第7章(後編):Azure Blob Storage への保存
この章は 第7章(前編):ファイル受信と検証 の続きです。前編で受け取って検証したファイルを、Azure Blob Storage へ保存する方法を説明します。前編のコード例を前提にしている箇所があるため、先に前編を読んでおくことをおすすめします。
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");パッケージの追加と認証
Section titled “パッケージの追加と認証”必要なパッケージを追加します。
dotnet add package Azure.Storage.Blobsdotnet add package Azure.Identitydotnet add package Microsoft.Extensions.Azure| パッケージ | 用途 |
|---|---|
Azure.Storage.Blobs | Blob Storage クライアントライブラリ |
Azure.Identity | Microsoft Entra ID による認証(DefaultAzureCredential など) |
Microsoft.Extensions.Azure | DI コンテナーへの 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 Service | App Service の マネージド ID | App Service で「ID」→「システム割り当て済み」を「オン」にすると ID が発行される。その ID に対してロールを割り当てる |
| Azure Container Apps / Azure Functions / Azure VM | 各リソースの マネージド ID | App Service と同様に、リソース側でマネージド ID を有効化してからロールを割り当てる |
| GitHub Actions などの CI/CD | サービスプリンシパル(アプリ登録)またはフェデレーション ID | Entra ID にアプリを登録し、そのサービスプリンシパルにロールを割り当てる |
割り当てのスコープ(適用範囲)は、必要最小限にすることが重要 です。サブスクリプション全体ではなく、対象のストレージアカウント、可能であればコンテナー単位まで絞り込んでください。ただし、後述のユーザー委任 SAS を発行する場合は、コンテナー単位まで絞ると SAS の発行に失敗します(理由は次の表のあとに説明します)。
# 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 で書くと次のようになります。
# 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 名よりも厳しい命名規則があります。構成ファイルから読み込む値も、この規則を満たしていなければなりません。
| 規則 | ✅ 有効な例 | ❌ 無効な例 |
|---|---|---|
| 使える文字は英小文字・数字・ハイフンのみ | uploads/user-files | Uploads(大文字)/user_files(アンダースコア)/my.files(ピリオド) |
| 長さは 3 文字以上 63 文字以下 | img | up(2 文字) |
| 先頭と末尾は英小文字か数字 | a-b | -uploads/uploads- |
| ハイフンを連続させない | up-loads | a--b |
規則に反する名前を指定すると、コンテナー作成の時点で RequestFailedException(Status: 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); }}Content-Type とメタデータの設定
Section titled “Content-Type とメタデータの設定”BLOB には システムプロパティ と ユーザー定義メタデータ を設定できます。
| 種別 | 説明 | 例 |
|---|---|---|
| システムプロパティ | HTTP ヘッダーに対応する既定のプロパティ | ContentType、CacheControl、ContentDisposition |
| ユーザー定義メタデータ | 任意の名前と値のペア | uploadedBy、originalFileName |
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";アップロード後にメタデータを更新したり読み取ったりする場合は、SetMetadataAsync と GetPropertiesAsync を使います。
// メタデータの更新(既存のメタデータは置き換えられる)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) を使います。ダウンロード時に取得した ETag を IfMatch に指定すると、値が一致しない場合に 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 で強制的に解除するまで書き込めません。try/finally で確実に ReleaseAsync を呼ぶか、そもそも無期限リースを使わない設計にしてください。
| 制御方式 | 条件ヘッダー | 用途 |
|---|---|---|
| 常に上書き | なし | 冪等なアップロード、キャッシュ的な用途 |
| 新規作成のみ | IfNoneMatch = ETag.All | 一意な名前を生成して保存する通常のアップロード |
| 楽観的同時実行制御 | IfMatch = originalETag | 既存ファイルの更新 |
| 悲観的同時実行制御 | LeaseId = leaseId(BLOB リース) | 長時間の排他が必要なバッチ処理 |
Azurite によるローカル開発
Section titled “Azurite によるローカル開発”ローカル開発では、Azure Storage のエミュレーターである Azurite を使うと、実際のストレージアカウントを作らずに動作を確認できます。
# Docker で起動する場合docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
# npm でインストールして起動する場合npm install -g azuriteazurite --silent --location ./azurite-dataAzurite は既知の開発用アカウントキーを持つため、開発環境では接続文字列 UseDevelopmentStorage=true を使用します。
{ "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);Blob Storage 実装
Section titled “Blob Storage 実装”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 つの構成セクションを読み込みます。このうち BlobStorage と FileUpload には ValidateOnStart() を付けているため、値が欠けていると起動時に OptionsValidationException で停止します。
{ "Storage": { "ServiceUri": "https://mystorageaccount.blob.core.windows.net" }, "BlobStorage": { "ContainerName": "uploads" }, "FileUpload": { "MaxFileSizeBytes": 5242880, "PermittedExtensions": [ ".jpg", ".jpeg", ".png", ".pdf" ] }}保存先パスの設計
Section titled “保存先パスの設計”BLOB 名(保存先パス)の設計は、後からの変更が困難です。次の観点を考慮して決めます。
| 観点 | 指針 |
|---|---|
| 一意性 | GUID や ULID(時刻順にソートできる 26 文字の識別子)を含め、衝突しない名前にする |
| 推測困難性 | 連番は避ける。URL を推測して他人のファイルを取得されるリスクを減らす |
| 分散 | 先頭に日付やハッシュを置き、名前が特定のプレフィックスに集中しないようにする |
| 論理的な区分 | テナント ID、ユーザー ID、用途を階層に含めて運用しやすくする |
| ライフサイクル管理 | 日付をパスに含めると、有効期限に基づく削除ポリシーを適用しやすい |
{用途}/{区分ID}/{yyyy}/{MM}/{一意なID}{拡張子}
例: avatars/tenant-a1b2/2026/08/01a004c1b9c07c2e9d4f6a8b0c1d2e3f.jpg| 部分 | 例の値 | 意味 |
|---|---|---|
| 用途 | avatars | ファイルの用途。アプリ内でファイルの種類を分ける |
| 区分 ID | tenant-a1b2 | テナント ID やユーザー ID など、ファイルを分ける単位 |
| 年 / 月 | 2026/08 | 有効期限に基づく削除ポリシーを適用しやすくする |
| 一意な ID | 01a004c1b9c07c2e9d4f6a8b0c1d2e3f | Guid.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}"; }}公開と非公開のアクセス制御
Section titled “公開と非公開のアクセス制御”Blob Storage への匿名アクセス(公開読み取り)は、既定で 無効 です。有効化するには、ストレージアカウントとコンテナーの両方で設定を変更する必要があります(手順の詳細は コンテナーと BLOB に対する匿名読み取りアクセスを構成するを参照)。
| ストレージアカウントの設定 | コンテナーのアクセスレベル | 結果 |
|---|---|---|
| 匿名アクセスを許可しない | 任意 | すべて非公開。アカウント設定が優先される |
| 匿名アクセスを許可する | プライベート(既定) | 非公開 |
| 匿名アクセスを許可する | BLOB | BLOB は匿名で読めるが、一覧は取得できない |
| 匿名アクセスを許可する | コンテナー | 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,}コントローラーからの利用
Section titled “コントローラーからの利用”ここまでの要素を組み合わせた完成形です。
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"]
3. 参考ドキュメント
Section titled “3. 参考ドキュメント”- クイック スタート: .NET 用 Azure Blob Storage クライアント ライブラリ | Microsoft Learn
- .NET を使用して BLOB をアップロードする | Microsoft Learn
- .NET を使用して BLOB のプロパティとメタデータを管理する | Microsoft Learn
- BLOB インデックス タグを使用して Azure BLOB データを管理および検索する | Microsoft Learn
- BLOB ストレージ内でコンカレンシーを管理する | Microsoft Learn
- コンテナーと BLOB 用の匿名読み取りアクセスを構成する | Microsoft Learn
- .NET を使用して Azure BLOB、Azure Files、Azure Queue のユーザー委任 SAS を作成する | Microsoft Learn
- .NET を使用してコンテナーまたは BLOB のサービス SAS を作成する | Microsoft Learn
- Azure SDK for .NET での依存関係の挿入 | Microsoft Learn
- Azure サービスを使用して .NET アプリケーションを認証する方法 | Microsoft Learn
- .NET 用 Azure Identity ライブラリを使用した認証のベスト プラクティス | Microsoft Learn
- .NET 用 Azure Identity ライブラリの資格情報チェーン | Microsoft Learn
- ローカルの Azure Storage 開発に Azurite エミュレーターを使用する | Microsoft Learn