ASP.NET Core 中间件嵌套执行管道Kestrel Web Server 承载 ASP.NET Core 应用。请求从左侧进入 Middleware A,A.next 包裹 Middleware B,B.next 包裹 Endpoint。 Endpoint 执行完成后,响应依次执行 B.after 和 A.after,并从左侧离开。REQUEST DELEGATE · NESTED NEXT CALLSKestrel Web Server01Middleware AA.beforeA.next()02Middleware BB.beforeB.next()EndpointB.afterA.afterREQUESTRESPONSE
before 进入当前层next 包裹并调用下一层after 从下一层返回

ASP.NET Core 的请求处理管道由 Web 服务器和一组中间件共同组成。服务器负责监听和接收 HTTP 请求,将请求交给应用管道,并把管道生成的响应发送给客户端;中间件负责对请求和响应进行处理。

从概念上讲,中间件是一种装配到应用管道中,用于处理请求和响应的组件。每个组件都可以:

  • 选择是否将请求传递到管道中的下一个组件。
  • 可在管道中的下一个组件前后执行工作。

下面的示例可以直观看出执行顺序:

app.Use(async (context, next) =>
{
    Console.WriteLine("A: before");
    await next(context);
    Console.WriteLine("A: after");
});

app.Use(async (context, next) =>
{
    Console.WriteLine("B: before");
    await next(context);
    Console.WriteLine("B: after");
});

app.Run(async context =>
{
    Console.WriteLine("Endpoint");
    await context.Response.WriteAsync("Hello World!");
});

输出顺序为:

A: before
B: before
Endpoint
B: after
A: after

如果某个中间件不调用 next,后续管道就不会继续执行,这种行为称为短路。当一个委托明确用于终止管道时,应使用不接收 next 参数的 Run,而不是注册一个永远不调用 nextUse 委托。静态文件中间件等内置组件也会在成功处理请求后短路管道。

⚠️ 响应安全: 不要在响应已经发送后继续调用 next,也不要在下游中间件返回后随意修改状态码、响应头或追加正文。响应开始后修改状态码或响应头会抛出异常,追加正文还可能违反 Content-Length 或破坏 JSON、CSS、图片等响应格式。可以通过 context.Response.HasStarted 判断响应是否已经开始;需要在响应头发送前执行逻辑时,可以注册 Response.OnStarting 回调,但回调也必须在响应开始前完成注册。详见 .NET 10 中间件文档

🧩 三种定义与激活方式

ASP.NET Core 中间件主要有以下三种定义与激活方式:

  1. 内联中间件: 直接通过 Use 方法注册委托。
  2. 基于工厂激活的中间件: 实现 IMiddleware 接口,由依赖注入容器按请求解析。
  3. 基于约定的中间件: 不需要实现特定接口,只需遵循构造函数和 InvokeInvokeAsync 方法约定。

下面依次介绍这三种方式。

1. 内联中间件

使用 WebApplication.Use(Func<RequestDelegate, RequestDelegate> middleware) 来进行注册。

app.Use(next =>
{
    return async context =>
    {
        Console.WriteLine("before");
        await next(context);
        Console.WriteLine("after");
    };
});

除此之外,Use 还有两个扩展方法重载:

  • IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, Func<Task>, Task> middleware)
  • IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, RequestDelegate, Task> middleware)

💡 性能提示: 在 .NET 10 中应优先使用接收 RequestDelegate 的第二个重载。与接收 Func<Task> 的重载相比,它每次请求可以减少两个内部对象分配。

// 兼容写法:next 是 Func<Task>
app.Use(async (context, func) =>
{
    Console.WriteLine("before");
    await func();
    Console.WriteLine("after");
});

// 推荐写法:next 是 RequestDelegate
app.Use(async (context, next) =>
{
    Console.WriteLine("before");
    await next(context);
    Console.WriteLine("after");
});

两种写法在调用后续管道这一点上是等价的,第一种重载的核心实现如下:

