ミドルウェア
同じリクエストについて、処理時間を記録したり、メンテナンスモードかどうかを確認したりしたいとします。各エンドポイントに同じコードを複製するのは大変です。エンドポイントの外側で一括して処理できます。
このような処理単位をミドルウェア(middleware)と呼び、順番につないでリクエストパイプライン(request pipeline)を構成します。以前使った例外処理もミドルウェアです。この章では 3 つのミドルウェアを自分で作り、次の処理を呼び出す様子を確認します。
using System.Diagnostics;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// Middleware 1: Log when a request enters and leaves
app.Use(async (context, next) =>
{
Console.WriteLine($"→ [1] Enter: {context.Request.Method} {context.Request.Path}");
await next(context);
Console.WriteLine($"← [1] Leave: {context.Response.StatusCode}");
});
// Middleware 2: Add elapsed time to the response header
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
Console.WriteLine("→ [2] Enter: start timing");
await next(context);
Console.WriteLine("← [2] Leave");
});
// Middleware 3: In maintenance mode, return 503 without calling the next step
app.Use(async (context, next) =>
{
if (context.Request.Query.ContainsKey("maintenance"))
{
Console.WriteLine("■ [3] Maintenance mode; request intercepted");
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
await next(context);
});
app.MapGet("/hello", () =>
{
Console.WriteLine("● Endpoint: handling request");
return new { Message = "Hello" };
});
app.Run();この例では実行順を分かりやすくするため Console.WriteLine で出力します。次章では正式な方法であるログを紹介します。
実行して確認する
前章のサービスを停止し、リポジトリのルートから実行します。
cd samples/13-middleware
dotnet run別のターミナルを開き、リクエストを送信します。
curl -i http://localhost:5080/helloHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Elapsed-Ms: 1
{"message":"Hello"}サービスを実行しているターミナルに戻ると、次のように出力されています。
→ [1] Enter: GET /hello
→ [2] Enter: start timing
● Endpoint: handling request
← [2] Leave
← [1] Leave: 200次に ?maintenance パラメーターを付けてリクエストします。
curl -i "http://localhost:5080/hello?maintenance"HTTP/1.1 503 Service Unavailable
Content-Length: 0
X-Elapsed-Ms: 1→ [1] Enter: GET /hello
→ [2] Enter: start timing
■ [3] Maintenance mode; request intercepted
← [2] Leave
← [1] Leave: 503この場合、エンドポイントは実行されません。X-Elapsed-Ms の値はマシンの速度によって変わり、0 など別の値になることもあります。
パイプラインのモデル
最初のリクエストの出力を図にすると、次のようになります。
リクエスト ──► [1] ──► [2] ──► [3] ──► エンドポイント
│
レスポンス ◄── [1] ◄── [2] ◄── [3] ◄───────┘ミドルウェアは次の処理を呼び出す前と後の両方でコードを実行できます。最初のミドルウェアを見てみましょう。
using System.Diagnostics;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// Middleware 1: Log when a request enters and leaves
app.Use(async (context, next) =>
{
Console.WriteLine($"→ [1] Enter: {context.Request.Method} {context.Request.Path}");
await next(context);
Console.WriteLine($"← [1] Leave: {context.Response.StatusCode}");
});
// Middleware 2: Add elapsed time to the response header
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
Console.WriteLine("→ [2] Enter: start timing");
await next(context);
Console.WriteLine("← [2] Leave");
});
// Middleware 3: In maintenance mode, return 503 without calling the next step
app.Use(async (context, next) =>
{
if (context.Request.Query.ContainsKey("maintenance"))
{
Console.WriteLine("■ [3] Maintenance mode; request intercepted");
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
await next(context);
});
app.MapGet("/hello", () =>
{
Console.WriteLine("● Endpoint: handling request");
return new { Message = "Hello" };
});
app.Run();contextはHttpContextで、今回のリクエストとレスポンスの情報をすべて含みます。nextはパイプラインの次の処理を表します。- 20 行目の
await next(context)はリクエストを次の処理に渡し、その完了を待機します。
19 行目が先に実行されます。後続のコードが正常に戻ると 21 行目が実行されるため、この例では 200 または 503 を記録できます。これはその時点でレスポンスが未送信という意味ではありません。エンドポイントがすでにレスポンス本文を書き出していることもあります。後続コードで未処理例外が発生すると、await next(context) より後の通常の文は実行されません。必ず実行する必要のある後処理は finally に置きます。
この仕組みなら next の前に計測を開始し、後で終了できます。また、try/catch で next を囲めば例外を処理できます。両方の処理をまとめておくことで、各エンドポイントで繰り返す必要がなくなります。
FastAPI との比較
FastAPI の @app.middleware("http") とほぼ同じです。response = await call_next(request) は await next(context) に相当し、その前のコードでリクエストを、その後のコードでレスポンスを処理します。
レスポンスヘッダーを変更する
using System.Diagnostics;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// Middleware 1: Log when a request enters and leaves
app.Use(async (context, next) =>
{
Console.WriteLine($"→ [1] Enter: {context.Request.Method} {context.Request.Path}");
await next(context);
Console.WriteLine($"← [1] Leave: {context.Response.StatusCode}");
});
// Middleware 2: Add elapsed time to the response header
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
Console.WriteLine("→ [2] Enter: start timing");
await next(context);
Console.WriteLine("← [2] Leave");
});
// Middleware 3: In maintenance mode, return 503 without calling the next step
app.Use(async (context, next) =>
{
if (context.Request.Query.ContainsKey("maintenance"))
{
Console.WriteLine("■ [3] Maintenance mode; request intercepted");
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
await next(context);
});
app.MapGet("/hello", () =>
{
Console.WriteLine("● Endpoint: handling request");
return new { Message = "Hello" };
});
app.Run();ミドルウェア 2 は処理時間をレスポンスヘッダーに書き込みます。直感的には await next(context) の後に書きたくなりますが、その時点ではレスポンスの送信がすでに始まっていることがあります。HTTP レスポンスはステータス行とヘッダーを先に送信し、その後に本文を送信します。エンドポイントが本文を書き始めるとヘッダーも送信済みなので、その後に変更すると例外が発生します。
そのため 28~32 行目では Response.OnStarting を使い、レスポンスヘッダーを送信する直前に実行するコールバックを登録します。この時点ならヘッダーを変更できます。ただし本文が生成済みとは限りません。ストリーミングレスポンスでは、その後も内容が生成されます。
技術詳細
X-Elapsed-Ms に記録されるのは、ミドルウェア 2 に入ってからレスポンス送信が始まるまでの時間です。クライアントがレスポンス全体を受信するまでの時間ではありません。
短絡
using System.Diagnostics;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// Middleware 1: Log when a request enters and leaves
app.Use(async (context, next) =>
{
Console.WriteLine($"→ [1] Enter: {context.Request.Method} {context.Request.Path}");
await next(context);
Console.WriteLine($"← [1] Leave: {context.Response.StatusCode}");
});
// Middleware 2: Add elapsed time to the response header
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
Console.WriteLine("→ [2] Enter: start timing");
await next(context);
Console.WriteLine("← [2] Leave");
});
// Middleware 3: In maintenance mode, return 503 without calling the next step
app.Use(async (context, next) =>
{
if (context.Request.Query.ContainsKey("maintenance"))
{
Console.WriteLine("■ [3] Maintenance mode; request intercepted");
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
await next(context);
});
app.MapGet("/hello", () =>
{
Console.WriteLine("● Endpoint: handling request");
return new { Message = "Hello" };
});
app.Run();ミドルウェア 3 はクエリ文字列に maintenance があるか調べます。ある場合はステータスコード 503(Service Unavailable)を設定して直接 return し、next を呼び出しません。
next を呼び出さなければ、それより後の処理は実行されません。これを短絡(short-circuit)と呼びます。ミドルウェア 3 が正常に戻ると、手前にある 1 と 2 は引き続き実行されるため、503 と処理時間を記録できます。
組み込みミドルウェアも短絡することがあります。認可チェックでは、保護されたエンドポイントに有効な ID がなければ 401 を返すことがあります。CORS ミドルウェアはプリフライトリクエストに応答し、静的ファイルミドルウェアはファイルが見つかるとその内容を返します。JWT の認証はトークンを検証して ID を確立する処理であり、トークンが無効だからといってすべてのリクエストを遮断するわけではありません。アクセスを許可するかどうかは、エンドポイントの認可要件によって決まります。
登録順が重要な理由
app.Use... で追加したミドルウェアは、呼び出した順に並びます。先にあるミドルウェアは後続を呼び出すことも、直接戻ることもできます。
この例の順序を入れ替えるとどうなるか考えてみましょう。
- ミドルウェア 3 を先頭に置くと、メンテナンスモードのリクエストはミドルウェア 1 に入る前に遮断され、記録されません。
- ミドルウェア 2 を最後に置くと、前段の処理にかかった時間は計測されません。
組み込みミドルウェアの場合、順序は正しさと安全性に直結します。
| ミドルウェア | 配置位置 | 理由 |
|---|---|---|
UseExceptionHandler | 例外を捕捉する対象の処理より前 | 後続の処理がスローした例外だけを捕捉できる |
UseStatusCodePages | 前方 | 後続の処理が生成した空のエラーレスポンスだけを処理できる |
UseCors | ルーティングの後、認証・認可の前 | プリフライトを CORS ポリシーで先に処理してから、実リクエストの ID と権限を確認する |
UseAuthentication | 認可の前 | 「何ができるか」を判断する前に「誰か」を把握する |
UseAuthorization | エンドポイントの前 | 権限のないリクエストをエンドポイント実行前に拒否する |
第 09 章では後続の処理で発生する例外を捕捉するため、例外処理を前の方に置きました。認証と認可の順序は第 18~20 章で使います。
注意
順序の誤りは起動時に必ず分かるとは限りません。例外処理ミドルウェアより前に置いたコードで例外が発生しても、そのミドルウェアは捕捉できません。組み込みミドルウェアを追加するときは推奨される順序を確認してください。
技術詳細
この例では UseRouting() を明示的に呼び出していません。WebApplication はルートの照合をカスタムミドルウェアより前に、エンドポイントの実行を後に配置します。そのためここでは context.GetEndpoint() で照合済みのエンドポイントを取得でき、一致しない場合は null になります。ルーティングミドルウェアの位置を手動で変更する場合は、この順序を改めて検討してください。
まとめ
- ミドルウェアはリクエスト処理パイプラインの構成要素で、
app.Use(async (context, next) => { ... })のように記述します。 await next(context)は後続の処理を呼び出します。後続が正常に戻ってから、その後の文が実行されます。- レスポンスヘッダーは送信前に変更します。
Response.OnStartingでコールバックを登録できます。 nextを呼ばないことを短絡と呼びます。後続の処理は実行されず、手前のミドルウェアは戻りの処理を続けます。- カスタムミドルウェアは登録順に並びます。例外処理は保護対象より前に置き、認証は認可より前に実行します。
次章:ログ——Console.WriteLine の代わりに ILogger を使います。前章:構成と Options。
