Blazor WebAssembly/Blazor Server のどちらでも、ユーザーのローカルから選択されたファイルを安全に受け取り、SQL Server などのデータベースへ保存・配信するには「UI・検証・ストレージ・配信」の4点を設計する必要があります。本記事では小〜中サイズは EF Core、数十MB〜GB 級は ADO.NET ストリーミングで処理する実装を、サンプルコード・スキーマ・設計判断の材料と共に網羅的に解説します。
Blazor でファイルをデータベースに保存する全体像
最初に、この記事で解決する課題をまとめます。
| 手順 | 内容 |
|---|---|
| 1. アップロード UI | Blazor の <InputFile> コンポーネントを使用。OnChange で IBrowserFile を受け取り、検証・送信を開始。 |
| 2. ファイル読み取り | file.OpenReadStream(maxAllowedSize) で Stream を取得。小容量は MemoryStream にコピー、⼤容量はストリームのまま送信・保存。 |
| 3. データベース設計 | 本体は varbinary(max)。MIME や拡張子・サイズ・作成日時は nvarchar/bigint/ datetime2 等でメタデータ列を持つ。 |
| 4. 保存処理 | 小〜中サイズは EF Core の byte[] マッピングで INSERT。数十MB以上は ADO.NET による UPDATE ... .WRITE() でチャンク書き込みし、サーバーのメモリを消費しない。 |
| 5. 取得/表示 | 配信用エンドポイントからストリームで返却。Results.Stream / FileStreamResult と Range 対応でレジューム・シークを有効化。 |
| 6. 大容量/大量ファイルの代案 | DB 膨張・バックアップ時間を避けるなら、ファイル実体は外部ストレージ(ファイルシステム・Blob)に保存し、DB には URI とハッシュ等のメタデータのみ保存。 |
以降は、WASM と Server 双方で動く UI、SQL Server を例にした DB スキーマ、EF Core/ADO.NET の保存実装、配信・セキュリティ・運用ノウハウ の順に具体化していきます。
前提と方針(WASM と Server の違いを押さえる)
- Blazor WebAssembly:ブラウザ内で実行。
<InputFile>から得たStreamを HTTP API へ送ってサーバー側で DB 保存する。 - Blazor Server:イベント処理はサーバー。
IBrowserFile.OpenReadStream()でクライアント→サーバーへストリーミング転送されるため、そのまま DB へ流し込み可能。 - データベースは SQL Server を例に記述(
varbinary(max))。PostgreSQL ならbytea、MySQL ならLONGBLOBなどで読み替えればOK。
DB スキーマ(SQL Server)
CREATE TABLE dbo.Files (
Id uniqueidentifier NOT NULL CONSTRAINT PK_Files PRIMARY KEY,
FileName nvarchar(260) NOT NULL,
ContentType nvarchar(127) NOT NULL,
Length bigint NOT NULL,
Blob varbinary(max) NULL,
Sha256 char(64) NULL, -- 検知・重複排除用(任意)
CreatedAt datetime2(7) NOT NULL CONSTRAINT DF_Files_CreatedAt DEFAULT (sysutcdatetime()),
RowVersion rowversion NOT NULL
);
GO
CREATE INDEX IX_Files_CreatedAt ON dbo.Files (CreatedAt DESC);
CREATE INDEX IX_Files_Sha256 ON dbo.Files (Sha256);
設計のポイント
- ファイル名は Windows 互換の 260 文字を上限に例示。必要に応じて拡張。
- ハッシュ(
Sha256)は重複検出や改ざん検出の運用に有効。 RowVersionは同時更新保護(楽観的同時実行制御)に利用。
小〜中サイズ(〜数MB)向け:EF Core で byte[] 保存
Entity と DbContext
public sealed class DbFile
{
public Guid Id { get; set; }
public string FileName { get; set; } = default!;
public string ContentType { get; set; } = default!;
public long Length { get; set; }
public byte[] Blob { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public byte[] RowVersion { get; set; } = default!;
}
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet Files => Set();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<DbFile>(e =>
{
e.Property(x => x.Blob).HasColumnType("varbinary(max)").IsRequired();
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
e.Property(x => x.ContentType).HasMaxLength(127).IsRequired();
e.Property(x => x.CreatedAt).HasDefaultValueSql("sysutcdatetime()");
e.Property(x => x.RowVersion).IsRowVersion();
});
}
}
保存サービス(EF Core)
小〜中サイズは MemoryStream に取り込み byte[] で保存するのが最もシンプルです。
public interface IFileStorage
{
Task<Guid> SaveAsync(Stream input, string fileName, string contentType, long length, CancellationToken ct);
Task<(string FileName, string ContentType, long Length)> GetMetaAsync(Guid id, CancellationToken ct);
Task CopyToAsync(Guid id, Stream output, CancellationToken ct);
}
public sealed class EfCoreFileStorage : IFileStorage
{
private readonly AppDbContext _db;
public EfCoreFileStorage(AppDbContext db) => _db = db;
public async Task<Guid> SaveAsync(Stream input, string fileName, string contentType, long length, CancellationToken ct)
{
using var ms = new MemoryStream(capacity: (int)Math.Min(length, 8 * 1024 * 1024)); // 予測容量
await input.CopyToAsync(ms, ct);
var entity = new DbFile
{
Id = Guid.NewGuid(),
FileName = fileName,
ContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType,
Length = length,
Blob = ms.ToArray()
};
_db.Add(entity);
await _db.SaveChangesAsync(ct);
return entity.Id;
}
public async Task<(string FileName, string ContentType, long Length)> GetMetaAsync(Guid id, CancellationToken ct)
=> await _db.Files
.Where(f => f.Id == id)
.Select(f => new ValueTuple<string, string, long>(f.FileName, f.ContentType, f.Length))
.SingleAsync(ct);
public async Task CopyToAsync(Guid id, Stream output, CancellationToken ct)
{
var blob = await _db.Files
.Where(f => f.Id == id)
.Select(f => f.Blob)
.SingleAsync(ct);
using var input = new MemoryStream(blob, writable: false);
await input.CopyToAsync(output, 81920, ct);
}
}
備考:EF Core は byte[] を全読み込みするため、巨大ファイルではメモリ圧迫が発生します。数十 MB を超える可能性があるなら、次章の ADO.NET ストリーミングへ移行してください。
数十MB〜GB 向け:ADO.NET(UPDATE ... .WRITE())でチャンク保存
SQL Server の varbinary(max) 列は、チャンク(断片)を連結する .WRITE() 構文を備えます。これを使えばアプリのメモリに乗せずに DB へ追加書き込みが可能です。
using System.Data;
using Microsoft.Data.SqlClient;
public sealed class SqlChunkedFileStorage : IFileStorage
{
private readonly string _conStr;
public SqlChunkedFileStorage(IConfiguration cfg) => _conStr = cfg.GetConnectionString("Default")!;
public async Task<Guid> SaveAsync(Stream input, string fileName, string contentType, long length, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var con = new SqlConnection(_conStr);
await con.OpenAsync(ct);
await using var tx = await con.BeginTransactionAsync(ct);
// まずメタデータだけを INSERT(本体は空の 0x)
await using (var insert = con.CreateCommand())
{
insert.Transaction = (SqlTransaction)tx;
insert.CommandText = @"INSERT INTO dbo.Files (Id, FileName, ContentType, Length, Blob)
VALUES (@Id, @FileName, @ContentType, @Length, 0x)";
insert.Parameters.Add(new SqlParameter("@Id", SqlDbType.UniqueIdentifier) { Value = id });
insert.Parameters.Add(new SqlParameter("@FileName", SqlDbType.NVarChar, 260) { Value = fileName });
insert.Parameters.Add(new SqlParameter("@ContentType", SqlDbType.NVarChar, 127) { Value = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType });
insert.Parameters.Add(new SqlParameter("@Length", SqlDbType.BigInt) { Value = length });
await insert.ExecuteNonQueryAsync(ct);
}
var buffer = new byte[1024 * 1024]; // 1MB チャンク(運用で調整)
int read;
while ((read = await input.ReadAsync(buffer, 0, buffer.Length, ct)) > 0)
{
await using var update = con.CreateCommand();
update.Transaction = (SqlTransaction)tx;
update.CommandText = @"UPDATE dbo.Files SET Blob .WRITE(@chunk, NULL, 0) WHERE Id = @id";
// 直近のチャンクだけを送る
var chunk = new byte[read];
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
update.Parameters.Add(new SqlParameter("@chunk", SqlDbType.VarBinary, read) { Value = chunk });
update.Parameters.Add(new SqlParameter("@id", SqlDbType.UniqueIdentifier) { Value = id });
await update.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
return id;
}
public async Task<(string FileName, string ContentType, long Length)> GetMetaAsync(Guid id, CancellationToken ct)
{
await using var con = new SqlConnection(_conStr);
await con.OpenAsync(ct);
await using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT FileName, ContentType, Length FROM dbo.Files WHERE Id = @Id";
cmd.Parameters.Add(new SqlParameter("@Id", SqlDbType.UniqueIdentifier) { Value = id });
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (!await rd.ReadAsync(ct)) throw new FileNotFoundException();
return (rd.GetString(0), rd.GetString(1), rd.GetInt64(2));
}
// DB からレスポンスへ直接ストリーミング(SequentialAccess)
public async Task CopyToAsync(Guid id, Stream output, CancellationToken ct)
{
await using var con = new SqlConnection(_conStr);
await con.OpenAsync(ct);
await using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT Blob FROM dbo.Files WHERE Id = @Id";
cmd.Parameters.Add(new SqlParameter("@Id", SqlDbType.UniqueIdentifier) { Value = id });
await using var rd = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess, ct);
if (!await rd.ReadAsync(ct)) throw new FileNotFoundException();
await using var dbStream = rd.GetStream(0);
await dbStream.CopyToAsync(output, 81920, ct);
}
}
運用ヒント
- ログ肥大を避けるにはチャンクサイズを適切化(1〜4MB など)。
- 1 件の巨大アップロードはトランザクション時間が長くなるため、タイムアウトに注意。
- 読み出しは
SequentialAccess+GetStreamでメモリ使用を最小化。
API(Minimal API)でアップロード/ダウンロードを実装
Program.cs(共通設定)
var builder = WebApplication.CreateBuilder(args);
// 大容量に備えリクエスト制限を調整(必要な範囲で)
builder.Services.Configure(o =>
{
o.MultipartBodyLengthLimit = 1L * 1024 * 1024 * 1024; // 1GB
});
builder.WebHost.ConfigureKestrel(o =>
{
o.Limits.MaxRequestBodySize = 1L * 1024 * 1024 * 1024; // リバースプロキシ/IIS使用時はそちら側も調整
});
// DI
builder.Services.AddDbContext(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped(); // もしくは EfCoreFileStorage
var app = builder.Build();
app.UseRouting();
POST /api/files(アップロード)
app.MapPost("/api/files", async (HttpRequest req, IFileStorage storage, CancellationToken ct) =>
{
var form = await req.ReadFormAsync(ct);
var file = form.Files["file"];
if (file is null) return Results.BadRequest("file is required.");
if (file.Length == 0) return Results.BadRequest("empty file.");
// サーバー側 MIME/署名検証(後述のヘルパーを利用してもよい)
await using var stream = file.OpenReadStream(); // 必要に応じ max を指定
var id = await storage.SaveAsync(stream, file.FileName, file.ContentType ?? "application/octet-stream", file.Length, ct);
return Results.Json(new { id });
});
GET /api/files/{id}(ダウンロード/配信)
app.MapGet("/api/files/{id:guid}", async (Guid id, IFileStorage storage, CancellationToken ct) =>
{
(string fileName, string contentType, long length) = await storage.GetMetaAsync(id, ct);
return Results.Stream(
async (body, _) => await storage.CopyToAsync(id, body, ct),
contentType,
fileDownloadName: fileName,
enableRangeProcessing: true // Range対応でレジューム/シークが可能
);
});
フロントエンド(Blazor)実装:<InputFile> とアップロード
共通の Razor コンポーネント例
@page "/upload"
@inject HttpClient Http
@if (progressPercent > 0)
{
進捗: @progressPercent%
}
@code {
private int progressPercent;
private async Task OnInputFileChange(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles())
{
// サイズ上限(クライアント側の早期リジェクト)
const long maxAllowedSize = 1L * 1024 * 1024 * 1024; // 1GB
if (file.Size > maxAllowedSize)
{
// ユーザーに通知
continue;
}
// IBrowserFile -> Stream
await using var src = file.OpenReadStream(maxAllowedSize);
// 進捗付きで multipart/form-data に載せる
var content = new MultipartFormDataContent();
var progress = new Progress<long>(sent =>
{
progressPercent = (int)(sent * 100 / file.Size);
InvokeAsync(StateHasChanged);
});
var streamContent = new ProgressStreamContent(src, file.ContentType ?? "application/octet-stream", file.Size, progress);
content.Add(streamContent, "file", file.Name);
var res = await Http.PostAsync("api/files", content);
res.EnsureSuccessStatusCode();
progressPercent = 0;
}
}
// HttpContent のシリアライズ過程で Read -> Write 毎に進捗を出すラッパー
private sealed class ProgressStreamContent : HttpContent
{
private readonly Stream _source;
private readonly IProgress<long>? _progress;
private readonly long _length;
private readonly int _bufferSize;
public ProgressStreamContent(Stream source, string contentType, long length, IProgress<long>? progress = null, int bufferSize = 81920)
{
_source = source;
_progress = progress;
_length = length;
_bufferSize = bufferSize;
Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
}
protected override async Task SerializeToStreamAsync(Stream target, TransportContext? context)
{
var buffer = new byte[_bufferSize];
int read;
long total = 0;
while ((read = await _source.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read));
total += read;
_progress?.Report(total);
}
}
protected override bool TryComputeLength(out long length) { length = _length; return true; }
}
}
Blazor Server の補足:非常に大きいファイルを扱う場合は、サーバー側の受信上限(SignalR の MaximumReceiveMessageSize など)を必要範囲で拡張してください。WASM ではバックエンド API 側の MultipartBodyLengthLimit 等を調整します。
MIME/拡張子検証・署名(マジックナンバー)チェック
クライアント申告の Content-Type や拡張子に依存せず、サーバーで先頭バイトを検査しましょう。
| 形式 | 先頭シグネチャ |
|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| JPEG | FF D8 FF |
| 25 50 44 46 2D | |
| ZIP/Office | 50 4B 03 04 |
| GIF | 47 49 46 38 |
public static class MimeSniffer
{
public static bool LooksLikePdf(ReadOnlySpan<byte> head)
=> head.Length >= 5 && head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46 && head[4] == 0x2D;
public static async Task<(string Mime, bool Safe)> DetectAsync(Stream s, CancellationToken ct)
{
var buf = new byte[16];
var read = await s.ReadAsync(buf.AsMemory(0, buf.Length), ct);
s.Position = 0; // 後続処理のため巻き戻し
if (read >= 8 && buf[0] == 0x89 && buf[1] == 0x50) return ("image/png", true);
if (read >= 3 && buf[0] == 0xFF && buf[1] == 0xD8) return ("image/jpeg", true);
if (LooksLikePdf(buf)) return ("application/pdf", true);
// 未知のものは octet-stream とする
return ("application/octet-stream", false);
}
}
運用 TIP:検知結果が Safe=false の場合は保留や追加審査(サンドボックスで開く、ウイルススキャンを強化する等)を行うと現場で事故が減ります。
ウイルス対策・セキュリティ・コンプライアンス
- ウイルススキャン:アップロード直後にスキャン。スキャンは非同期キュー(メッセージング)で行い、結果 NG はダウンロード不可に。
- Content-Disposition:配信時は
attachmentでダウンロードさせ、XSS の踏み台化を防止。 - X-Content-Type-Options:
nosniffを付与。 - サーバー側バリデーション:拡張子・署名・サイズ・禁止拡張子(.exe 等)をチェック。
- 暗号化:DB 暗号化(TDE)やアプリ層 AES 暗号化(鍵は KMS)で保護。
- 監査・追跡:アップロード者・時刻・ハッシュ・元 IP を監査ログへ。
大容量・大量ファイルの代案(外部ストレージ + DB メタデータ)
DB に BLOB を入れるとバックアップ時間やスケールに影響します。以下の折衷構成が実務で安定します。
| 保存方式 | 長所 | 短所 | 向き/不向き |
|---|---|---|---|
| DB に実体(本記事の方法) | 一貫したトランザクション・整合性。バックアップ一体化。 | DB 増大・バックアップ肥大・I/O コスト。 | 台数少・件数少、中規模まで。 |
| 外部ストレージに実体、DB に URI/ハッシュのみ | スケール容易・CDN 併用・安価。 | 二相整合性や署名 URL の実装が必要。 | 大容量・大量配信、マイクロサービス。 |
Blazor Server 特有の設定(必要に応じて)
// Program.cs
builder.Services.AddServerSideBlazor()
.AddHubOptions(o =>
{
// 受信メッセージ上限(必要に応じてだけ拡張)
o.MaximumReceiveMessageSize = 64L * 1024 * 1024; // 例: 64MB
});
ただし、単純に上限を上げるだけでなく、巨大ファイルは API へ直接送る(WASM と同様の経路に寄せる)設計も検討してください。
ダウンロードの最適化:Range/キャッシュ/ETag
- Range 対応:
Results.Stream(..., enableRangeProcessing: true)でメディアのシークが快適に。 - キャッシュ制御:公開可否に応じて
Cache-Control: private, max-age=...orno-store。 - ETag:ハッシュを ETag にすれば、整合性検証と無駄な再送防止が両立。
アップロード前チェックリスト
- 拡張子と MIME の両方をチェックし、許可リスト方式で絞る。
- サイズ上限(クライアントとサーバーの両方)を明示。
- サーバー側でのタイムアウト・同時実行数を調整。
- 監査ログ・通知(失敗/成功)・リトライ戦略を用意。
- ウイルススキャンのバイパス経路が無いことを確認。
トラブルシューティング
| 症状 | 原因の典型 | 対処 |
|---|---|---|
| すぐに 413 / Request too large | サーバーの MaxRequestBodySize / MultipartBodyLengthLimit が小さい | Program.cs で上限調整。リバースプロキシ/IIS 側設定も合わせて。 |
| メモリが膨らむ | EF Core で巨大ファイルを byte[] 化 | ADO.NET のチャンク書き込みに切替。 |
| ダウンロードが重い | 全バイトを一度に読み出し | SequentialAccess と GetStream() でストリーミング。 |
| ファイル種別が偽装 | 拡張子しか見ていない | 署名チェック + サーバー側 MIME 判定を追加。 |
実装を丸ごとつなぐ:最小構成の全体例
以下は「Blazor(WASM/Server 共通 UI)」+「Minimal API」+「SQL Server(チャンク保存)」を組み合わせた最小構成の抜粋です。
Razor(アップロード UI)
<InputFile OnChange="OnInputFileChange" multiple />
@code {
private async Task OnInputFileChange(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles())
{
await using var src = file.OpenReadStream(1L * 1024 * 1024 * 1024);
var content = new MultipartFormDataContent {
{ new StreamContent(src), "file", file.Name }
};
var res = await Http.PostAsync("api/files", content);
res.EnsureSuccessStatusCode();
}
}
}
Minimal API(保存/配信)
app.MapPost("/api/files", async (HttpRequest req, IFileStorage storage, CancellationToken ct) =>
{
var form = await req.ReadFormAsync(ct);
var f = form.Files["file"]; if (f is null) return Results.BadRequest();
await using var s = f.OpenReadStream();
var id = await storage.SaveAsync(s, f.FileName, f.ContentType ?? "application/octet-stream", f.Length, ct);
return Results.Json(new { id });
});
app.MapGet("/api/files/{id:guid}", async (Guid id, IFileStorage storage, CancellationToken ct) =>
{
var meta = await storage.GetMetaAsync(id, ct);
return Results.Stream(async output => await storage.CopyToAsync(id, output, ct),
meta.ContentType, meta.FileName, enableRangeProcessing: true);
});
ADO.NET ストレージ(チャンク書込)
// 先述の SqlChunkedFileStorage をそのまま使用
パフォーマンス・メモリ・運用の指針
- スループット:サーバー NIC・DB I/O がボトルネックになりやすい。CPU は低め。
- メモリ:EF Core はサイズ分のメモリを確保。ADO.NET チャンクなら一定量で頭打ち。
- バックアップ:BLOB が大きいとバックアップ/復元に時間がかかる。フル/差分の計画を。
- ガバナンス:保持期間(Retention)を決め、定期的に古いファイルをアーカイブまたは削除。
| 観点 | EF Core(byte[]) | ADO.NET(チャンク) |
|---|---|---|
| 実装の簡単さ | ◎ | ○ |
| メモリ効率 | △(全読込) | ◎(一定量) |
| スループット | ○ | ○〜◎(調整次第) |
| 巨大ファイル対応 | × | ◎ |
よくある設計質問への回答
フロント側のアップロード方法は?
<InputFile> を使い、IBrowserFile.OpenReadStream(maxAllowedSize) で Stream を取得。WASM は HTTP API へ、Server は直接サーバーへストリーミングするのが基本です。
DB 列の型や設計は?
- 本体:
varbinary(max) - MIME:
nvarchar(127) - ファイル名:
nvarchar(260) - サイズ:
bigint - 任意:
RowVersion、Sha256、作成/更新日時、所有者、分類タグ
大容量ファイルへの対応策は?
- ADO.NET の
UPDATE ... .WRITE()でチャンク書き込み。 - API・Kestrel・IIS のリクエスト上限を必要最小限で調整。
- 配信は Range 対応でレジューム・シークを許可。
- 運用上は外部ストレージ+DB メタデータ方式も候補に。
セキュリティと品質のベストプラクティス(チェックボックス)
- [ ] サイズ制限:UI とサーバー両方に上限を設定。
- [ ] 拡張子・MIME・署名の整合:許可リスト方式。
- [ ] ウイルススキャン:非同期キューで確実に実施。
- [ ] タイムアウト/リトライ:特に巨大ファイルでの安定性を担保。
- [ ] 監査ログ:だれが何をいつアップロード/ダウンロードしたか。
- [ ] 暗号化:転送は TLS、保管は TDE/アプリ層暗号。
まとめ
Blazor アプリで DB へ安全かつ効率的にファイルを保存するには、UI(InputFile)、API(Minimal API)、保存方式(EF Core / ADO.NET チャンク)、配信(Range 対応)、そして セキュリティ を一連の流れで設計することが重要です。小〜中サイズは EF Core、⼤容量は ADO.NET ストリーミングに切り替える二段構えにしておけば、負荷や運用の変化に強い堅牢なシステムを構築できます。
付録:最小限の SQL スクリプトと appsettings.json 例
{
"ConnectionStrings": {
"Default": "Server=.;Database=BlazorFiles;Integrated Security=true;TrustServerCertificate=true"
}
}
-- テーブルは記事先頭の CREATE TABLE を使用
-- サンプルデータの存在確認
SELECT TOP 10 Id, FileName, ContentType, Length, CreatedAt
FROM dbo.Files
ORDER BY CreatedAt DESC;
付録:サンプルの検証用ユニットテスト(抜粋)
[Fact]
public async Task SaveAsync_Then_ReadBack_StreamMatches()
{
// Arrange
var bytes = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();
await using var src = new MemoryStream(bytes);
var storage = new SqlChunkedFileStorage(TestConfig.ConnectionString);
// Act
var id = await storage.SaveAsync(src, "test.bin", "application/octet-stream", bytes.Length, CancellationToken.None);
await using var dst = new MemoryStream();
await storage.CopyToAsync(id, dst, CancellationToken.None);
// Assert
dst.ToArray().Should().Equal(bytes);
}
この記事の要点
- UI は
<InputFile>+OpenReadStreamでシンプルに。 - DB は
varbinary(max)とメタデータ列の二層で設計。 - 小〜中サイズは EF Core、巨大ファイルは ADO.NET のチャンク書込。
- 配信はストリーミング+Range 対応、セキュリティは多層防御。

コメント