public static IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, Func<Task>, Task> middleware)
{
    return app.Use(next =>
    {
        return context =>
        {
            Func<Task> simpleNext = () => next(context);
            return middleware(context, simpleNext);
        };
    });
}

2. 基于工厂激活的中间件

基于工厂激活的中间件需要实现 IMiddleware 接口。该接口只定义了一个 InvokeAsync 方法:第一个参数是当前请求的 HttpContext,第二个参数是表示后续管道的 RequestDelegate。调用这个委托会继续执行后续中间件;不调用则会短路后续管道。

public sealed class MyMiddleware : IMiddleware
{
    private readonly ILogger<MyMiddleware> _logger;

    public MyMiddleware(ILogger<MyMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        _logger.LogInformation("before");
        await next(context);
        _logger.LogInformation("after");
    }
}

由于 IMiddleware 实例由 IMiddlewareFactory 从依赖注入容器中解析,因此必须先将它注册为服务。这里将其注册为作用域服务,表示每个请求创建一个实例;也可以根据需要注册为瞬时服务。

// 1. 将自定义中间件注册为作用域服务
builder.Services.AddScoped<MyMiddleware>();

// 2. 将中间件添加到请求管道
// 也可以使用非泛型版本:app.UseMiddleware(typeof(MyMiddleware));
app.UseMiddleware<MyMiddleware>();

请求到达时,默认的 IMiddlewareFactory 会从当前请求的服务提供程序中解析 MyMiddleware 并调用它的 InvokeAsync 方法。请求处理完成后,框架会在 finally 块中调用 IMiddlewareFactory.Release;默认工厂的 Release 是空实现,实例生命周期和资源释放由依赖注入容器管理。

⚠️ 注意: UseMiddleware 的泛型和非泛型版本都提供了 args 参数,用于传入无法由容器提供或需要显式指定的构造函数参数。该参数只适用于基于约定的中间件;为实现了 IMiddleware 接口的中间件指定参数会直接抛出异常。

if (typeof(IMiddleware).IsAssignableFrom(middleware))
{
    if (args.Length > 0)
    {
        throw new NotSupportedException(Resources.FormatException_UseMiddlewareExplicitArgumentsNotSupported(typeof(IMiddleware)));
    }

    return UseMiddlewareInterface(app, middleware);
}

3. 基于约定的中间件

基于约定的中间件不需要实现预定义接口或继承基类,只需要遵循以下约定:

  • 中间件类型需要一个有效的公共实例构造函数,其中必须包含一个 RequestDelegate 参数。创建中间件实例时,表示后续管道的 RequestDelegate 对象会与该参数绑定。构造函数还可以包含其他由依赖注入容器提供或通过 UseMiddleware 显式传入的参数。
  • 请求处理逻辑必须定义在返回 Task 的公共 InvokeInvokeAsync 方法中。第一个参数必须是 HttpContext,其余参数会从当前请求的依赖注入容器中解析,因此对应的服务必须提前注册。

💡 依赖注入提示: 基于约定的中间件通常在应用启动、构建请求管道时创建。作用域服务不应注入它的构造函数,而应作为 InvokeInvokeAsync 的参数注入;另一种做法是改用 IMiddleware

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

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

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("before");
        await _next(context);
        _logger.LogInformation("after");
    }
}
// 注册 MyMiddleware
app.UseMiddleware<MyMiddleware>();

下面再为 MyMiddleware 添加一个需要显式传入的构造函数参数:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly bool _global;
    private readonly ILogger<MyMiddleware> _logger;

    public MyMiddleware(
        RequestDelegate next,
        bool global,
        ILogger<MyMiddleware> logger)
    {
        _next = next;
        _global = global;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("before, global: {Global}", _global);
        await _next(context);
        _logger.LogInformation("after, global: {Global}", _global);
    }
}
// true 会映射到 MyMiddleware 构造函数中的 global 参数
app.UseMiddleware<MyMiddleware>(true);

UseMiddleware 显式传入的参数用于匹配中间件构造函数中的参数。当存在多个显式参数时,建议按照构造函数的声明顺序传入,特别是多个参数类型相同时,以避免产生歧义。

