mcpField
M
middleware-patterns

middleware-patterns

github.com/wshaddix開発8677 回インストール35 週間前に更新
リポジトリ

middleware-patterns について

middleware-patternsは、ASP.NET Core開発者向けのリクエスト処理ミドルウェアのスキルです。パイプラインの順序、分岐や短絡処理、実装パターン、例外処理、テストを扱います。

主な機能

  1. 01パイプラインの順序と影響の理解
  2. 02規約ベース、ファクトリーベース、インライン型の実装
  3. 03条件分岐と独立したパイプラインの設定
  4. 04例外とステータスコード応答の処理
  5. 05ミドルウェア動作のテスト

活用シーン

  1. 01ルーティング、認可、CORSの順序設定
  2. 02リクエストログやAPIキー検証の追加
  3. 03パスや環境ごとのパイプライン設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill middleware-patterns

インストール後、Claude Code で/middleware-patternsを入力して読み込まれたか確認します。

詳細

ASP.NET Core ミドルウェアのパターン

理由

ミドルウェアは ASP.NET Core のリクエスト処理の基盤です。適切に設計されたミドルウェアを使用すると、ロギング、認証、キャッシュなどの横断的関心事に対応できます。堅牢なアプリケーションを構築するには、パイプラインの順序とミドルウェアのパターンを理解することが重要です。


パイプラインの順序

ミドルウェアは登録された順に実行されます。順序は非常に重要です。ミドルウェアを誤った位置に配置すると、見つけにくいバグが発生します。

推奨される順序
var app = builder.Build();

// 1. Exception handling (outermost -- catches everything below)
app.UseExceptionHandler("/error");

// 2. HSTS (before any response is sent)
if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

// 3. HTTPS redirection
app.UseHttpsRedirection();

// 4. Static files (short-circuits for static content before routing)
app.UseStaticFiles();

// 5. Routing (matches endpoints but does not execute them yet)
app.UseRouting();

// 6. CORS (must be after routing, before auth)
app.UseCors();

// 7. Authentication (identifies the user)
app.UseAuthentication();

// 8. Authorization (checks permissions against the matched endpoint)
app.UseAuthorization();

// 9. Custom middleware (runs after auth, before endpoint execution)
app.UseRequestLogging();

// 10. Endpoint execution (terminal -- executes the matched endpoint)
app.MapControllers();
app.MapRazorPages();
順序が重要な理由
誤り結果
UseAuthorization() を UseRouting() より前に配置する認証にはエンドポイントのメタデータがないため、すべてのリクエストが通過します
UseCors() を UseAuthorization() より後に配置するプリフライトリクエストには認証トークンがないため失敗します
UseExceptionHandler() をカスタムミドルウェアより後に配置するカスタムミドルウェア内の例外が処理されません
UseStaticFiles() を UseAuthorization() より後に配置する静的ファイルに不要な認証が要求されます

パターン 1:規約ベースのミドルウェア

規約ベースのミドルウェアでは、RequestDelegate を受け取るコンストラクターと InvokeAsync メソッドを使用します。

public sealed class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await _next(context);
        }
        finally
        {
            stopwatch.Stop();
            _logger.LogInformation(
                "Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
                context.Request.Method,
                context.Request.Path,
                stopwatch.ElapsedMilliseconds,
                context.Response.StatusCode);
        }
    }
}

public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
        => app.UseMiddleware<RequestTimingMiddleware>();
}

// Usage in Program.cs
app.UseRequestTiming();

パターン 2:ファクトリーベース(IMiddleware)

スコープ付きサービスを必要とするミドルウェアには、IMiddleware を実装します。DI を使用して、リクエストごとにミドルウェアのインスタンスを作成します。

public sealed class TenantMiddleware : IMiddleware
{
    private readonly TenantDbContext _db;

