想像你走進一片森林,每個訪問都是一個 HTTP 請求 🌳。森林裡的每個樹屋(Service / Middleware)都會依序經過,有的樹屋會幫你掛上名牌(CorrelationId),有的會在入口拉下柵欄(Maintenance Mode),還有的會檢查你有沒有森林通行證(API Key)。這些小樹屋站點就是 Middleware,它們靜靜地守護著這片森林,讓森林安全、有序


當系統有很多請求同時進來時,Log 會變得很亂。如果我們能幫每一條請求配一個 唯一編號 (Correlation Id),那麼不論請求經過多少 Service,都能用這個編號把 Log 串起來。就好比 📦 快遞公司給每個包裹一個「物流追蹤碼」,你就能隨時查到它走過的所有路徑

- 嘗試從 Request Header 讀取 X-Correlation-ID(若前端傳進來就沿用)
- 若沒有,就自己產生一個 Guid
- 存到 HttpContext.Items(只活在這次請求週期,方便後面程式讀取)
- 同時放進 Response Header(讓前後端對齊)
- 用 ILogger.BeginScope() 把 CorrelationId 串進所有 Log

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| public class CorrelationIdMiddleware { private const string CorrelationIdHeader = "X-Correlation-ID"; private RequestDelegate _next; private readonly ILogger<CorrelationIdMiddleware> _logger; public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger) { _next = next; _logger = logger; }
public void Invoke(HttpContext context) { var correlationId = context.Request.Headers.TryGetValue(CorrelationIdHeader, out StringValues existing) ? existing.ToString() : Guid.NewGuid().ToString("N");
context.Items.Add(CorrelationIdHeader, correlationId);
context.Response.OnStarting(() => { if (!context.Response.Headers.ContainsKey(CorrelationIdHeader)) { context.Response.Headers.Add(CorrelationIdHeader, correlationId); } return Task.CompletedTask; });
var sw = Stopwatch.StartNew();
using (_logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId })) { _logger.LogInformation("Incoming {Method} {Path}", context.Request.Method, context.Request.Path); _next(context); sw.Stop(); _logger.LogInformation("Completed {StatusCode} in {Elapsed} ms", context.Response.StatusCode, sw.ElapsedMilliseconds); } } }
|

注入管線
1 2 3 4 5 6 7 8
| app.UseMiddleware<CorrelationIdMiddleware>();
app.MapGet("/debug", (HttpContext ctx) => { var cid = ctx.Items["X-Correlation-Id"]?.ToString(); return $"Your Correlation Id = {cid}"; });
|


有時候系統要維護(更新、資料庫升級),就需要一種「一鍵開關」,大部分人進來 → 統一回 503 Service Unavailable。但特殊路徑(例如 /health,給 Kubernetes 偵測) → 照常回 200
特殊 IP(例如工程師的電腦) → 照常可用,方便內部測試。就像商場大門口貼「暫停營業」,但員工憑識別證還能進

- 檢查 Enabled 是否開啟
- 如果符合 白名單路徑 / 白名單 IP,則直接放行
- 其他請求 → 回 503,並加上 Retry-After
1 2 3 4 5 6
| "Maintenance": { "Enabled": true, "RetryAfter": "00:05:00", "BypassPaths": [ "/health" ], "AllowIps": [ "127.0.0.1" ] }
|

一般請求
1 2 3 4
| GET /products → 回 503 Service Unavailable → Header: Retry-After: 300 (秒) → Body: "Service under maintenance. Please try again later."
|
Health Check(例如 /health)
1 2
| GET /health → 仍然回 200 OK (方便 Kubernetes / 負載平衡器檢查)
|
白名單 IP(例如 127.0.0.1)
1 2
| GET /orders → 照常執行,不受影響(方便工程師自己測)
|
MaintenanceModeMiddleware.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| using Microsoft.Extensions.Options; using System.Globalization; using System.Net;
public sealed class MaintenanceModeMiddleware { private readonly RequestDelegate _next; private readonly IOptionsMonitor<MaintenanceOptions> _options;
public MaintenanceModeMiddleware(RequestDelegate next, IOptionsMonitor<MaintenanceOptions> options) { _next = next; _options = options; }
public async Task InvokeAsync(HttpContext context) { var opt = _options.CurrentValue;
if (!opt.Enabled || ShouldBypass(context, opt)) { await _next(context); return; }
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
if (opt.RetryAfter is { } ra) context.Response.Headers["Retry-fAter"] = ((int)ra.TotalSeconds).ToString(CultureInfo.InvariantCulture);
await context.Response.WriteAsync("Service under maintenance. Please try again later."); }
private static bool ShouldBypass(HttpContext ctx, MaintenanceOptions opt) { var path = ctx.Request.Path;
if (opt.BypassPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase))) return true;
var ip = ctx.Connection.RemoteIpAddress; if (ip is not null && opt.AllowIps.Contains(ip.ToString())) return true;
return false; } }
public static class MaintenanceExtensions { public static IServiceCollection AddMaintenance(this IServiceCollection services, IConfiguration cfg) => services.Configure<MaintenanceOptions>(cfg.GetSection("Maintenance"));
public static IApplicationBuilder UseMaintenanceMode(this IApplicationBuilder app) => app.UseMiddleware<MaintenanceModeMiddleware>(); }
|