InvokeInvokeAsync 的第一个参数必须是 HttpContext,其余参数由依赖注入容器按类型解析,与 UseMiddleware 显式传入的构造函数参数无关。

与基于工厂激活的中间件的区别

  • 显式参数支持不同: 显式参数是无法由容器提供或需要手动指定的构造函数参数。基于工厂激活的 IMiddleware 不支持通过 UseMiddleware 传入显式参数,而基于约定的中间件支持。
  • 生命周期不同: IMiddleware 的生命周期取决于服务注册方式。默认的 IMiddlewareFactory 会在每个请求中按注册方式解析实例;注册为作用域服务时,同一请求内共享实例,注册为瞬时服务时,每次解析都会创建实例。基于约定的中间件则在每个管道注册点构建一次,并在后续请求之间复用,但这并不等同于把它注册成依赖注入容器中的单例服务。

🛣️ 注册、终止与管道分支

中间件的定义方式解决了“组件怎么写”,而 UseRunMap 等方法解决了“组件怎么进入请求管道”。

方法用途
Use注册可以继续执行或短路后续管道的中间件。
Run注册终止当前管道的委托,不接收 next 参数。
Map根据请求路径建立独立分支。
MapWhen根据 Func<HttpContext, bool> 条件建立独立分支。
UseWhen根据条件进入分支;分支未短路时重新回到主管道。

Map 常用于按路径拆分管道:

app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) =>
    {
        Console.WriteLine("进入 /admin 分支");
        await next(context);
    });

    adminApp.Run(async context =>
    {
        await context.Response.WriteAsync("Admin branch");
    });
});

Map 匹配成功后,会把匹配到的路径段从 HttpRequest.Path 移到 HttpRequest.PathBase。分支中的链接生成或路径判断如果依赖原始路径,需要同时考虑这两个属性。

MapWhen 可以使用任意请求条件建立分支:

app.MapWhen(
    context => context.Request.Query.ContainsKey("debug"),
    debugApp =>
    {
        debugApp.Run(async context =>
        {
            await context.Response.WriteAsync("Debug branch");
        });
    });

UseWhenMapWhen 的主要区别是:如果分支没有短路或终止请求,执行流程会重新回到主管道。

app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    apiApp =>
    {
        apiApp.Use(async (context, next) =>
        {
            Console.WriteLine("API request");
            await next(context);
        });
    });

app.MapGet("/api/time", () => DateTimeOffset.UtcNow);

端点短路

使用 Endpoint Routing 时,可以通过 ShortCircuit 让匹配到的 Endpoint 在路由之后立即执行,跳过位于路由之后的其他中间件:

app.MapGet("/healthz", () => Results.Ok("healthy"))
    .ShortCircuit();

// 为多个已知但不存在的路径直接返回 404
app.MapShortCircuit(404, "robots.txt", "favicon.ico");

短路 Endpoint 不会跳过 UseRouting 之前的中间件,但会跳过路由之后的身份认证、授权、CORS、日志等组件。因此,它不适合需要 [Authorize]RequireAuthorizationRequireCors 等策略的 Endpoint;框架也会拒绝这种组合。详见 .NET 10 路由短路文档

更多分支规则可参考 .NET 10 管道分支文档

📐 中间件顺序

中间件的注册顺序会直接影响安全性、功能和性能。下面是一个常见的管道顺序:

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseRouting();
app.UseCors();

app.UseAuthentication();
app.UseAuthorization();
// 启用防伪服务时添加
app.UseAntiforgery();

app.MapStaticAssets();
app.MapControllers();

需要注意以下约束:

  • 异常处理中间件应尽量靠前,才能捕获后续组件抛出的异常。
  • UseCors 通常位于 UseRouting 之后、身份认证和授权之前。
  • UseAuthentication 必须位于 UseAuthorization 之前。
  • 启用防伪中间件时,UseAntiforgery 应放在身份认证和授权之后。
  • 使用 Endpoint 专属限流策略或 [EnableRateLimiting] 时,UseRateLimiter 必须放在 UseRouting 之后;只有全局限流器时可以放在路由之前。如果分区键依赖当前用户,还应放在身份认证之后。详见 .NET 10 限流中间件

