コンテンツにスキップ

第7章(後編):Azure Blob Storage への保存

この章は 第7章(前編):ファイル受信と検証 の続きです。前編で受け取って検証したファイルを、Azure Blob Storage へ保存する方法を説明します。前編のコード例を前提にしている箇所があるため、先に前編を読んでおくことをおすすめします。


  1. Blob Storage への接続と基本操作
  2. Blob Storage クライアントの DI 設計とアプリケーションへの組み込み
  3. 参考ドキュメント

1. Blob Storage への接続と基本操作

Section titled “1. Blob Storage への接続と基本操作”

受け取ったファイルの保存先には、アプリケーションサーバーのローカルディスク、リレーショナルデータベースのバイナリ列、外部のオブジェクトストレージという 3 つの選択肢があります。

保存先向いている場面注意点
ローカルディスク単一サーバーで完結する小規模なアプリケーションサーバーを増やすと他のサーバーからファイルが見えない。コンテナーでは再デプロイのたびに消える
データベースのバイナリ列ファイルとレコードの一貫性をトランザクションで保証したい場合公式ドキュメントもパフォーマンスへの悪影響に注意を促している。バックアップサイズも膨らむ
オブジェクトストレージ上記以外のほとんどの場合保存と参照が別のサービスになるため、後述するアクセス制御の設計が必要

本章が外部ストレージを扱うのは、Web アプリケーションが複数台構成やコンテナーで動くことが前提になった現在、3 つ目が既定の選択肢になるためです。

Blob Storage のオブジェクトモデル

Section titled “Blob Storage のオブジェクトモデル”

Azure Blob Storage は、大量の非構造化データ(画像、動画、ログ、バックアップなど)を保存するためのオブジェクトストレージサービスです。BLOB (Binary Large Object) とは、ここに保存されるデータ 1 件分の単位 で、実質的には 1 つのファイルに相当します。データは 3 階層で構成されます。

flowchart TB
    SA["ストレージアカウント\nhttps://{account}.blob.core.windows.net"]
    SA --> C1["コンテナー: images"]
    SA --> C2["コンテナー: documents"]
    C1 --> B1["BLOB: 2026/08/abc123.jpg"]
    C1 --> B2["BLOB: 2026/08/def456.png"]
    C2 --> B3["BLOB: contracts/xyz789.pdf"]

.NET クライアントライブラリ Azure.Storage.Blobs は、この階層に対応する 3 つのクライアントクラスを提供します。

クラス対応するリソース主な役割
BlobServiceClientストレージアカウントコンテナーの一覧・作成、ユーザー委任キーの取得
BlobContainerClientコンテナーコンテナー内の BLOB の一覧・作成・削除
BlobClient個別の BLOBアップロード、ダウンロード、プロパティ/メタデータの操作
BlobServiceClient serviceClient = /* DI から取得 */;
BlobContainerClient containerClient = serviceClient.GetBlobContainerClient("images");
BlobClient blobClient = containerClient.GetBlobClient("2026/08/abc123.jpg");

必要なパッケージを追加します。

Terminal window
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.Azure
パッケージ用途
Azure.Storage.BlobsBlob Storage クライアントライブラリ
Azure.IdentityMicrosoft Entra ID による認証(DefaultAzureCredential など)
Microsoft.Extensions.AzureDI コンテナーへの Azure クライアント登録(AddAzureClients

認証方式には、接続文字列(アカウントキー)を使う方法と、Microsoft Entra ID を使う方法があります。アカウントキーはストレージアカウント全体への完全な権限を持つため、Microsoft Entra ID による認証を推奨します

using Azure.Identity;
using Azure.Storage.Blobs;
// 推奨: Microsoft Entra ID による認証
var serviceClient = new BlobServiceClient(
new Uri("https://mystorageaccount.blob.core.windows.net"),
new DefaultAzureCredential());

DefaultAzureCredential は、実行環境に応じて資格情報を自動的に切り替えます。

実行環境使用される資格情報
ローカル開発Azure CLI (az login)、Visual Studio、Azure Developer CLI のサインイン情報
Azure App Service / Container Apps / VMマネージド ID (Managed Identity)
CI/CD環境変数に設定されたサービスプリンシパル

「ロールを割り当てる」と言われても、誰に 割り当てればよいのかが分かりにくいところです。割り当て先は DefaultAzureCredential が実際に使用する ID であり、それは実行環境ごとに異なります。

実行環境ロールの割り当て先(セキュリティプリンシパル)準備作業
ローカル開発開発者本人の Microsoft Entra ID アカウントaz login でサインインしたユーザー)開発者のアカウントに対象ストレージアカウントへのロールを割り当てる。チーム開発では、開発者を Entra ID のグループにまとめ、グループに割り当てると管理しやすい
Azure App ServiceApp Service の マネージド IDApp Service で「ID」→「システム割り当て済み」を「オン」にすると ID が発行される。その ID に対してロールを割り当てる
Azure Container Apps / Azure Functions / Azure VM各リソースの マネージド IDApp Service と同様に、リソース側でマネージド ID を有効化してからロールを割り当てる
GitHub Actions などの CI/CDサービスプリンシパル(アプリ登録)またはフェデレーション IDEntra ID にアプリを登録し、そのサービスプリンシパルにロールを割り当てる

割り当てのスコープ(適用範囲)は、必要最小限にすることが重要 です。サブスクリプション全体ではなく、対象のストレージアカウント、可能であればコンテナー単位まで絞り込んでください。ただし、後述のユーザー委任 SAS を発行する場合は、コンテナー単位まで絞ると SAS の発行に失敗します(理由は次の表のあとに説明します)。