    public TenantMiddleware(TenantDbContext db)
    {
        _db = db;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        if (tenantId is not null)
        {
            var tenant = await _db.Tenants.FindAsync(tenantId);
            context.Items["Tenant"] = tenant;
        }

        await next(context);
    }
}

// IMiddleware requires explicit DI registration
builder.Services.AddScoped<TenantMiddleware>();
app.UseMiddleware<TenantMiddleware>();
規約ベースと IMiddleware の比較
項目規約ベースIMiddleware
ライフタイムシングルトン(一度だけ作成)リクエストごと(DI から取得)
スコープ付きサービスInvokeAsync のパラメーター経由のみコンストラクターインジェクション経由
登録UseMiddleware<T>() のみservices.Add*<T>() と UseMiddleware<T>() が必要
パフォーマンスわずかに高速リクエストごとに DI から解決

パターン 3:インラインミドルウェア

シンプルで一度きりのロジックに使用します。

app.Use — 次に処理を渡す
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Request-Id"] = context.TraceIdentifier;
    await next(context);
});
app.Run — 終端
app.Run(async context =>
{
    await context.Response.WriteAsync("Fallback response");
});
app.Map — パスで分岐
app.Map("/api/diagnostics", diagnosticApp =>
{
    diagnosticApp.Run(async context =>
    {
        var data = new
        {
            MachineName = Environment.MachineName,
            Timestamp = DateTimeOffset.UtcNow
        };
        await context.Response.WriteAsJsonAsync(data);
    });
});

パターン 4:ショートサーキットロジック

ミドルウェアは next() を呼び出さないことで、パイプラインをショートサーキットできます。

リクエストの検証
public sealed class ApiKeyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _expectedKey;

    public ApiKeyMiddleware(RequestDelegate next, IConfiguration config)
    {
        _next = next;
        _expectedKey = config["ApiKey"]
            ?? throw new InvalidOperationException("ApiKey configuration is required");
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue("X-Api-Key", out var providedKey)
            || !string.Equals(providedKey, _expectedKey, StringComparison.Ordinal))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsJsonAsync(new
            {
                Error = "Invalid or missing API key"
            });
            return; // Short-circuit
        }

        await _next(context);
    }
}
フィーチャーフラグによるゲート
app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/beta"),
    betaApp =>
    {
        betaApp.Use(async (context, next) =>
        {
            var featureManager = context.RequestServices
                .GetRequiredService<IFeatureManager>();

            if (!await featureManager.IsEnabledAsync("BetaFeatures"))
            {
                context.Response.StatusCode = StatusCodes.Status404NotFound;
                return;
            }

            await next(context);
        });
    });

パターン 5:リクエストとレスポンスの操作

リクエストボディの読み取り
public sealed class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        context.Request.EnableBuffering();

        if (context.Request.ContentLength > 0 && context.Request.ContentLength < 64_000)
        {
            context.Request.Body.Position = 0;
            using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
            var body = await reader.ReadToEndAsync();
            _logger.LogDebug("Request body for {Path}: {Body}", context.Request.Path, body);
            context.Request.Body.Position = 0;
        }

        await _next(context);
    }
}
レスポンスの変更
public async Task InvokeAsync(HttpContext context)
{
    var originalBodyStream = context.Response.Body;

    using var responseBody = new MemoryStream();
    context.Response.Body = responseBody;

    await _next(context);

    context.Response.Body.Seek(0, SeekOrigin.Begin);
    var responseText = await new StreamReader(context.Response.Body).ReadToEndAsync();
    context.Response.Body.Seek(0, SeekOrigin.Begin);

    await responseBody.CopyToAsync(originalBodyStream);
}

注意: レスポンスボディの置き換えには、メモリのオーバーヘッドが追加されます。診断目的に限って使用してください。


パターン 6:例外処理ミドルウェア