在 Minimal API 应用中,WebApplication 会按条件自动补充部分中间件:开发环境下首先添加 UseDeveloperExceptionPage;存在 Endpoint 且用户未显式调用时添加 UseRouting 和管道末尾的 UseEndpoints;容器中能检测到认证或授权服务且用户未显式调用时,再依次添加 UseAuthenticationUseAuthorization。用户注册的中间件和 Endpoint 默认位于 UseRoutingUseEndpoints 之间。

当应用需要加入 CORS、把自定义中间件放到路由之前,或者精确控制安全组件的顺序时,应显式调用相应方法。例如加入 UseCors 时,应显式调用 UseAuthenticationUseAuthorization,确保 CORS 位于两者之前。详见 .NET 10 Minimal API 中的 WebApplication

MapStaticAssetsUseStaticFiles

从 .NET 9 开始,现代 ASP.NET Core 应用通常使用 MapStaticAssets 为构建时已知的 wwwroot 资源创建 Endpoint。该方式支持构建时压缩、内容指纹、缓存标头,并且能与 Endpoint 元数据和授权系统集成:

app.MapStaticAssets();

如果静态资源明确公开,而且不需要执行路由之后的认证、授权、CORS 或日志中间件,可以主动短路:

app.MapStaticAssets().ShortCircuit();

UseStaticFiles 在 .NET 10 中仍然有效,适合提供自定义目录、运行时生成文件、嵌入资源,或提供从 MapStaticAssets 清单中排除的资源。把 UseStaticFiles 放在授权之前会使其成功匹配的文件直接公开并短路后续管道;需要保护的文件应放在 Web 根目录之外,并在授权之后通过单独的静态文件配置或受保护的 Endpoint 提供。详见 .NET 10 静态文件文档

🧰 使用扩展方法封装中间件

可复用的中间件通常会提供一个语义清晰的 Use{Middleware} 扩展方法,使启动代码与 ASP.NET Core 内置中间件保持一致:

public static class MyMiddlewareExtensions
{
    public static IApplicationBuilder UseMyMiddleware(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<MyMiddleware>();
    }
}

注册时不再直接暴露具体的中间件类型:

app.UseMyMiddleware();

扩展方法还可以集中处理选项参数和默认配置,避免在多个应用入口中重复注册逻辑。官方完整示例可参考 编写自定义 ASP.NET Core 中间件

🔑 注入 Keyed Services

当同一个服务接口存在多个实现时,可以使用 [FromKeyedServices] 指定需要注入的实现。假设应用已经定义了 IMessageWriterLoggingMessageWriter

builder.Services.AddKeyedScoped<IMessageWriter, LoggingMessageWriter>("audit");

public class AuditMiddleware
{
    private readonly RequestDelegate _next;

    public AuditMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(
        HttpContext context,
        [FromKeyedServices("audit")] IMessageWriter writer)
    {
        writer.Write($"Request: {context.Request.Path}");
        await _next(context);
    }
}

[FromKeyedServices] 可以用于中间件构造函数以及 InvokeInvokeAsync 参数,但生命周期规则不会因此改变:基于约定的中间件如果需要作用域服务,仍应将服务注入 InvokeInvokeAsync;实现 IMiddleware 的中间件则可以按自身注册的生命周期进行构造函数注入。详见 .NET 10 依赖注入文档

⚙️ 实现原理

Func<RequestDelegate, RequestDelegate> 形式的中间件通过 IApplicationBuilder.Use 注册。ApplicationBuilder 使用 List<Func<RequestDelegate, RequestDelegate>> 保存这些组件,Build 方法再从后向前依次包装它们,最终构建出一个 RequestDelegate 委托链。

Build 会先创建位于管道末端的委托。如果请求已经匹配到了 Endpoint,但对应的 Endpoint 没有执行,说明路由管道配置有误,此时会抛出 InvalidOperationException;否则请求到达管道末端时,响应状态码会被设置为 404。下面的代码展示了这部分核心逻辑,不同 ASP.NET Core 版本可能还会包含额外的诊断代码。

