Blazor Web App から HttpCLient を使ってファイルアップロードする方法を書きます。ベースとしたのは Visual Studio 2022 のテンプレートを使って、ターゲットフレームワークを .NET 9.0 に、 [Interactive render mode] を [WebAssembly] に、 [Interactive location] を [Per page/component] に設定して作成したプロジェクトです。設定が異なる場合は下の説明の中には当てはまらない事がありますので注意してください。

JavaScript の fetch を使ってアップロードする例は先の記事「Blazor Web App でファイルアップロード」に書きました。それに比べて、HttpClient を使���場合は、Program.cs での HttpClient の登録が必要なこと、本来ブラウザでは使えない HttpClient を使うことの違和感などが気になりました。それでも Blazor では JavaScript より HttpClient を使うのが本筋のようです。
この記事の例では、ファイルのアップロード先は外部 Web API または本 Blazor アプリのプロジェクト内に実装した Web API としました。その Web API のアクションメソッドに Blazor Web App の Razor コンポーネントから HttpClient を使ってファイルを送信します。(実際にはブラウザから送信されますので、HttpClient から生成された WebAssembly が送信しているのだろうと思いますが、具体的にどうなっているのかは分かりません)
Visual Studio でプロジェクトを作成すると、下のソリューションエクスプローラーの画像の構成となります。青枠と赤枠の中のフォルダ / ファイルはこの記事を書く際に追加したものです。青枠がプロジェクト内に作成した Web API とファイル保存用のフォルダ、赤枠がファイルをアップロードする Razor コンポーネントです。