這個是給「合作夥伴 / 內部系統」的簡單驗證方式,不需要完整的 JWT 或 OAuth。只要在 Header 或 Query 帶正確的 API Key 才能進來。就像去遊樂園,要有門票才能進,不然直接擋在大門口,不讓你排隊

- 檢查是否在 白名單路徑(例如 /health) → 直接放行
- 嘗試從 Header / Query 抓 API Key
- 如果 Key 不存在或錯誤 → 直接回 401 Unauthorized,不用浪費後面昂貴的資源(DB、Auth)
1 2 3 4 5 6 7
| public sealed class ApiKeyOptions { public string HeaderName { get; set; } = "X-API-KEY"; public string QueryName { get; set; } = "api_key"; public HashSet<string> Keys { get; set; } = new(StringComparer.Ordinal); public string[] BypassPaths { get; set; } = new[] { "/health" }; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| using Microsoft.Extensions.Options;
public sealed class ApiKeyMiddleware { private readonly RequestDelegate _next; private readonly IOptions<ApiKeyOptions> _options;
public ApiKeyMiddleware(RequestDelegate next, IOptions<ApiKeyOptions> options) { _next = next; _options = options; }
public async Task InvokeAsync(HttpContext context) { var opt = _options.Value; var path = context.Request.Path;
if (opt.BypassPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase))) { await _next(context); return; }
var key = context.Request.Headers[opt.HeaderName].FirstOrDefault() ?? context.Request.Query[opt.QueryName].FirstOrDefault();
if (string.IsNullOrEmpty(key) || !opt.Keys.Contains(key)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers["WWW-Authenticate"] = "ApiKey"; await context.Response.WriteAsync("API key is missing or invalid."); return; }
await _next(context); } }
public static class ApiKeyExtensions { public static IServiceCollection AddApiKeyAuth(this IServiceCollection services, IConfiguration cfg) { var section = cfg.GetSection("ApiKeyAuth"); services.Configure<ApiKeyOptions>(o => { o.HeaderName = section["HeaderName"] ?? o.HeaderName; o.QueryName = section["QueryName"] ?? o.QueryName; var keys = section.GetSection("Keys").Get<string[]>() ?? Array.Empty<string>(); o.Keys = new HashSet<string>(keys, StringComparer.Ordinal); o.BypassPaths = section.GetSection("BypassPaths").Get<string[]>() ?? o.BypassPaths; }); return services; }
public static IApplicationBuilder UseApiKeyAuth(this IApplicationBuilder app) => app.UseMiddleware<ApiKeyMiddleware>(); }
|

1 2 3 4 5 6 7 8
| { "ApiKeyAuth": { "HeaderName": "X-API-KEY", "QueryName": "api_key", "Keys": [ "dev-key-123", "prod-key-456" ], "BypassPaths": [ "/health" ] } }
|
Program.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| var builder = WebApplication.CreateBuilder(args); builder.Services.AddApiKeyAuth(builder.Configuration);
var app = builder.Build();
app.UseRouting(); app.UseApiKeyAuth(); app.UseAuthentication(); app.UseAuthorization();
app.MapGet("/health", () => "OK"); app.MapGet("/products", () => new[] { "A", "B", "C" });
app.Run();
|
1 2 3
| curl -i http://localhost:5000/products curl -i -H "X-API-KEY: dev-key-123" http://localhost:5000/products curl -i "http://localhost:5000/products?api_key=dev-key-123"
|


到這裡,我們已經在森林裡走完三個重要的哨站
CorrelationIdMiddleware 🌿 → 幫每位訪客掛上專屬號碼牌。
MaintenanceModeMiddleware 🍂 → 維護時暫時拉下柵欄,但留後門給森林守衛。
ApiKeyMiddleware 🌸 → 只有拿到通行證的人才能繼續前行。
就像一片健康的森林需要規則與守護,WebAPI 也需要 Middleware 來維持秩序與安全