private readonly List<Func<RequestDelegate, RequestDelegate>> _components = new();

public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
{
    _components.Add(middleware);
    return this;
}
public RequestDelegate Build()
{
    RequestDelegate app = context =>
    {
        // If we reach the end of the pipeline, but we have an endpoint, then something unexpected has happened.
        // This could happen if user code sets an endpoint, but they forgot to add the UseEndpoint middleware.
        var endpoint = context.GetEndpoint();
        var endpointRequestDelegate = endpoint?.RequestDelegate;
        if (endpointRequestDelegate != null)
        {
            var message =
                $"The request reached the end of the pipeline without executing the endpoint: '{endpoint!.DisplayName}'. " +
                $"Please register the EndpointMiddleware using '{nameof(IApplicationBuilder)}.UseEndpoints(...)' if using " +
                $"routing.";
            throw new InvalidOperationException(message);
        }

        context.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.CompletedTask;
    };

    for (var c = _components.Count - 1; c >= 0; c--)
    {
        app = _components[c](app);
    }

    return app;
}

🆕 .NET 10 中与中间件有关的行为变化

前文介绍的中间件定义、激活和管道构建机制在 .NET 10 中仍然适用。不过,异常处理、Cookie 身份认证和转发头配置有三项值得留意的变化。

已处理异常默认不再记录诊断信息

从 .NET 10 开始,如果自定义 IExceptionHandler.TryHandleAsync 返回 true,表示异常已经处理,异常处理中间件默认不再为该异常执行以下诊断操作:

  • 通过 ILogger 记录 UnhandledException
  • 写入 Microsoft.AspNetCore.Diagnostics.HandledException 诊断事件。
  • http.server.request.duration 指标添加 error.type 标签。

如果仍希望为已处理异常保留这些诊断信息,可以通过 SuppressDiagnosticsCallback 恢复以前的行为:

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    SuppressDiagnosticsCallback = context => false
});

回调返回 false 表示“不抑制诊断信息”。详见 .NET 10 异常处理诊断变化

在 .NET 10 中,使用 Cookie 身份认证访问已知 API Endpoint 时,未经身份验证的请求默认返回 401 Unauthorized,没有访问权限的请求默认返回 403 Forbidden,不再重定向到登录页或拒绝访问页面。

ASP.NET Core 通过 IApiEndpointMetadata 识别 API Endpoint,以下 Endpoint 会自动获得相关元数据:

  • 使用 [ApiController] 的控制器 Endpoint。
  • 读取 JSON 请求体或写入 JSON 响应的 Minimal API Endpoint。
  • 使用 TypedResults 返回类型的 Endpoint。
  • SignalR Endpoint。

这项变化更符合 API 客户端对 HTTP 状态码的预期。XHR 请求在以前的版本中就已经返回 401403,在 .NET 10 中仍保持这一行为,而不受目标是否为已知 API Endpoint 的影响。如果应用确实需要保留旧的重定向行为,可以覆盖 Cookie 身份认证的 RedirectToLoginRedirectToAccessDenied 事件。详见 .NET 10 API Endpoint 身份认证行为

转发头中间件改用 KnownIPNetworks

.NET 10 已将 Microsoft.AspNetCore.HttpOverrides.IPNetworkForwardedHeadersOptions.KnownNetworks 标记为弃用。继续使用会产生 ASPDEPR005 编译警告,应改用 System.Net.IPNetworkKnownIPNetworks

using System.Net;
using Microsoft.AspNetCore.HttpOverrides;

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto;

    options.KnownIPNetworks.Add(
        new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
});

app.UseForwardedHeaders();

UseForwardedHeaders 通常应位于 UseHttpsRedirection 以及所有依赖客户端 IP、请求协议或主机信息的中间件之前。只应信任明确配置的代理和网络,避免让客户端伪造转发头。详见 .NET 10 转发头 API 变化