Blazorでファイルアップロードを実装すると、昔のASP.NETのようにPostedFile.SaveAs()で即保存…とはいきません。さらに「DBへ先に登録して採番IDを取得し、そのIDをファイル名にして、フォーム送信(ボタン押下)で保存したい」という要件はつまずきやすいポイントです。この記事では、<InputFile>とIBrowserFileを使って“選択は保持、保存は送信時”を安全に実現する方法を具体例付きで解説します。
なぜBlazorでは「PostedFileを受け取ってSaveAs」ができないのか
従来のASP.NET(Web Forms / MVCの一部の実装感)では、アップロードされたファイルはサーバーに届いたHTTPリクエストの一部として扱われ、サーバー側でPostedFileやIFormFileのようなオブジェクトを直接受け取れました。ところがBlazorは「UIイベント駆動」で動くため、ファイルは<input type="file">(Blazorでは<InputFile>)を介してブラウザー側のファイル参照として扱われます。
| 観点 | 従来のASP.NET(例) | Blazor |
|---|---|---|
| ファイルの入口 | フォームPOST(multipart/form-data) | UIイベント(<InputFile>のOnChange) |
| 受け取る型 | PostedFile / IFormFile | IBrowserFile |
| 保存の基本 | SaveAs()相当で直接保存 | OpenReadStream() → CopyToAsync()で書き込み |
| 注意点 | リクエストサイズ制限、拡張子検証など | 既定のサイズ上限(小さめ)、レンダーモード、WASMはAPI経由など |
つまりBlazorでは「サーバーに届いたファイルをその場で保存する」ではなく、ブラウザーが持っているファイルを、必要なタイミングで読み出して保存するという設計になります。ここに「採番IDをファイル名にしたい」要件を組み合わせると、実装は基本的に2段階になります。
結論:2段階に分けると要件が自然に満たせる
やりたいことを整理すると次の順番です。
- ユーザーがファイルを選択する(ただし、この時点では保存しない)
- ユーザーがフォーム送信(ボタン押下)する
- DBへ登録して採番ID(identityなど)を取得する
- IDをファイル名にしてサーバーへ保存する(例:
12345.jpg)
Blazorでこの流れを実現する最小構成は次の通りです。
- ファイル選択時(
OnChange):IBrowserFileをコンポーネントのフィールドやリストに保持しておく(保存しない) - 送信時(
OnValidSubmitや@onclick):DB登録→ID取得→保存パス生成→OpenReadStream()→FileStreamへコピー
実装例:フォーム送信時に採番IDでファイルを保存する
ここでは「単一ファイルを選択し、フォーム送信でDB登録→ID採番→ID名で保存」という典型パターンを、できるだけそのまま使える形で紹介します。
前提:モデル(入力用DTO)
using System.ComponentModel.DataAnnotations;
public class CreateItemInput
{
[Required(ErrorMessage = "名前は必須です。")]
[StringLength(100)]
public string Name { get; set; } = "";
[StringLength(500)]
public string? Note { get; set; }
}
Razor:InputFileで選択したIBrowserFileを保持する
<EditForm>の送信イベントを使うと、バリデーションと「送信」タイミングが揃うので実装が安定します。ボタンはtype="submit"にします。
@page "/items/create"
@using Microsoft.AspNetCore.Components.Forms
@inject IWebHostEnvironment Env
@inject AppDbContext Db
<EditForm Model="_input" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<label>名前</label>
<InputText @bind-Value="_input.Name" />
</div>
<div>
<label>メモ</label>
<InputTextArea @bind-Value="_input.Note" />
</div>
<div>
<label>添付ファイル</label>
<InputFile OnChange="OnFileSelected" accept=".jpg,.png,.pdf" />
@if (_selectedFile is not null)
{
<p>選択中:@_selectedFile.Name(@_selectedFile.Size bytes)</p>
}
</div>
<button type="submit">登録して保存</button>
</EditForm>
@code {
private CreateItemInput _input = new();
private IBrowserFile? _selectedFile;
private const long MaxUploadBytes = 10 * 1024 * 1024; // 10MB(必要に応じて調整)
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".pdf"
};
private void OnFileSelected(InputFileChangeEventArgs e)
{
// ここでは保存しない。参照(IBrowserFile)を保持するだけ。
_selectedFile = e.File;
}
private async Task HandleValidSubmit()
{
// 1) 先にDBへ登録して採番IDを得る
var entity = new ItemEntity
{
Name = _input.Name,
Note = _input.Note
};
Db.Items.Add(entity);
await Db.SaveChangesAsync();
var id = entity.Id;
// 2) ファイルが選択されていれば、ID名で保存する
if (_selectedFile is not null)
{
await SaveFileAsync(id, _selectedFile);
}
// 必要に応じて画面遷移やメッセージ表示など
}
private async Task SaveFileAsync(int id, IBrowserFile file)
{
// 拡張子・サイズの検証(最低限)
var ext = Path.GetExtension(file.Name);
if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext))
{
throw new InvalidOperationException("許可されていない拡張子です。");
}
if (file.Size > MaxUploadBytes)
{
throw new InvalidOperationException("ファイルサイズが上限を超えています。");
}
// 保存先:公開不要なら wwwroot 配下は避け、アプリ直下などに置く
var uploadDir = Path.Combine(Env.ContentRootPath, "uploads");
Directory.CreateDirectory(uploadDir);
var safeFileName = $"{id}{ext.ToLowerInvariant()}";
var savePath = Path.Combine(uploadDir, safeFileName);
// OpenReadStreamの既定上限は小さいため、必ずmaxAllowedSizeを指定する
await using var input = file.OpenReadStream(MaxUploadBytes);
await using var output = new FileStream(
savePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await input.CopyToAsync(output);
}
}
ポイントは、ファイル選択時は「保持」だけにして、送信時にIDを確定させてから保存することです。この流れなら、ファイル名に採番IDを組み込む要件が自然に満たせます。
保存先パス設計:どこに置くべきか
アップロードファイルは「公開してよいか」「認可が必要か」で置き場所が変わります。安易にwwwroot配下へ保存すると、URLを知っている人が直接アクセスできてしまうケースがあります。
| 保存先 | 向いているケース | 注意点 |
|---|---|---|
wwwroot/uploads | 画像など公開して問題ないファイル | 基本的に誰でもURL直叩きで取得可能。機密ファイルは避ける |
ContentRootPath/uploads(アプリ配下) | ログイン必須、認可付きで配信したいファイル | 配信はController/Minimal APIで認可してから返す設計が必要 |
| 外部ストレージ(Azure Blob等) | スケール・バックアップ・冗長性を重視 | 署名付きURLや権限管理、料金設計が必要 |
安全に運用するための必須チェック
アップロードは攻撃の入口になりやすい領域です。最低限、次のポイントは実装時に入れてください。
| 項目 | やること | 理由 |
|---|---|---|
| サイズ上限 | OpenReadStream(maxAllowedSize)で制限 | 巨大ファイルでメモリ/ディスク/回線を枯渇させない |
| 拡張子ホワイトリスト | .jpgや.pdfなど必要最小限のみ許可 | 実行可能ファイルやスクリプト混入のリスクを下げる |
| ContentTypeの扱い | 参考程度にし、過信しない | クライアント申告なので偽装可能 |
| 保存ファイル名 | 元の名前は使わず、IDやGUIDで決める | パス・トラバーサルや文字化け、重複を回避 |
| 公開方法 | 機密なら直接配信せず、認可付きで返す | URL漏えいで閲覧される事故を防ぐ |
「拡張子だけ」でも不安な場合
より堅牢にするなら、いわゆる“マジックナンバー”(先頭バイト列)を確認して「見た目がjpgでも中身は別」のケースを弾く、ウイルススキャンを行う、といった対策も検討できます。業務システムでは、ファイル種別を増やすほど事故率が上がるため、許可する種類を絞るのが実務的に効きます。
Blazor Server / WebAssembly / .NET 8 Blazor Web App の違いで変わる実装
Blazorのホスティング形態によって「どこでコードが動くか」「どうやってサーバーへ保存するか」が変わります。特にBlazor WebAssemblyは、ブラウザー内で動くため、サーバーのディスクへ直接書き込めません。
| 方式 | 実行場所 | 保存の基本戦略 | よくある落とし穴 |
|---|---|---|---|
| Blazor Server | サーバー | IBrowserFile.OpenReadStream() → サーバーで保存 | 接続切れやタイムアウト、サイズ制限の設計 |
| Blazor WebAssembly | ブラウザー | Web APIへHttpClientでアップロード → サーバーで保存 | 「サーバーに保存するコード」をWASM側に書いても動かない |
| .NET 8 Blazor Web App | SSR + Interactive(混在) | Interactiveなコンポーネントでイベント発火→保存 | Static SSRのままだとOnChangeや@onclickが発火しない |
.NET 8 Blazor Web Appでイベントが発火しないときの確認ポイント
Blazor Web AppはSSR(サーバーでHTML生成)を前提にしつつ、必要な部分だけInteractiveにできます。ファイル選択やボタンクリックは「ブラウザーでのイベント」なので、対象コンポーネントがInteractiveで動いていないとイベントが発火しません。
- ページやコンポーネントに
@rendermode InteractiveServer/InteractiveWebAssembly/InteractiveAutoが付いているか - レイアウトやルートの設定でInteractiveが無効になっていないか
- ブラウザーのコンソールでスクリプトエラーが出ていないか
@rendermode InteractiveServer
<!-- これでこのコンポーネント内のOnChange/@onclickが動作します -->
Blazor WebAssemblyの場合:Web APIへ送ってサーバー側で保存する
WASMはクライアント実行なので、最終的にはサーバーへアップロードするAPIが必要です。ただし今回の要件は「採番IDでファイル名を決めたい」なので、API設計も2段階に寄せると分かりやすいです。
- フォーム送信で「DB登録API」を呼ぶ → IDを返す
- 続けて「ファイルアップロードAPI」にIDを渡してアップロード → サーバー側で
{id}.{ext}で保存
クライアント(WASM):Multipartで送信する例
@inject HttpClient Http
private IBrowserFile? _selectedFile;
private async Task SubmitAsync()
{
// 1) DB登録(例:POST /api/items)
var createResponse = await Http.PostAsJsonAsync("/api/items", _input);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync();
var id = created!.Id;
// 2) ファイルがあればアップロード(例:POST /api/items/{id}/file)
if (_selectedFile is not null)
{
using var content = new MultipartFormDataContent();
await using var stream = _selectedFile.OpenReadStream(MaxUploadBytes);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue(_selectedFile.ContentType);
content.Add(fileContent, "file", _selectedFile.Name);
var uploadResponse = await Http.PostAsync($"/api/items/{id}/file", content);
uploadResponse.EnsureSuccessStatusCode();
}
}
サーバー側(API):IFormFileで受け取り、ID名で保存する例
API側は通常のASP.NET Coreとして実装できます。保存名はクライアント任せにせず、サーバーでIDから決めるのが基本です。
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/items")]
public class ItemsController : ControllerBase
{
private readonly IWebHostEnvironment _env;
private readonly AppDbContext _db;
public ItemsController(IWebHostEnvironment env, AppDbContext db)
{
_env = env;
_db = db;
}
[HttpPost("{id:int}/file")]
[RequestSizeLimit(10 * 1024 * 1024)] // 例:10MB
public async Task<IActionResult> UploadFile(int id, IFormFile file)
{
if (file is null || file.Length == 0)
return BadRequest("ファイルがありません。");
// 例:DBに存在するIDか確認(認可もここで)
var exists = await _db.Items.AnyAsync(x => x.Id == id);
if (!exists)
return NotFound();
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var allowed = new HashSet<string> { ".jpg", ".jpeg", ".png", ".pdf" };
if (!allowed.Contains(ext))
return BadRequest("許可されていない拡張子です。");
var uploadDir = Path.Combine(_env.ContentRootPath, "uploads");
Directory.CreateDirectory(uploadDir);
var savePath = Path.Combine(uploadDir, $"{id}{ext}");
await using var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
await file.CopyToAsync(fs);
return Ok();
}
}
WASMの場合は「選択したIBrowserFileを保持しておく」こと自体は同じですが、保存処理はサーバーAPIへ委譲する点が決定的に違います。
より実務向け:失敗時の整合性(DBとファイルのズレ)をどうするか
「DB登録→ファイル保存」は、途中で失敗すると整合性が崩れます。例えばDBは登録されたのにファイル保存で例外が起きた、という状態です。ファイルシステムはDBトランザクションに含められないため、実務では次のような方針を決めておくと運用が安定します。
| 方針 | 流れ | メリット | デメリット |
|---|---|---|---|
| DB先行(シンプル) | DB登録→ファイル保存→失敗ならエラー表示 | 実装が簡単 | DBだけ残る可能性。後処理が必要 |
| ステータス管理 | DB登録(FileStatus=Pending)→保存成功でCompleted | ズレが見える化され運用しやすい | 項目追加と画面・バッチが必要 |
| 一時保存→確定リネーム | 選択時に一時保存(GUID名)→送信時にID名へ移動 | 送信時の失敗が減る。再送もしやすい | 一時領域の掃除(期限切れ削除)が必要 |
「ボタン押下時に保存したい」要件が強い場合でも、入力画面が長く、ユーザーが途中で離席する可能性があるなら、一時保存(トークン方式)を検討すると事故が減ります。ファイルを“保持”し続ける設計は、接続切れや再読み込みに弱いからです。
複数ファイルに拡張する場合の考え方
複数ファイルを許可するなら、<InputFile multiple>とGetMultipleFiles()を使います。ファイル名ルールは「IDだけ」だと衝突するため、実務では次のどれかに寄せます。
{id}_{連番}.{ext}{id}/{guid}.{ext}のようにIDフォルダ配下へ格納- ファイル用テーブル(ItemFiles)を作り、ファイル側も採番して
fileId.extにする
private List<IBrowserFile> _selectedFiles = new();
private void OnFilesSelected(InputFileChangeEventArgs e)
{
_selectedFiles = e.GetMultipleFiles().ToList();
}
よくある詰まりポイントと解決策
| 症状 | 原因 | 対処 |
|---|---|---|
| 大きめのファイルで例外が出る | OpenReadStream()の既定上限(小さい)を超えた | OpenReadStream(maxAllowedSize)で上限を明示し、同時にサイズ検証も入れる |
OnChangeや@onclickが動かない | Blazor Web AppがStatic SSRのまま | @rendermodeをInteractiveにする。レイアウト/ルート設定も確認 |
| 保存先フォルダがなくて失敗する | ディレクトリ未作成 | Directory.CreateDirectory()を必ず呼ぶ |
| 同名ファイルで上書きされる | FileMode.Createで毎回作成している | 上書き不可にするならCreateNewや存在チェック、バージョン管理を導入 |
| 公開されてはいけないファイルが見えてしまう | wwwroot配下へ保存した | アプリ外部/非公開領域へ保存し、認可付きで配信する |
まとめ:Blazorのアップロードは「保持」と「保存」を分離すると強い
BlazorではIBrowserFileを起点に、選択時は保持、送信時にDB採番→ID名で保存という2段階にすると、要件がきれいに実現できます。あわせて、サイズ制限・拡張子制限・保存先の公開範囲・整合性(DBとファイル)まで設計しておくと、後から困りません。まずはこの記事の最小実装を動かし、運用要件に応じて一時保存方式やAPI分離へ拡張していくのがおすすめです。

コメント