ASP.NET Core 中间件
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,而不是注册一个永远不调用 next 的 Use 委托。静态文件中间件等内置组件也会在成功处理请求后短路管道。
⚠️ 响应安全: 不要在响应已经发送后继续调用
next,也不要在下游中间件返回后随意修改状态码、响应头或追加正文。响应开始后修改状态码或响应头会抛出异常,追加正文还可能违反Content-Length或破坏 JSON、CSS、图片等响应格式。可以通过context.Response.HasStarted判断响应是否已经开始;需要在响应头发送前执行逻辑时,可以注册Response.OnStarting回调,但回调也必须在响应开始前完成注册。详见 .NET 10 中间件文档。
🧩 三种定义与激活方式
ASP.NET Core 中间件主要有以下三种定义与激活方式:
- 内联中间件: 直接通过
Use方法注册委托。 - 基于工厂激活的中间件: 实现
IMiddleware接口,由依赖注入容器按请求解析。 - 基于约定的中间件: 不需要实现特定接口,只需遵循构造函数和
Invoke或InvokeAsync方法约定。
下面依次介绍这三种方式。
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的公共Invoke或InvokeAsync方法中。第一个参数必须是 HttpContext,其余参数会从当前请求的依赖注入容器中解析,因此对应的服务必须提前注册。
💡 依赖注入提示: 基于约定的中间件通常在应用启动、构建请求管道时创建。作用域服务不应注入它的构造函数,而应作为
Invoke或InvokeAsync的参数注入;另一种做法是改用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 显式传入的参数用于匹配中间件构造函数中的参数。当存在多个显式参数时,建议按照构造函数的声明顺序传入,特别是多个参数类型相同时,以避免产生歧义。
Invoke 或 InvokeAsync 的第一个参数必须是 HttpContext,其余参数由依赖注入容器按类型解析,与 UseMiddleware 显式传入的构造函数参数无关。
与基于工厂激活的中间件的区别
- 显式参数支持不同: 显式参数是无法由容器提供或需要手动指定的构造函数参数。基于工厂激活的
IMiddleware不支持通过UseMiddleware传入显式参数,而基于约定的中间件支持。 - 生命周期不同:
IMiddleware的生命周期取决于服务注册方式。默认的IMiddlewareFactory会在每个请求中按注册方式解析实例;注册为作用域服务时,同一请求内共享实例,注册为瞬时服务时,每次解析都会创建实例。基于约定的中间件则在每个管道注册点构建一次,并在后续请求之间复用,但这并不等同于把它注册成依赖注入容器中的单例服务。
🛣️ 注册、终止与管道分支
中间件的定义方式解决了“组件怎么写”,而 Use、Run、Map 等方法解决了“组件怎么进入请求管道”。
| 方法 | 用途 |
|---|---|
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");
});
});
UseWhen 与 MapWhen 的主要区别是:如果分支没有短路或终止请求,执行流程会重新回到主管道。
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]、RequireAuthorization 或 RequireCors 等策略的 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;容器中能检测到认证或授权服务且用户未显式调用时,再依次添加 UseAuthentication 和 UseAuthorization。用户注册的中间件和 Endpoint 默认位于 UseRouting 与 UseEndpoints 之间。
当应用需要加入 CORS、把自定义中间件放到路由之前,或者精确控制安全组件的顺序时,应显式调用相应方法。例如加入 UseCors 时,应显式调用 UseAuthentication 和 UseAuthorization,确保 CORS 位于两者之前。详见 .NET 10 Minimal API 中的 WebApplication。
MapStaticAssets 与 UseStaticFiles
从 .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] 指定需要注入的实现。假设应用已经定义了 IMessageWriter 和 LoggingMessageWriter:
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] 可以用于中间件构造函数以及 Invoke 或 InvokeAsync 参数,但生命周期规则不会因此改变:基于约定的中间件如果需要作用域服务,仍应将服务注入 Invoke 或 InvokeAsync;实现 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 异常处理诊断变化。
Cookie 身份认证对 API Endpoint 返回 401 或 403
在 .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 请求在以前的版本中就已经返回 401 或 403,在 .NET 10 中仍保持这一行为,而不受目标是否为已知 API Endpoint 的影响。如果应用确实需要保留旧的重定向行为,可以覆盖 Cookie 身份认证的 RedirectToLogin 和 RedirectToAccessDenied 事件。详见 .NET 10 API Endpoint 身份认证行为。
转发头中间件改用 KnownIPNetworks
.NET 10 已将 Microsoft.AspNetCore.HttpOverrides.IPNetwork 和 ForwardedHeadersOptions.KnownNetworks 标记为弃用。继续使用会产生 ASPDEPR005 编译警告,应改用 System.Net.IPNetwork 和 KnownIPNetworks:
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 变化。