この記事の例は、Microsoft のドキュメント「クライアント側のレンダリング (CSR) を使用するサーバーへのファイルのアップロード」に該当します。それを読んですべて分かれば良いのですが、自分は読んでも分からないことがありました。
なので、この記事に、Microsoft のドキュメントを読んでも分からなかったことを調べて書くとともに、実装を基本的な部分のみに簡略化したコードを備忘録として載せることにしました。
(1) InputFile クラスの使用
html の <input type="file"> を使った場合は、ユーザーが選択したファイルのデータを HttpClient の C# のコードに渡すことができません (裏ワザとかがあるかもしれませんが)。
InputFile クラスを使用すると、ユーザーがファイルの選択を完了したときに発生する OnChange イベントのハンドラの引数に InputFileChangeEventArgs オブジェクト が渡され、ユーザーが選択したファイルのデータを HttpClient の C# のコードで取得できます。
それゆえ、アップロードするのに HttpClient を使う場合は、InputFile クラスを使用するほか選択肢はなさそうです。
(2) Razor コンポーネントの配置場所
上のソリューションエクスプローラーの画像に示すように、メインプロジェクトとクライアントプロジェクト (.Client) の 2 つのプロジェクトが作られます。そのどちらにも Razor コンポーネントを配置できるのですが、今回のファイルをアップロードする Razor コンポーネントはどちらに配置すればいいのでしょうか?
答えはクライアントプロジェクト (.Client) 側です。上のソリューションエクスプローラーの画像の赤枠で示した FileUpload.razor がそれです。理由は以下の通りです。
Microsoft のドキュメント「ASP.NET Core Blazor プロジェクトの構造」に "対話型 WebAssembly または対話型自動レンダリング モードを使用するコンポーネントは、.Client プロジェクトに配置する必要があります" と書いてあります。
「対話型」の対話というのはユーザーとの対話を意味します。例えばテンプレートで自動生成されるコンポーネント Counter.razor のようにユーザーのクリックに応じてカウント値が変わっていくものが該当するようです。InputFile クラスの OnChange イベントで処理を行うのも対話に含まれるようです。
実は、最初 FileUpload.razor はメインプロジェクトに配置していたのですが、OnChange のハンドラが動かず、その解決に半日ほどハマったのは内緒です。(笑)
(3) Program.cs で HttpClient サービスの追加
HttpClient サービスの追加をメインプロジェクとクライアントプロジェクト (.Client) 両方の Program.cs で行う必要があります。
上に紹介した Microsoft のドキュメントに書いてある通り、メインプロジェクトの Program.cs に、
// IHttpClientFactory および関連するサービスを追加
builder.Services.AddHttpClient();
クライアントプロジェクト (.Client) の Program.cs に、
// Web API に対する POST 要求のための HttpClient の登録
builder.Services.AddScoped(sp =>
new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
・・・を追加します。(注: 後者のコードは「Blazor WebAssembly スタンドアロン アプリ」のテンプレートを使って作成した Blazor WebAssembly プロジェクトの Prpgram.cs には最初から含まれています)
でも、考えてみると HttpClient を使うのはクライアントプロジェクト (.Client) の FileUpload.razor なのに、なぜメインプロジェクトの Program.cs でも追加しなければならないのかが腑に落ちません。
Microsoft のドキュメントには "クライアント側コンポーネントはサーバーでプリレンダリングされるため、HttpClient サービスをメインプロジェクトに追加する必要があります。対話型コンポーネントではプリレンダリングが既定で有効になっています" と書いてありました。そう言われても、その仕組みは全く理解できていませんが。(汗)
上のクライアントプロジェクト (.Client) の Program.cs での設定例では、HttpClient の BaseAddres プロパティを設定していますがその理由を書きます。
builder.HostEnvironment.BaseAddress というのは Blazor アプリのベースアドレスで、この記事の例では https://localhost:44340/ となります。
Microsoft のドキュメント「HttpClient.BaseAddress プロパティ」に "相対 URI を使用して HttpRequestMessage を送信すると、メッセージ Uri が BaseAddress プロパティに追加され、絶対 URI が作成されます" と書かれています。
なので、Blazor アプリ内の Web API (上のソリューションエクスプローラーの画像の青枠がそれ) にファイルを送信する場合は、上のコードのように BaseAddress プロパティを設定しておくと、FileUpload.razor の HttpClient のコードで URI を指定する際、相対 URI を使うことができるようになります。
外部 Web API にファイルを送信する場合は、FileUpload.razor の HttpClient のコードで絶対 URI を指定しますが、その場合はそれが HttpClient の BaseAddress に追加されるということはなく、コードで指定した絶対 URI がそのまま使用されます (ドキュメントにはそのことは書いてないですが検証して確認)。
HttpClient のコードで常に絶対 URI を指定するのであれば上のような HttpClient の BaseAddress の設定は不要で、以下のようにすれば良いです。
// FileUpload.razor の Http.PostAsync(url, content) の url に
// http から始まる絶対 URI を指定するのであれば以下で良い
builder.Services.AddScoped(sp => new HttpClient());
(4) FileUpload.razor
ユーザーがファイルを選択してアップロードする razor コンポーネント (FileUpload.razor) のサンプルコードは以下の通りです。Blazor Web App はデフォルトでは static rendering になるそうで、@rendermode InteractiveWebAssembly を設定しないと対話型にならないので注意してください。
@page "/fileupload"
@rendermode InteractiveWebAssembly
@using System.Net.Http.Headers
@inject HttpClient Http
<h3>Upload File</h3>
<InputFile OnChange="OnInputFileChange" multiple />
<br />
<p>@result</p>
@code {
// 外部 Web API の URI
// private string url = "https://localhost:44366/FileUpDownload/multiple";
// Blazor アプリのプロジェクト内に作成した Web API の URI
// Program.cs での HttpClient の登録で BaseAddress に本 Blazor
// アプリのベースアドレスを設定してあるので相対 URI を使用可
private string url = "/FileSave";
private string result = string.Empty;
private async Task OnInputFileChange(InputFileChangeEventArgs e)
{
using var content = new MultipartFormDataContent();
// GetMultipleFiles メソッドの引数 maximumFileCount は
// デフォルトで 10。超えると例外がスローされる
foreach (var file in e.GetMultipleFiles())
{
// OpenReadStream メソッドの引数 maxAllowedSize は
// デフォルトで 500KB。超えると例外がスローされる
var streamContent = new StreamContent(file.OpenReadStream());
streamContent.Headers.ContentDisposition =
new ContentDispositionHeaderValue("form-data")
{
// Name は Web API の引数名に合わせる
Name = "postedfiles",
FileName = Path.GetFileName(file.Name)
};
streamContent.Headers.ContentType =
new MediaTypeHeaderValue(file.ContentType);
content.Add(streamContent);
}
HttpResponseMessage response = await Http.PostAsync(url, content);
// Web API からの応答のコンテンツの文字列を取得
result = await response.Content.ReadAsStringAsync();
}
}
上のコードでは、ユーザーがファイルの選択を完了すると、ファイルは即 Web API に送信されます。
それを、ユーザーがファイルを選択した後でボタンクリックによりアップロードしたい場合は、OnInputFileChange メソッドでは e.GetMultipleFiles() メソッドで IReadOnlyList<IBrowserFile> オブジェクトを取得してそれを保持するに留め、ボタンクリックのハンドラで保持された IReadOnlyList<IBrowserFile> オブジェクトからファイルデータを取得して送信するのが良さそうです。
(5) Web API
FileUpload.razor から送信されたファイルを受け取って UploadedFiles フォルダに保存する Web API のサンプルコードを下に載せておきます。
この記事のコードでは Microsoft の記事「ASP.NET Core でファイルをアップロードする」に書かれたセキュリティに関する配慮はされていませんので注意してください。例えば「アプリと同じディレクトリツリーに、アップロードしたファイルを保持しないでください」とありますが、アプリケーションルート直下の UploadedFiles というフォルダに、アップロードされたファイルをチェックせず、ユーザーによって指定されたファイル名でそのまま保存するようになっています。
using Microsoft.AspNetCore.Mvc;
namespace BlazorWebAppWASM.Controllers
{
[Route("[controller]")]
[ApiController]
public class FileSaveController : ControllerBase
{
// 物理パスの取得用
private readonly IWebHostEnvironment _hostingEnvironment;
public FileSaveController(IWebHostEnvironment hostingEnvironment)
{
this._hostingEnvironment = hostingEnvironment;
}
[HttpPost]
public async Task<IActionResult> MuilipleFiles(List<IFormFile>? postedFiles)
{
string result = "Uploaded files: ";
if (postedFiles != null)
{
// アプリケーションルートの物理パスを取得
// wwwroot の物理パスは WebRootPath プロパティを使う
string contentRootPath = _hostingEnvironment.ContentRootPath;
foreach (var postedFile in postedFiles)
{
if (postedFile != null && postedFile.Length > 0)
{
// アップロードされたファイル名を取得
string filename = Path.GetFileName(postedFile.FileName);
// アプリケーションルート直下の UploadedFiles フォルダに書き込み
string filePath = $"{contentRootPath}\\UploadedFiles\\{filename}";
using (var stream = new FileStream(filePath, FileMode.Create))
{
await postedFile.CopyToAsync(stream);
}
result += $"{filename} ";
}
}
}
else
{
result = "postedFiles is null";
}
return Content(result);
}
}
}
Blazor アプリのプロジェクト内に実装する場合は、上のソリューションエクスプローラーの画像の青枠に示したようにメインプロジェクトに配置します。さらに、メインプロジェクトの Program.cs に下の 2 行を追加します。
// Controller を使用できるようサービスを追加
builder.Services.AddControllers();
// ・・・中略・・・
// 属性でルーティングされたコントローラーをマップ
app.MapControllers();
外部 Web API に上と同様なコードを実装して FileUpload.razor から送信されたファイルを受け取ることもできます。ただし、その場合はクロスドメインになるので、Web API 側のサーバーが CORS 対応している必要があります。
HttpClient を使っていると言っても、結局それは WebAssembly に変換されてブラウザに送信され、ブラウザから Web API に要求が出るので、クロスドメインでの要求は、JavaScript の fetch を使った場合と同様に、CORS 対応がされてないと失敗します。
外部 Web API のサーバーで CORS 対応がされていれば、下の Fiddler でのキャプチャ画像のように #95 でプリフライトリクエストが出て、#96 でファイルが送信されます。
