日程Fit|「いつ空いてますか?」の往復はもう不要。候補日を選んでURLを送るだけ|登録不要|今すぐ無料で使う →

Blazorのファイルアップロードをフォーム送信時に保存する方法:IBrowserFileで採番ID(identity)をファイル名にする

Blazorでファイルアップロードを実装すると、昔のASP.NETのようにPostedFile.SaveAs()で即保存…とはいきません。さらに「DBへ先に登録して採番IDを取得し、そのIDをファイル名にして、フォーム送信(ボタン押下)で保存したい」という要件はつまずきやすいポイントです。この記事では、<InputFile>IBrowserFileを使って“選択は保持、保存は送信時”を安全に実現する方法を具体例付きで解説します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

なぜBlazorでは「PostedFileを受け取ってSaveAs」ができないのか

従来のASP.NET(Web Forms / MVCの一部の実装感)では、アップロードされたファイルはサーバーに届いたHTTPリクエストの一部として扱われ、サーバー側でPostedFileIFormFileのようなオブジェクトを直接受け取れました。ところがBlazorは「UIイベント駆動」で動くため、ファイルは<input type="file">(Blazorでは<InputFile>)を介してブラウザー側のファイル参照として扱われます。

観点従来のASP.NET(例)Blazor
ファイルの入口フォームPOST(multipart/form-data)UIイベント(<InputFile>OnChange
受け取る型PostedFile / IFormFileIBrowserFile
保存の基本SaveAs()相当で直接保存OpenReadStream()CopyToAsync()で書き込み
注意点リクエストサイズ制限、拡張子検証など既定のサイズ上限(小さめ)、レンダーモード、WASMはAPI経由など

つまりBlazorでは「サーバーに届いたファイルをその場で保存する」ではなく、ブラウザーが持っているファイルを、必要なタイミングで読み出して保存するという設計になります。ここに「採番IDをファイル名にしたい」要件を組み合わせると、実装は基本的に2段階になります。

結論:2段階に分けると要件が自然に満たせる

やりたいことを整理すると次の順番です。

  1. ユーザーがファイルを選択する(ただし、この時点では保存しない)
  2. ユーザーがフォーム送信(ボタン押下)する
  3. DBへ登録して採番ID(identityなど)を取得する
  4. IDをファイル名にしてサーバーへ保存する(例:12345.jpg

Blazorでこの流れを実現する最小構成は次の通りです。

  • ファイル選択時(OnChangeIBrowserFileをコンポーネントのフィールドやリストに保持しておく(保存しない)
  • 送信時(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 AppSSR + 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分離へ拡張していくのがおすすめです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次