組み込みの例外ハンドラー
app.UseExceptionHandler(exceptionApp =>
{
    exceptionApp.Run(async context =>
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/json";

        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();

        var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
        logger.LogError(exceptionFeature?.Error, "Unhandled exception for {Path}", context.Request.Path);

        await context.Response.WriteAsJsonAsync(new
        {
            Error = "An internal error occurred",
            TraceId = context.TraceIdentifier
        });
    });
});
IExceptionHandler(.NET 8+)

複数のハンドラーを登録でき、順番に呼び出されます。

public sealed class ValidationExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        if (exception is not ValidationException validationException)
            return false;

        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsJsonAsync(new
        {
            Error = "Validation failed",
            Details = validationException.Errors
        }, ct);

        return true;
    }
}

public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        logger.LogError(exception, "Unhandled exception");

        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        await context.Response.WriteAsJsonAsync(new
        {
            Error = "An internal error occurred",
            TraceId = context.TraceIdentifier
        }, ct);

        return true;
    }
}

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();
例外以外のエラーに対する StatusCodePages
app.UseStatusCodePagesWithReExecute("/error/{0}");

app.UseStatusCodePages(async context =>
{
    context.HttpContext.Response.ContentType = "application/json";
    await context.HttpContext.Response.WriteAsJsonAsync(new
    {
        Error = $"HTTP {context.HttpContext.Response.StatusCode}",
        TraceId = context.HttpContext.TraceIdentifier
    });
});

パターン 7:条件付きミドルウェア

UseWhen — 条件付き分岐(パイプラインに合流します)
app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    apiApp =>
    {
        apiApp.UseRateLimiter();
    });
MapWhen — 条件付き分岐(パイプラインに合流しません)
app.MapWhen(
    context => context.WebSockets.IsWebSocketRequest,
    wsApp =>
    {
        wsApp.Run(async context =>
        {
            using var ws = await context.WebSockets.AcceptWebSocketAsync();
        });
    });
環境固有のミドルウェア
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI();
}
else
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

パターン 8:分岐ミドルウェア

異なるルートプレフィックスに対して、完全に独立したパイプラインを作成します。

app.Map("/api", apiApp =>
{
    apiApp.UseExceptionHandler("/api/error");
    apiApp.UseHttpsRedirection();
    apiApp.UseAuthentication();
    apiApp.UseAuthorization();
    apiApp.UseRateLimiter();
    apiApp.MapControllers();
});

app.Map("/webhooks", webhookApp =>
{
    webhookApp.UseMiddleware<WebhookSignatureValidation>();
    webhookApp.UseMiddleware<WebhookIdempotency>();
    webhookApp.MapRazorPages();
});

app.UseExceptionHandler("/Error");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();

パターン 9:オプションを使用するミドルウェア

public class RateLimitingMiddlewareOptions
{
    public int MaxRequestsPerSecond { get; set; } = 10;
    public int BurstSize { get; set; } = 20;
    public TimeSpan BlockDuration { get; set; } = TimeSpan.FromMinutes(1);
}

public class RateLimitingMiddleware(
    RequestDelegate next,
    IOptions<RateLimitingMiddlewareOptions> options,
    IMemoryCache cache)
{
    private readonly RateLimitingMiddlewareOptions _options = options.Value;

    public async Task Invoke(HttpContext context)
    {
        var clientId = GetClientIdentifier(context);
        var cacheKey = $"ratelimit:{clientId}";

        if (!await TryAcquireTokenAsync(cacheKey))
        {
            context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            context.Response.Headers.RetryAfter = _options.BlockDuration.TotalSeconds.ToString();
            await context.Response.WriteAsync("Rate limit exceeded");
            return;
        }

        await next(context);
    }

    private string GetClientIdentifier(HttpContext context)
    {
        return context.User.Identity?.Name ??
               context.Connection.RemoteIpAddress?.ToString() ??
               "anonymous";
    }

    private async Task<bool> TryAcquireTokenAsync(string cacheKey) => true;
}

builder.Services.Configure<RateLimitingMiddlewareOptions>(options =>
{
    options.MaxRequestsPerSecond = 5;
    options.BurstSize = 10;
});