Terminal window
# Bash(Linux / macOS)
# 例 1: ローカル開発。サインイン中の開発者アカウントに、
# 特定のストレージアカウントに対する読み書き権限を与える
az role assignment create \
--assignee-object-id "$(az ad signed-in-user show --query id -o tsv)" \
--assignee-principal-type User \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/<サブスクリプション ID>/resourceGroups/<リソースグループ名>/providers/Microsoft.Storage/storageAccounts/<ストレージアカウント名>"
# 例 2: Azure App Service。まずシステム割り当てマネージド ID を有効化し、
# 発行されたプリンシパル ID にロールを割り当てる
PRINCIPAL_ID=$(az webapp identity assign \
--name "<アプリ名>" --resource-group "<リソースグループ名>" \
--query principalId -o tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/<サブスクリプション ID>/resourceGroups/<リソースグループ名>/providers/Microsoft.Storage/storageAccounts/<ストレージアカウント名>"

Windows の PowerShell では、行継続がバックスラッシュ (\) ではなくバッククォート (`) である点と、コマンドの出力を変数に受け取る書き方が異なります。なお <アプリ名> のような差し替え用の箇所を引用符で囲んでいるのは、<> が bash ではリダイレクト、PowerShell では予約演算子として扱われるためです。同じ内容を PowerShell で書くと次のようになります。

Terminal window
# PowerShell(Windows)
# 例 1: ローカル開発。サインイン中の開発者アカウントに、
# 特定のストレージアカウントに対する読み書き権限を与える
az role assignment create `
--assignee-object-id "$(az ad signed-in-user show --query id -o tsv)" `
--assignee-principal-type User `
--role "Storage Blob Data Contributor" `
--scope "/subscriptions/<サブスクリプション ID>/resourceGroups/<リソースグループ名>/providers/Microsoft.Storage/storageAccounts/<ストレージアカウント名>"
# 例 2: Azure App Service。まずシステム割り当てマネージド ID を有効化し、
# 発行されたプリンシパル ID にロールを割り当てる
$PRINCIPAL_ID = az webapp identity assign `
--name "<アプリ名>" --resource-group "<リソースグループ名>" `
--query principalId -o tsv
az role assignment create `
--assignee-object-id "$PRINCIPAL_ID" `
--assignee-principal-type ServicePrincipal `
--role "Storage Blob Data Contributor" `
--scope "/subscriptions/<サブスクリプション ID>/resourceGroups/<リソースグループ名>/providers/Microsoft.Storage/storageAccounts/<ストレージアカウント名>"

用途に応じて、次のようにロールを使い分けます。

ロール権限主な用途
ストレージ BLOB データ閲覧者 (Storage Blob Data Reader)読み取りのみファイルの配信だけを行うアプリ
ストレージ BLOB データ共同作成者 (Storage Blob Data Contributor)読み取り・書き込み・削除アップロード機能を持つアプリ。本章の例はこれを想定
Storage Blob デリゲータ (Storage Blob Delegator)ユーザー委任キーの取得のみ(BLOB データへのアクセス権は含まない)データ用ロールをコンテナー単位のスコープで割り当てているアプリに、ストレージアカウント以上のスコープで追加する

本番環境では資格情報を明示する

Section titled “本番環境では資格情報を明示する”

DefaultAzureCredential は「環境に合わせて自動で切り替わる」点が便利な一方、どの資格情報が採用されるかを事前に保証できません。これは本番環境では次のような問題を招きます。

たとえば、マネージド ID で稼働していた本番サーバーに、誰かが調査目的で Azure CLI をインストールして az login したとします。その後にマネージド ID 側の認証が何らかの理由で失敗すると、DefaultAzureCredential は失敗した資格情報を黙って読み飛ばし、次の候補である Azure CLI の資格情報を使い始めます。結果として、意図しない権限でアプリが動き続けることになります。

このため Microsoft は、本番環境では DefaultAzureCredential を使わず、ManagedIdentityCredential のように決定的な資格情報へ置き換えること を推奨しています。開発環境では引き続き利便性を優先し、ChainedTokenCredential で候補を明示的に列挙します。

using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Azure;
builder.Services.AddAzureClients(clientBuilder =>
{
clientBuilder.AddBlobServiceClient(
new Uri("https://mystorageaccount.blob.core.windows.net"));
TokenCredential credential;
if (builder.Environment.IsProduction() || builder.Environment.IsStaging())
{
// 本番・ステージング: マネージド ID だけを使う(フォールバックしない)
// システム割り当て ID なら new ManagedIdentityCredential() で足りる
var clientId = builder.Configuration["UserAssignedClientId"];
credential = new ManagedIdentityCredential(
ManagedIdentityId.FromUserAssignedClientId(clientId));
}
else
{
// ローカル開発: 使う可能性のある資格情報だけを明示的に列挙する
credential = new ChainedTokenCredential(
new AzureCliCredential(),
new AzureDeveloperCliCredential());
}
clientBuilder.UseCredential(credential);
});

資格情報を明示する効果は、セキュリティ面だけではありません。DefaultAzureCredential はローカル PC 上でも候補を順番に試すため、その途中でマネージド ID の取得を試みます。マネージド ID は Azure が仮想マシン内部に用意する固定アドレス (169.254.169.254) へ問い合わせて取得しますが、Azure 外のローカル PC にはその宛先が存在しません。この宛先への通信が明確に拒否される環境であれば即座に次の候補へ進めるものの、パケットが黙って破棄されるネットワークではタイムアウトを待つことになります。

筆者の環境で実際に計測したところ、DefaultAzureCredential のままではトークン取得に 約 180 秒かかりました。上記のように ChainedTokenCredential で候補を絞ると 1 秒未満で完了します。「アプリの起動が妙に遅い」と感じたら、まずこの点を疑ってください。

本章の以降のコード例では、説明を簡潔にするために DefaultAzureCredential を使用します。実際に本番環境へデプロイする際は、上記のように資格情報を明示してください。

ストリームをそのままアップロードする

Section titled “ストリームをそのままアップロードする”

Web アプリケーションでのファイルアップロードでは、いったん自前でローカルディスクへ保存せず、受信したストリームを直接 Blob Storage へ流し込むのが基本形です。

using Azure.Storage.Blobs;
public static async Task<Uri> UploadAsync(
BlobContainerClient containerClient,
IFormFile file,
string blobName,
CancellationToken cancellationToken)
{
var blobClient = containerClient.GetBlobClient(blobName);
await using var stream = file.OpenReadStream();
await blobClient.UploadAsync(stream, overwrite: false, cancellationToken);
return blobClient.Uri;
}

UploadAsync は、データサイズと転送オプションに応じて、単一の Put Blob 操作を行うか、Put Block を複数回実行してから Put Block List でコミットするかを自動的に選択します。大きなファイルの並列転送を制御したい場合は StorageTransferOptions を指定します。

コンテナー名には、BLOB 名よりも厳しい命名規則があります。構成ファイルから読み込む値も、この規則を満たしていなければなりません。

規則✅ 有効な例❌ 無効な例
使える文字は英小文字・数字・ハイフンのみuploadsuser-filesUploads(大文字)/user_files(アンダースコア)/my.files(ピリオド)
長さは 3 文字以上 63 文字以下imgup(2 文字)
先頭と末尾は英小文字か数字a-b-uploadsuploads-
ハイフンを連続させないup-loadsa--b

規則に反する名前を指定すると、コンテナー作成の時点で RequestFailedExceptionStatus: 400)が発生します。エラーコードは違反の種類で分かれ、使えない文字を含む場合は InvalidResourceName、長さが範囲外の場合は OutOfRangeInput になります。BLOB 名では大文字もスラッシュも使えるため、コンテナー名だけ規則が異なる点に注意してください。

using Azure.Storage;
using Azure.Storage.Blobs.Models;
var options = new BlobUploadOptions
{
TransferOptions = new StorageTransferOptions
{
// この値「未満」なら 1 回のリクエストでアップロードする閾値。
// ちょうど同じサイズのときは分割される
InitialTransferSize = 8 * 1024 * 1024,
// 分割する場合の 1 ブロックあたりの最大サイズ
MaximumTransferSize = 4 * 1024 * 1024,
// 並列アップロード数
MaximumConcurrency = 4,
},
};
await blobClient.UploadAsync(stream, options, cancellationToken);

ストリーミング受信(MultipartReader)と組み合わせれば、大きなファイルをメモリに載せずに Blob Storage へ転送できます。

while ((section = await reader.ReadNextSectionAsync(cancellationToken)) is not null)
{
var contentDisposition = section.GetContentDispositionHeader();
if (contentDisposition is not null && contentDisposition.IsFileDisposition())
{
var blobClient = containerClient.GetBlobClient($"{Guid.NewGuid():N}.bin");
// multipart のセクションから Blob Storage へ直接ストリーム転送
await blobClient.UploadAsync(section.Body, overwrite: false, cancellationToken);
}
}

BLOB には システムプロパティユーザー定義メタデータ を設定できます。

種別説明
システムプロパティHTTP ヘッダーに対応する既定のプロパティContentTypeCacheControlContentDisposition
ユーザー定義メタデータ任意の名前と値のペアuploadedByoriginalFileName

Content-Type を設定しないと、ブラウザが BLOB の URL を直接開いたときに application/octet-stream として扱われ、画像が表示されずダウンロードされてしまいます。アップロード時に BlobUploadOptions.HttpHeaders で指定します。

using System.Text;
using Azure.Storage.Blobs.Models;
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders
{
// クライアント申告値をそのまま使わず、拡張子から判定した値を設定する
ContentType = ResolveContentType(blobName),
CacheControl = "public, max-age=31536000",
},
Metadata = new Dictionary<string, string>
{
["originalFileName"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(file.FileName)),
["uploadedBy"] = userId,
["uploadedAt"] = DateTimeOffset.UtcNow.ToString("O"),
},
};
await blobClient.UploadAsync(stream, uploadOptions, cancellationToken);

CacheControl に指定した値は、BLOB をダウンロードするときの Cache-Control 応答ヘッダーとしてそのまま返されます。上の例の public, max-age=31536000(1 年)は、BLOB 名に GUID を使い、いったん保存したファイルの内容を後から差し替えない運用を前提とした値です。同じ名前で内容を更新する可能性がある場合は短い期間にするか、no-cache を指定してください。また public は CDN やプロキシなどの共有キャッシュへの保存を許可する意味を持つため、後述の SAS で限定的に公開するファイルでは private を選びます。

Content-Type の判定には、FileExtensionContentTypeProvider を利用できます。このクラスは Microsoft.AspNetCore.StaticFiles アセンブリにありますが、ASP.NET Core の共有フレームワークに同梱されているため、NuGet パッケージを追加する必要はありません(同名のパッケージが NuGet に存在しますが、Web アプリケーションで追加すると NU1510 警告が出ます)。

拡張子から MIME タイプを引くだけの単純な仕組みで、未知の拡張子では false を返します。その場合は汎用の application/octet-stream にフォールバックさせます。

using Microsoft.AspNetCore.StaticFiles;
private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
private static string ResolveContentType(string fileName)
=> ContentTypeProvider.TryGetContentType(fileName, out var contentType)
? contentType
: "application/octet-stream";

アップロード後にメタデータを更新したり読み取ったりする場合は、SetMetadataAsyncGetPropertiesAsync を使います。

// メタデータの更新(既存のメタデータは置き換えられる)
await blobClient.SetMetadataAsync(new Dictionary<string, string>
{
["scanStatus"] = "clean",
}, cancellationToken: cancellationToken);
// プロパティとメタデータの取得
BlobProperties properties = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken);
Console.WriteLine(properties.ContentType);
Console.WriteLine(properties.Metadata["scanStatus"]);

メタデータでは「検索」ができない

Section titled “メタデータでは「検索」ができない”

ここで重要な制約があります。メタデータは、値を指定して BLOB を検索するための手段としては使えません

Blob Storage には BLOB を一覧・検索する手段がいくつかありますが、それぞれ役割が異なります。

手段できることメタデータで絞り込めるか
プレフィックス指定の一覧取得 (GetBlobsAsync)名前が特定の文字列で始まる BLOB を列挙するできない
BLOB インデックスタグ (Tags)キーと値の条件式で BLOB を横断的に検索する(例: "scanStatus" = 'clean'— (タグが検索対象)
Azure AI Search などの外部検索サービス全文検索や高度な条件検索を行う別途インデックスを構築すれば可能

このうち BLOB インデックスタグ は、Blob Storage が標準で提供する検索用の索引機能です。タグとして設定したキーと値は Blob Storage 側で索引化され、FindBlobsByTagsAsync で「タグの値がこの条件に一致する BLOB」をコンテナーをまたいで探し出せます。「スキャン未完了のファイルを全部拾いたい」「特定のテナントのファイルだけ集めたい」といった用途がこれにあたります。

一方、メタデータには索引が作られません。メタデータの値で BLOB を探そうとすると、全 BLOB を列挙して 1 件ずつクライアント側で条件に合うか確認することになり、件数が増えると現実的ではなくなります(GetBlobsAsync(new GetBlobsOptions { Traits = BlobTraits.Metadata }) と指定すれば一覧の応答にメタデータを含められるので、BLOB ごとに GetPropertiesAsync を呼ぶ必要はありません。それでも全件を取得して絞り込む点は変わりません)。メタデータはあくまで、BLOB のパスが既に分かっている状態で、その BLOB に付随する補足情報を取り出す ための機能だと理解してください。

// 検索したい属性はタグとして設定する(1 BLOB あたり最大 10 個)
var uploadOptions = new BlobUploadOptions
{
Tags = new Dictionary<string, string>
{
["scanStatus"] = "pending",
["tenantId"] = tenantId,
},
};
await blobClient.UploadAsync(stream, uploadOptions, cancellationToken);
// タグを条件に BLOB を検索する
await foreach (TaggedBlobItem item in
serviceClient.FindBlobsByTagsAsync("\"scanStatus\" = 'pending'", cancellationToken))
{
Console.WriteLine($"{item.BlobContainerName}/{item.BlobName}");
}

同名の BLOB が既に存在する場合の挙動は、明示的に制御する必要があります。

// ① 常に上書きする
await blobClient.UploadAsync(stream, overwrite: true, cancellationToken);
// ② 既存の場合は失敗させる(既定の動作)
// 既に存在すると RequestFailedException(HTTP 409 BlobAlreadyExists)がスローされる
await blobClient.UploadAsync(stream, overwrite: false, cancellationToken);

より細かく制御する場合は、BlobRequestConditions に条件付きヘッダーを指定します。ここで使う ETag は、HTTP がリソースの版を表すために用いる識別子で(Blob Storage での同時実行制御の考え方は BLOB の同時実行の管理を参照)、Blob Storage では BLOB の内容が変わるたびに新しい値が振られます。IfNoneMatch = ETag.All は「どんな ETag とも一致しない場合だけ実行する」、つまり その BLOB がまだ存在しない場合だけ書き込む という指定です。

using Azure;
using Azure.Storage.Blobs.Models;
// 新規作成のみを許可する(If-None-Match: *)
var createOnly = new BlobUploadOptions
{
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All },
};
try
{
await blobClient.UploadAsync(stream, createOnly, cancellationToken);
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status409Conflict)
{
// 同名の BLOB が既に存在する
}

既存 BLOB の更新時に、取得してから更新するまでの間に他のプロセスが変更していないことを保証するには、楽観的同時実行制御 (Optimistic Concurrency) を使います。ダウンロード時に取得した ETagIfMatch に指定すると、値が一致しない場合に HTTP 412 (Precondition Failed) が返ります。

// 現在の ETag を取得する。GetPropertiesAsync は内容をダウンロードしないため、
// 大きなファイルでもメモリを消費しない
Response<BlobProperties> properties = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken);
ETag originalETag = properties.Value.ETag;
var conditionalUpdate = new BlobUploadOptions
{
Conditions = new BlobRequestConditions { IfMatch = originalETag },
};
try
{
await blobClient.UploadAsync(updatedContent, conditionalUpdate, cancellationToken);
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status412PreconditionFailed)
{
// 取得後に他のプロセスが BLOB を更新した。再取得してやり直す
}

BLOB リース (Lease) は、BLOB に対して 書き込みの排他ロック を取得する仕組みです。リースを取得するとリース ID が発行され、以降その BLOB に書き込めるのはリース ID を提示したコードだけになります。楽観的同時実行制御が「衝突したら気付いてやり直す」方式なのに対し、リースは「そもそも他のコードに書かせない」方式です。

using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs.Specialized;
BlobLeaseClient lease = blobClient.GetBlobLeaseClient();
// 期間は 15〜60 秒の範囲で指定する。TimeSpan.FromSeconds(-1) は無期限
string leaseId = (await lease.AcquireAsync(
TimeSpan.FromSeconds(30), cancellationToken: cancellationToken)).Value.LeaseId;
try
{
// リース ID を条件に渡した書き込みだけが成功する
var leasedUpload = new BlobUploadOptions
{
Conditions = new BlobRequestConditions { LeaseId = leaseId },
};
await blobClient.UploadAsync(updatedContent, leasedUpload, cancellationToken);
}
finally
{
// 処理が終わったら必ず解放する
await lease.ReleaseAsync(cancellationToken: cancellationToken);
}

リース中の BLOB に対する操作の結果は次のとおりです。読み取りは制限されない ため、リースは「書き込みの排他」だと理解してください。

リース中の操作結果
リース ID を渡さずに書き込むHTTP 412 (LeaseIdMissing)
別のコードがリースを取得しようとするHTTP 409 (LeaseAlreadyPresent)
内容を読み取る (DownloadContentAsync)成功する
15 秒未満または 60 秒を超える期間を指定するHTTP 400 (InvalidHeaderValue)

処理が 60 秒を超える場合は RenewAsync でリースを更新し続けます。解放を忘れると、期限が切れるまで他のコードがその BLOB を更新できなくなります。無期限リースを解放し忘れた場合は、BreakAsync で強制的に解除するまで書き込めません。tryfinally で確実に ReleaseAsync を呼ぶか、そもそも無期限リースを使わない設計にしてください。

制御方式条件ヘッダー用途
常に上書きなし冪等なアップロード、キャッシュ的な用途
新規作成のみIfNoneMatch = ETag.All一意な名前を生成して保存する通常のアップロード
楽観的同時実行制御IfMatch = originalETag既存ファイルの更新
悲観的同時実行制御LeaseId = leaseId(BLOB リース)長時間の排他が必要なバッチ処理

ローカル開発では、Azure Storage のエミュレーターである Azurite を使うと、実際のストレージアカウントを作らずに動作を確認できます。

Terminal window
# Docker で起動する場合
docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
# npm でインストールして起動する場合
npm install -g azurite
azurite --silent --location ./azurite-data

Azurite は既知の開発用アカウントキーを持つため、開発環境では接続文字列 UseDevelopmentStorage=true を使用します。

appsettings.Development.json
{
"Storage": {
"ConnectionString": "UseDevelopmentStorage=true"
}
}
// appsettings.json(本番環境)
{
"Storage": {
"ServiceUri": "https://mystorageaccount.blob.core.windows.net"
}
}

2. Blob Storage クライアントの DI 設計とアプリケーションへの組み込み

Section titled “2. Blob Storage クライアントの DI 設計とアプリケーションへの組み込み”

1. Blob Storage への接続と基本操作」では BlobServiceClient を直接 new して Blob Storage を操作しました。しかし実際のアプリケーションでは、クライアントを DI コンテナーで管理し、アプリのコードからは抽象化されたインターフェイス越しに使うのが定石です。ここでは、前節で扱った Blob Storage の操作を、そのまま実運用に耐える形へ組み立て直していきます。

AddAzureClients によるクライアント登録

Section titled “AddAzureClients によるクライアント登録”

BlobServiceClientスレッドセーフであり、再利用が推奨されるクライアント です。リクエストのたびに new すると、コネクションプールの枯渇や認証トークン取得のオーバーヘッドを招きます。Microsoft.Extensions.Azure パッケージの AddAzureClients を使って、DI コンテナーに Singleton として登録します。

using Azure.Identity;
using Microsoft.Extensions.Azure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAzureClients(clientBuilder =>
{
// 構成セクションからクライアントを生成する
clientBuilder.AddBlobServiceClient(builder.Configuration.GetSection("Storage"));
// すべてのクライアントで共有する資格情報を設定する
clientBuilder.UseCredential(new DefaultAzureCredential());
// 再試行などの既定の動作を構成する
clientBuilder.ConfigureDefaults(builder.Configuration.GetSection("AzureDefaults"));
});
{
"AzureDefaults": {
"Retry": {
"MaxRetries": 3,
"Mode": "Exponential"
}
},
"Storage": {
"ServiceUri": "https://mystorageaccount.blob.core.windows.net"
}
}

複数のストレージアカウントを使い分ける場合は、WithName で名前を付けて登録し、IAzureClientFactory<T> から取り出します。

builder.Services.AddAzureClients(clientBuilder =>
{
clientBuilder.AddBlobServiceClient(builder.Configuration.GetSection("PublicStorage"))
.WithName("public");
clientBuilder.AddBlobServiceClient(builder.Configuration.GetSection("PrivateStorage"))
.WithName("private");
clientBuilder.UseCredential(new DefaultAzureCredential());
});
using Azure.Storage.Blobs;
using Microsoft.Extensions.Azure;
namespace FileUploadSample.Storage;
public sealed class ArchiveService(IAzureClientFactory<BlobServiceClient> clientFactory)
{
private readonly BlobServiceClient _publicClient = clientFactory.CreateClient("public");
private readonly BlobServiceClient _privateClient = clientFactory.CreateClient("private");
}

ストレージ抽象化インターフェイスの設計

Section titled “ストレージ抽象化インターフェイスの設計”

アプリケーションのビジネスロジックが BlobClient に直接依存すると、ローカルファイルシステムや別のクラウドストレージへ差し替えづらくなり、単体テストも困難になります。保存先に依存しないインターフェイス を定義しましょう。

flowchart LR
    C["FilesController"] --> I["IFileStorage\n(抽象)"]
    I -.実装.-> B["BlobFileStorage\n(Azure Blob Storage)"]
    I -.実装.-> L["LocalFileStorage\n(ローカルファイルシステム)"]
    I -.実装.-> M["InMemoryFileStorage\n(テスト用)"]
namespace FileUploadSample.Storage;
/// <summary>ファイルの保存先を抽象化するインターフェイス。</summary>
public interface IFileStorage
{
Task<StoredFile> SaveAsync(
FileUploadDescriptor descriptor,
CancellationToken cancellationToken = default);
Task<Stream?> OpenReadAsync(string storagePath, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string storagePath, CancellationToken cancellationToken = default);
Task<Uri> CreateReadUrlAsync(
string storagePath,
TimeSpan lifetime,
string? downloadFileName = null,
CancellationToken cancellationToken = default);
}
/// <summary>保存要求を表すレコード。</summary>
public sealed record FileUploadDescriptor(
Stream Content,
string StoragePath,
string ContentType,
IReadOnlyDictionary<string, string>? Metadata = null,
bool AllowOverwrite = false);
/// <summary>保存結果を表すレコード。</summary>
public sealed record StoredFile(string StoragePath, long Length, string ETag);
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;
namespace FileUploadSample.Storage;
public sealed class BlobStorageOptions
{
public const string SectionName = "BlobStorage";
// required だけでは構成バインド時に検証されないため [Required] を付ける
[Required(AllowEmptyStrings = false)]
public required string ContainerName { get; set; }
}
public sealed class BlobFileStorage(
BlobServiceClient serviceClient,
// SAS URL の発行役。実装は後述の「SAS による一時的なアクセス許可」で示す
UserDelegationSasProvider sasProvider,
IOptions<BlobStorageOptions> options,
ILogger<BlobFileStorage> logger) : IFileStorage
{
private readonly BlobContainerClient _container =
serviceClient.GetBlobContainerClient(options.Value.ContainerName);
public async Task<StoredFile> SaveAsync(
FileUploadDescriptor descriptor,
CancellationToken cancellationToken = default)
{
var blobClient = _container.GetBlobClient(descriptor.StoragePath);
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = descriptor.ContentType },
Metadata = descriptor.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value),
Conditions = descriptor.AllowOverwrite
? null
: new BlobRequestConditions { IfNoneMatch = ETag.All },
};
try
{
Response<BlobContentInfo> response =
await blobClient.UploadAsync(descriptor.Content, uploadOptions, cancellationToken);
return new StoredFile(
descriptor.StoragePath,
// 非シークストリーム(MultipartReader のセクションなど)ではサイズを取得できない。
// 正確な値が必要なら、呼び出し側で数えるか GetPropertiesAsync で取得する
descriptor.Content.CanSeek ? descriptor.Content.Length : 0,
response.Value.ETag.ToString());
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status409Conflict)
{
logger.LogWarning("BLOB {StoragePath} は既に存在します。", descriptor.StoragePath);
throw new InvalidOperationException($"'{descriptor.StoragePath}' は既に存在します。", ex);
}
}
public async Task<Stream?> OpenReadAsync(
string storagePath,
CancellationToken cancellationToken = default)
{
var blobClient = _container.GetBlobClient(storagePath);
try
{
return await blobClient.OpenReadAsync(cancellationToken: cancellationToken);
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status404NotFound)
{
return null;
}
}
public async Task<bool> DeleteAsync(
string storagePath,
CancellationToken cancellationToken = default)
{
var blobClient = _container.GetBlobClient(storagePath);
Response<bool> response = await blobClient.DeleteIfExistsAsync(
cancellationToken: cancellationToken);
return response.Value;
}
public Task<Uri> CreateReadUrlAsync(
string storagePath,
TimeSpan lifetime,
string? downloadFileName = null,
CancellationToken cancellationToken = default)
{
// SAS の生成は UserDelegationSasProvider へ委譲する。
// 実装は「SAS による一時的なアクセス許可」を参照
var blobClient = _container.GetBlobClient(storagePath);
return sasProvider.CreateReadUrlAsync(
blobClient, lifetime, downloadFileName, cancellationToken);
}
}

登録はサービス登録用の拡張メソッドにまとめると、Program.cs が読みやすくなります。

using Azure.Identity;
using FileUploadSample.Validation;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace FileUploadSample.Storage;
public static class StorageServiceCollectionExtensions
{
public static IServiceCollection AddBlobFileStorage(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddAzureClients(clientBuilder =>
{
clientBuilder.AddBlobServiceClient(configuration.GetSection("Storage"));
clientBuilder.UseCredential(new DefaultAzureCredential());
});
services.AddOptions<BlobStorageOptions>()
.Bind(configuration.GetSection(BlobStorageOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// StoragePathBuilder と UploadValidator が依存するため、
// FileUploadOptions の登録もこの拡張メソッドにまとめる
services.AddOptions<FileUploadOptions>()
.Bind(configuration.GetSection(FileUploadOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// BlobServiceClient は Singleton、ラッパーは Scoped で登録する
services.AddScoped<IFileStorage, BlobFileStorage>();
// 状態を持たず生成コストも低いため Transient で登録する
// (第6章のライフタイム選択のガイドラインに従う)
services.AddTransient<IUploadValidator, UploadValidator>();
// ユーザー委任キーをキャッシュするため Singleton で登録する
services.AddSingleton<UserDelegationSasProvider>();
// TimeProvider は既定では登録されていないため、明示的に登録する
services.TryAddSingleton(TimeProvider.System);
// 保存先の BLOB 名を組み立てる。実装は後述の「保存先パスの設計」で示す
services.AddSingleton<IStoragePathBuilder, StoragePathBuilder>();
return services;
}
}
builder.Services.AddBlobFileStorage(builder.Configuration);

この拡張メソッドは 3 つの構成セクションを読み込みます。このうち BlobStorageFileUpload には ValidateOnStart() を付けているため、値が欠けていると起動時に OptionsValidationException で停止します。

{
"Storage": {
"ServiceUri": "https://mystorageaccount.blob.core.windows.net"
},
"BlobStorage": {
"ContainerName": "uploads"
},
"FileUpload": {
"MaxFileSizeBytes": 5242880,
"PermittedExtensions": [ ".jpg", ".jpeg", ".png", ".pdf" ]
}
}

BLOB 名(保存先パス)の設計は、後からの変更が困難です。次の観点を考慮して決めます。

観点指針
一意性GUID や ULID(時刻順にソートできる 26 文字の識別子)を含め、衝突しない名前にする
推測困難性連番は避ける。URL を推測して他人のファイルを取得されるリスクを減らす
分散先頭に日付やハッシュを置き、名前が特定のプレフィックスに集中しないようにする
論理的な区分テナント ID、ユーザー ID、用途を階層に含めて運用しやすくする
ライフサイクル管理日付をパスに含めると、有効期限に基づく削除ポリシーを適用しやすい
{用途}/{区分ID}/{yyyy}/{MM}/{一意なID}{拡張子}
例: avatars/tenant-a1b2/2026/08/01a004c1b9c07c2e9d4f6a8b0c1d2e3f.jpg
部分例の値意味
用途avatarsファイルの用途。アプリ内でファイルの種類を分ける
区分 IDtenant-a1b2テナント ID やユーザー ID など、ファイルを分ける単位
年 / 月2026/08有効期限に基づく削除ポリシーを適用しやすくする
一意な ID01a004c1b9c07c2e9d4f6a8b0c1d2e3fGuid.CreateVersion7()"N" 書式
拡張子.jpg検証済みの拡張子をそのまま使う
using FileUploadSample.Validation;
using Microsoft.Extensions.Options;
using System.Globalization;
namespace FileUploadSample.Storage;
public interface IStoragePathBuilder
{
/// <param name="scopeId">ファイルを区分する単位の識別子。テナント ID やユーザー ID を指定する。</param>
string Build(string category, string scopeId, string originalFileName);
}
public sealed class StoragePathBuilder(
TimeProvider timeProvider,
IOptions<FileUploadOptions> options) : IStoragePathBuilder
{
public string Build(string category, string scopeId, string originalFileName)
{
var now = timeProvider.GetUtcNow();
var extension = Path.GetExtension(originalFileName).ToLowerInvariant();
// 拡張子もクライアント由来なので、そのまま BLOB 名に含めてはいけない。
// 検証済みの値だけを受け入れる(多層防御。通常は上流の UploadValidator で弾かれる)
if (!options.Value.PermittedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"許可されていない拡張子です: {extension}", nameof(originalFileName));
}
var id = Guid.CreateVersion7(now).ToString("N");
// 日付の書式はカルチャに依存させない。
// 書式指定子 yyyy は実行環境のカレンダーに従うため、たとえばタイ語環境では
// 2026 年が 2569 年(仏暦)になり、同じ日時から別のパスが生成されてしまう
var yearMonth = now.ToString("yyyy/MM", CultureInfo.InvariantCulture);
// BLOB 名は、アプリケーションが生成した値と検証済みの拡張子だけで組み立てる
return $"{category}/{scopeId}/{yearMonth}/{id}{extension}";
}
}

Blob Storage への匿名アクセス(公開読み取り)は、既定で 無効 です。有効化するには、ストレージアカウントとコンテナーの両方で設定を変更する必要があります(手順の詳細は コンテナーと BLOB に対する匿名読み取りアクセスを構成するを参照)。

ストレージアカウントの設定コンテナーのアクセスレベル結果
匿名アクセスを許可しない任意すべて非公開。アカウント設定が優先される
匿名アクセスを許可するプライベート(既定)非公開
匿名アクセスを許可するBLOBBLOB は匿名で読めるが、一覧は取得できない
匿名アクセスを許可するコンテナーBLOB の読み取りと一覧取得が匿名で可能

アカウント側で匿名アクセスを許可していない状態のまま、コンテナーに公開アクセスレベルを設定しようとすると、設定が黙って無視されるのではなく 409 (PublicAccessNotPermitted) で失敗します。公開が必要な場合は、ストレージアカウントとコンテナーの両方を明示的に設定してください。

アプリケーションがプロキシとして配信する実装は次のようになります。認可チェックを挟めるため、非公開ファイルの配信に適しています。

以降のコード例に出てくる _dbContext.UploadedFiles は、アップロード時に BLOB 名や元のファイル名を記録しておくテーブルです。BLOB 名だけでは元のファイル名や所有者が分からないため、こうした情報はデータベース側に持ちます。具体的な設計はメタデータ管理とデータベース連携で扱います。

以降のコード例は、いずれもコントローラーのアクション部分だけを抜き出したものです。_fileStorageストレージ抽象化インターフェイスの設計で定義した IFileStorage_authorizationService は ASP.NET Core 標準の IAuthorizationService を、それぞれコンストラクターで受け取っている前提とします。コントローラー全体の姿はコントローラーからの利用で示します。

[HttpGet("{id:guid}/content")]
public async Task<IActionResult> DownloadThroughApp(Guid id, CancellationToken cancellationToken)
{
var record = await _dbContext.UploadedFiles.FindAsync([id], cancellationToken);
if (record is null)
{
return NotFound();
}
// 認可チェック(所有者またはアクセス権を持つユーザーのみ)
var authorization = await _authorizationService.AuthorizeAsync(User, record, "FileAccess");
if (!authorization.Succeeded)
{
return Forbid();
}
var stream = await _fileStorage.OpenReadAsync(record.StoragePath, cancellationToken);
if (stream is null)
{
return NotFound();
}
// 元のファイル名でダウンロードさせる。
// File ヘルパーが Content-Disposition ヘッダーを組み立てるため、
// 日本語のファイル名もフレームワーク側で正しくエンコードされる。
return File(stream, record.ContentType, record.OriginalFileName);
}

SAS による一時的なアクセス許可

Section titled “SAS による一時的なアクセス許可”

共有アクセス署名 (Shared Access Signature: SAS) は、有効期限と権限を限定した URL を発行する仕組みです。クライアントはこの URL で Blob Storage へ直接アクセスできるため、アプリケーションサーバーを経由せずに済みます。

SAS には次の 3 種類があります。

種別署名に使う鍵特徴
アカウント SASストレージアカウントキーBLOB / キュー / テーブル / ファイルの複数サービスや、サービス自体の設定操作まで委任できる。範囲が広く、漏えいしたときの影響が最も大きい
サービス SASストレージアカウントキー単一のサービス内のリソースに限定される。アカウントキーをアプリケーションが保持する必要がある
ユーザー委任 SASユーザー委任キー(Microsoft Entra ID 由来)Blob Storage 専用。アカウントキーが不要で、推奨

ユーザー委任 SAS は、BlobServiceClient.GetUserDelegationKeyAsync で取得したキーで署名します。キーの有効期間は最大 7 日間で、SAS はキーの有効期限を超えて使えないため、ユーザー委任 SAS の期限も実質的に最大 7 日間です。

using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Microsoft.Net.Http.Headers;
namespace FileUploadSample.Storage;
public sealed class UserDelegationSasProvider(BlobServiceClient serviceClient, TimeProvider timeProvider)
: IDisposable
{
// キーと有効期限を 1 つの参照にまとめる。
// 参照の代入はアトミックなので、ロックを取らずに読んでも
// 「キーと期限がちぐはぐな組み合わせ」にはならない
private sealed record CachedKey(UserDelegationKey Key, DateTimeOffset ExpiresOn);
// キーは SAS の期限より少し長めに取り、キャッシュ判定の余裕 (5 分) を上回るようにする
private static readonly TimeSpan KeyMargin = TimeSpan.FromMinutes(10);
// ユーザー委任キーの有効期間は最大 7 日間。SAS の期限はその余裕分だけ短くなる
private static readonly TimeSpan MaxLifetime = TimeSpan.FromDays(7) - KeyMargin;
private CachedKey? _cached;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<Uri> CreateReadUrlAsync(
BlobClient blobClient,
TimeSpan lifetime,
string? downloadFileName = null,
CancellationToken cancellationToken = default)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lifetime, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfGreaterThan(lifetime, MaxLifetime);
var key = await GetUserDelegationKeyAsync(lifetime, cancellationToken);
var now = timeProvider.GetUtcNow();
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = blobClient.BlobContainerName,
BlobName = blobClient.Name,
Resource = "b",
// 時計のずれを考慮して開始時刻を少し前倒しする
StartsOn = now.AddMinutes(-5),
ExpiresOn = now.Add(lifetime),
};
// 読み取り専用の権限のみを付与する
sasBuilder.SetPermissions(BlobSasPermissions.Read);
if (downloadFileName is not null)
{
// ファイル名をそのまま文字列連結してはいけない。
// HTTP ヘッダーは ASCII しか運べないため、日本語などを含めると
// ダウンロード時のファイル名が壊れる。しかもここでは例外が出ない (後述)。
// SetHttpFileName が RFC 6266 に従って
// ASCII 版 (filename) と UTF-8 版 (filename*) の両方を組み立ててくれる。
var contentDisposition = new ContentDispositionHeaderValue("attachment");
contentDisposition.SetHttpFileName(downloadFileName);
sasBuilder.ContentDisposition = contentDisposition.ToString();
}
var uriBuilder = new BlobUriBuilder(blobClient.Uri)
{
Sas = sasBuilder.ToSasQueryParameters(key, blobClient.AccountName),
};
return uriBuilder.ToUri();
}
private async Task<UserDelegationKey> GetUserDelegationKeyAsync(
TimeSpan lifetime,
CancellationToken cancellationToken)
{
// ユーザー委任キーが期限切れになると、SAS 自体の有効期限が残っていても
// 認可エラーになる。そのため「発行する SAS の有効期限まで
// キーが生きているか」を基準に、キャッシュの再利用可否を判断する
bool CoversLifetime(CachedKey? cached) =>
cached is not null
&& cached.ExpiresOn > timeProvider.GetUtcNow().Add(lifetime).AddMinutes(5);
// フィールドをローカル変数へ 1 度だけ読み出す
var cached = _cached;
if (CoversLifetime(cached))
{
return cached!.Key;
}
await _semaphore.WaitAsync(cancellationToken);
try
{
// 待っている間に他のスレッドが取得済みかもしれないので、もう一度確認する
cached = _cached;
if (CoversLifetime(cached))
{
return cached!.Key;
}
var now = timeProvider.GetUtcNow();
// これから発行する SAS の期限より確実に長いキーを要求する。
// 固定値 (たとえば常に 1 時間) にすると、lifetime が長いときに
// 取得直後のキーでも CoversLifetime を満たさず、
// 毎回キーを取り直したうえに SAS がキーの期限切れで 403 になる
var expiresOn = now.Add(lifetime).Add(KeyMargin);
Response<UserDelegationKey> response = await serviceClient.GetUserDelegationKeyAsync(
now.AddMinutes(-5), expiresOn, cancellationToken);
_cached = new CachedKey(response.Value, expiresOn);
return response.Value;
}
finally
{
_semaphore.Release();
}
}
// SemaphoreSlim は破棄が必要なため、IDisposable を実装して DI コンテナーに任せる。
// Singleton として登録した場合、アプリケーション終了時に自動で呼ばれる
public void Dispose() => _semaphore.Dispose();
}

コントローラーからは、SAS URL へのリダイレクトを返します。

[HttpGet("{id:guid}/download")]
public async Task<IActionResult> RedirectToSasUrl(Guid id, CancellationToken cancellationToken)
{
var record = await _dbContext.UploadedFiles.FindAsync([id], cancellationToken);
if (record is null)
{
return NotFound();
}
// 認可はアプリケーション側で実施し、通過した場合のみ短命な URL を発行する。
// 元のファイル名を渡すと、SAS 側の Content-Disposition で復元される
var url = await _fileStorage.CreateReadUrlAsync(
record.StoragePath, TimeSpan.FromMinutes(10), record.OriginalFileName, cancellationToken);
return Redirect(url.ToString());
}

このダウンロード処理で、クライアント・アプリケーション・Microsoft Entra ID・Blob Storage の間をやり取りが行き来する順序を時系列で表すと、次のようになります。

sequenceDiagram
    participant C as クライアント
    participant A as ASP.NET Core アプリ
    participant E as Microsoft Entra ID
    participant B as Blob Storage

    C->>A: GET /files/{id}/download
    A->>A: 認証・認可チェック
    A->>E: マネージド ID でトークン取得
    E-->>A: アクセストークン
    A->>B: GetUserDelegationKeyAsync
    B-->>A: ユーザー委任キー
    A->>A: SAS URL を生成(読み取り専用 / 10 分)
    A-->>C: 302 Redirect(SAS URL)
    C->>B: GET SAS URL
    B-->>C: ファイルの内容

メタデータ管理とデータベース連携

Section titled “メタデータ管理とデータベース連携”

BLOB のメタデータは HTTP ヘッダーの制約を受け、検索もできません。実運用では ファイルの属性はデータベースで管理し、BLOB は実体の保存にのみ使う 構成が基本です。

flowchart LR
    subgraph db ["リレーショナルデータベース"]
        T["UploadedFiles テーブル\n・Id\n・OriginalFileName\n・ContentType\n・Length\n・StoragePath\n・OwnerId\n・ScanStatus\n・CreatedAt"]
    end
    subgraph blob ["Azure Blob Storage"]
        B["BLOB 実体\navatars/tenant-a1b2/2026/08/....jpg"]
    end
    T -->|StoragePath で参照| B
namespace FileUploadSample.Models;
public class UploadedFile
{
public Guid Id { get; set; }
/// <summary>表示用の元ファイル名。出力時は必ず HTML エンコードする。</summary>
public required string OriginalFileName { get; set; }
/// <summary>サーバー側で判定した MIME タイプ。</summary>
public required string ContentType { get; set; }
public long Length { get; set; }
/// <summary>BLOB 名(コンテナー内のパス)。</summary>
public required string StoragePath { get; set; }
public required string OwnerId { get; set; }
public ScanStatus ScanStatus { get; set; } = ScanStatus.Pending;
public DateTimeOffset CreatedAt { get; set; }
}
public enum ScanStatus
{
Pending,
Clean,
Infected,
}

ここまでの要素を組み合わせた完成形です。

using System.Security.Claims;
using FileUploadSample.Models;
using FileUploadSample.Storage;
using FileUploadSample.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
namespace FileUploadSample.Controllers;
[ApiController]
[Authorize]
[Route("api/files")]
public class FilesController(
IFileStorage fileStorage,
IUploadValidator validator,
IStoragePathBuilder pathBuilder,
AppDbContext dbContext,
TimeProvider timeProvider,
ILogger<FilesController> logger) : ControllerBase
{
private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
[HttpPost]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(IFormFile file, CancellationToken cancellationToken)
{
// ① 検証(空ファイル・サイズ・拡張子・シグネチャをまとめて確認する)
var validation = validator.Validate(file);
if (!validation.IsValid)
{
// 第3章で扱った ProblemDetails 形式でエラーを返す
return ValidationProblem(detail: validation.ErrorMessage);
}
var ownerId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new InvalidOperationException("ユーザー ID を特定できません。");
// 第 1 引数は用途。コンテナー名 (uploads) とは別の階層になるため、
// ここに "uploads" を渡すと BLOB 名が uploads/uploads/... と重複する。
// 第 2 引数はファイルを区分する単位。ここではユーザーごとに分けるため ownerId を渡す
var storagePath = pathBuilder.Build("attachments", ownerId, file.FileName);
var contentType = ResolveContentType(file.FileName);
// ② BLOB へ保存(受信ストリームをそのまま転送)
await using var content = file.OpenReadStream();
var descriptor = new FileUploadDescriptor(
Content: content,
StoragePath: storagePath,
ContentType: contentType,
Metadata: new Dictionary<string, string>
{
["ownerId"] = ownerId,
["uploadedAt"] = timeProvider.GetUtcNow().ToString("O"),
},
AllowOverwrite: false);
var stored = await fileStorage.SaveAsync(descriptor, cancellationToken);
// ③ メタデータをデータベースへ登録
var now = timeProvider.GetUtcNow();
var record = new UploadedFile
{
Id = Guid.CreateVersion7(now),
OriginalFileName = file.FileName,
ContentType = contentType,
Length = file.Length,
StoragePath = stored.StoragePath,
OwnerId = ownerId,
ScanStatus = ScanStatus.Pending,
CreatedAt = now,
};
dbContext.UploadedFiles.Add(record);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("ファイル {FileId} を {StoragePath} へ保存しました。", record.Id, stored.StoragePath);
return CreatedAtAction(nameof(GetDownloadUrl), new { id = record.Id }, new { id = record.Id });
}
[HttpGet("{id:guid}/download-url")]
public async Task<IActionResult> GetDownloadUrl(Guid id, CancellationToken cancellationToken)
{
var record = await dbContext.UploadedFiles.FindAsync([id], cancellationToken);
if (record is null || record.OwnerId != User.FindFirstValue(ClaimTypes.NameIdentifier))
{
// 存在の有無を漏らさないよう、権限がない場合も 404 を返す
return NotFound();
}
if (record.ScanStatus != ScanStatus.Clean)
{
return Problem(
detail: "ウイルススキャンが完了していません。",
statusCode: StatusCodes.Status409Conflict);
}
var url = await fileStorage.CreateReadUrlAsync(
record.StoragePath, TimeSpan.FromMinutes(10), record.OriginalFileName, cancellationToken);
return Ok(new { url, expiresInSeconds = 600 });
}
private static string ResolveContentType(string fileName)
=> ContentTypeProvider.TryGetContentType(fileName, out var contentType)
? contentType
: "application/octet-stream";
}

処理の流れは次のとおりです。

flowchart TB
    S["リクエスト受信\n(multipart/form-data)"] --> V{"検証\nサイズ / 拡張子 / シグネチャ"}
    V -->|NG| E["400 Bad Request"]
    V -->|OK| P["保存先パスの生成\nGUID v7 + 日付階層"]
    P --> B["Blob Storage へ\nストリーム転送"]
    B --> D["データベースへ\nメタデータ登録"]
    D --> Q["スキャン待ち\n(ScanStatus = Pending)"]
    D --> R["201 Created"]