app.UseMiddleware<RateLimitingMiddleware>();

パターン 10:ミドルウェアのテスト

public class MiddlewareTests
{
    [Fact]
    public async Task SecurityHeadersMiddleware_AddsRequiredHeaders()
    {
        var middleware = new SecurityHeadersMiddleware(async (context) =>
        {
            await Task.CompletedTask;
        });

        var context = new DefaultHttpContext();

        await middleware.Invoke(context);

        Assert.Equal("nosniff", context.Response.Headers["X-Content-Type-Options"].ToString());
        Assert.Equal("DENY", context.Response.Headers["X-Frame-Options"].ToString());
    }

    [Fact]
    public async Task ApiKeyMiddleware_Returns401_WhenKeyMissing()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new[] { new KeyValuePair<string, string?>("ApiKey", "test-key") })
            .Build();

        var middleware = new ApiKeyMiddleware(async (context) =>
        {
            await Task.CompletedTask;
        }, config);

        var context = new DefaultHttpContext();
        context.Response.Body = new MemoryStream();

        await middleware.Invoke(context);

        Assert.Equal(401, context.Response.StatusCode);
    }
}

アンチパターン

レスポンスの開始後に Next を呼び出す
// BAD: Calling next after response has started
public async Task Invoke(HttpContext context)
{
    await context.Response.WriteAsync("Before");
    await next(context); // May fail
    await context.Response.WriteAsync("After"); // Won't work
}

// GOOD: Only modify response before calling next
public async Task Invoke(HttpContext context)
{
    var originalBody = context.Response.Body;
    context.Response.Body = new MemoryStream();

    await next(context);

    context.Response.Body.Position = 0;
    await context.Response.Body.CopyToAsync(originalBody);
}
コンテキストを復元しない
// BAD: Not restoring HttpContext state
public async Task Invoke(HttpContext context)
{
    var originalUser = context.User;
    context.User = new ClaimsPrincipal();
    await next(context);
    // Missing: context.User = originalUser;
}

// GOOD: Always restore state
public async Task Invoke(HttpContext context)
{
    var originalUser = context.User;
    try
    {
        context.User = new ClaimsPrincipal();
        await next(context);
    }
    finally
    {
        context.User = originalUser;
    }
}

主な原則

  • 順序がすべてです — リクエストではミドルウェアは上から下へ、レスポンスでは下から上へ実行されます
  • 例外ハンドラーは最初に配置します — UseExceptionHandler を最も外側に配置する必要があります
  • 再利用可能なミドルウェアにはインラインではなくクラスを優先します — テストや組み合わせが容易で、単一の責務に集中できます
  • スコープ付きの依存関係には IMiddleware を使用します — 規約ベースのミドルウェアはシングルトンです
  • 意図的にショートサーキットします — ミドルウェアが next() を呼び出さない理由を必ず記載します
  • 頻繁に実行される処理でのレスポンスボディ操作は避けます — リクエストごとのメモリ使用量が 2 倍になります

エージェントでの注意点

  1. UseRouting() の前に UseAuthorization() を配置しないでください -- 認可にはエンドポイントのメタデータが必要です。
  2. UseAuthorization() の後に UseCors() を配置しないでください -- CORS のプリフライトリクエストには認証トークンがありません。
  3. パススルーミドルウェアで next() を呼び出し忘れないでください -- パイプラインが予期せず途中で終了します。
  4. EnableBuffering() を呼び出さずに Request.Body を読み取らないでください -- デフォルトでは、ボディは前方にのみ読み取れます。
  5. DI に登録せずに IMiddleware を登録しないでください -- services.AddScoped<T>() による明示的な登録が必要です。
  6. 下流でレスポンスが開始された後に next() を呼び出し、Response.Body に書き込まないでください -- context.Response.HasStarted を確認してください。

参考資料

コメント