首页 / 资讯中心 / 文章详情

ASP.NET Core请求处理核心机制:中间件、路由与过滤器实战

ASP.NET Core请求处理核心机制:中间件、路由与过滤器实战 ★ FEATURED ARTICLE
先说个真实场景。有次我接手一个接口偶发性超时的排查调用链日志里只有请求已进入和请求已返回两条记录中间那1.8秒去哪了完全看不见。后来一层层加诊断中间件才定位到是某个自定义中间件在读取请求体之后做了二次校验把整个响应给拖住了。这件事给我的教训就是ASP.Net请求处理机制不是面试题是排查线上问题最底层的那张地图。这篇文章是ASP.Net请求处理机制初步探索之旅的Part 2标题既然叫核心那就直接拆管线里最绕人也最容易出问题的五个核心节点中间件责任链怎么拼、EndpointRouting怎么分工、请求作用域和依赖注入是什么关系、过滤器管道在哪个层级干活以及几个我实测下来高频翻车的点。整个系列的第一篇讲了管线的入口和HTTP上下文基础这一篇默认你已经会创建项目、写出一个能跑的最小中间件如果还没看过Part 1也不影响阅读遇到概念我会重新讲透。1. 从事件机器到委托接力棒管线模型为什么要推倒重来1.1 传统管道里那套十多个事件的问题做.NET Framework时代的老前辈应该还有印象当年ASP.NET的请求处理机制是围绕HttpApplication构建的。请求从服务器进来以后会按照固定的顺序触发一串事件比如BeginRequest、AuthenticateRequest、AuthorizeRequest、ResolveRequestCache、AcquireRequestState、PostAcquireRequestState……接近二十个。开发者想在哪个阶段横插一脚就去Global.asax里写一个Application_BeginRequest之类的方法或者写一个IHttpModule去订阅对应的事件。这套机制不是不能用但用久了会发现问题很多。事件顺序全由宿主框架硬编码开发者想调整非常痛苦。比如你希望某个自定义逻辑跑在认证之前但认证事件已经跑完了就只能换事件去凑合或者想办法改宿主的管线配置。再有就是IHttpModule是全局的它对所有请求生效你想要实现只有特定路径才执行某些逻辑只能自己拿HttpContext里的路径去if else写出来的代码又臭又长。还有一个更致命的点这套事件机制和服务器、页面生命周期绑定得太紧。你很难把一个请求处理逻辑从页面里抽出来单独测试更别提在非Web环境下复用同一套逻辑。1.2 RequestDelegate到底在传递什么ASP.NET Core的解法非常直接它把整个请求处理过程定义成了一个委托接力。所有处理逻辑都被拆成一个个中间件每个中间件的基本形态就是接收一个HttpContext做自己的事情然后决定是调用下一个中间件还是直接在这里终结请求。最底层的抽象就是一个委托签名是FuncHttpContext, Task在框架里有一个专门的名字叫RequestDelegate。public class TimingMiddleware { private readonly RequestDelegate _next; public TimingMiddleware(RequestDelegate next) { _next next; } public async Task InvokeAsync(HttpContext context) { // 请求刚进来先记个时间 var stopwatch System.Diagnostics.Stopwatch.StartNew(); // 交给下一个中间件等它执行完再回来 await _next(context); // 响应已经生成停表记录耗时 stopwatch.Stop(); } }这个中间件的实例本身由UseMiddlewareT()在应用启动时创建但InvokeAsync才是每次请求都执行的方法。_next(context)这行代码是整个中间件管道的核心调用它意味着我处理完了自己这一层剩下的交给下一层。如果不调用_next请求就在这里被短路往回走。我在刚开始接触这套模型时总喜欢把它和工厂流水线类比后来发现不太准确。流水线是零件单向流动每个工位只加工一次。中间件管道更像是在叠罗汉请求从最外层中间件钻进去一层层往里走走到最里面执行完业务逻辑之后响应再一层层往外冒。每一层中间件在请求进来和响应出去两个方向都有机会做事这个特性是所有中间件框架的精髓。1.3 责任链模型带来的红利为什么说这个改变是推倒重来级别因为责任链模型从根上解决了旧管道的三个痛点。第一顺序自己说了算。中间件的执行顺序完全由你在Startup.Configure里调用UseXxx的先后顺序决定想调整某个逻辑的执行时机只需要挪一挪代码行的位置。第二短路变得极其自然。任何一个中间件都可以决定不调用_next直接返回请求就在这一层结束。比如鉴权失败时直接返回401静态文件中间件命中文件后不再往下跑这些都是短路。第三可测试性直接起飞。因为中间件就是一个普通的类方法签名明确你完全可以在单元测试里构造一个HttpContext和一个假的RequestDelegate单独验证某一个中间件的行为不用再启动整个Web服务器。这也是为什么这套模型后来能被移植到各种非MVC场景甚至非HTTP场景。请求处理不再绑定在页面生命周期上它就是一个纯粹的上下文的传递和处理这套思维的迁移价值比框架本身更大。2. 中间件顺序就是架构拼错一个人请求就废了2.1 Use、Run、Map的语义别搞混中间件管道的拼装代码长什么样大家应该都见过就是Startup.Configure里那串app.UseXxx()调用。但很多初学者会把Use、Run、Map混着乱用虽然编译能过行为却完全不在预期内。app.Use(...)注册一个中间件可以自己决定是调用next继续往下传还是直接短路。这是最常用的方式。app.Run(...)注册一个终端中间件它是管道的终点没有next可调用执行到这里请求必须产生响应。app.Map(path, ...)按路径分支当请求路径匹配时就进入另一个子管道不匹配就跳过。看一个标准示例app.Use(async (context, next) { Console.WriteLine($请求进入: {context.Request.Path}); await next(); Console.WriteLine($响应返回: {context.Response.StatusCode}); }); app.Map(/health, healthApp { healthApp.Run(async context { context.Response.ContentType text/plain; await context.Response.WriteAsync(ok); }); }); app.Run(async context { await context.Response.WriteAsync(Hello from terminal middleware); });注意Map里的子管道是独立的一根小管道在子管道内部你可以再继续Use、Run。如果你在/health分支的子管道里不调用next那么请求就不会回到外面的主管道继续走到app.Run那个终端中间件。管道的分支是单向深入的不是合并后再继续这一点很多人第一次写的时候会翻车。2.2 顺序错误有多狠一个真实的401事故说到顺序我接手过一个某内部管理系统的项目接口鉴权偶尔失效用户明明登录了却偶发401。排查了半天发现一个很有意思的现象这个系统的Startup.Configure里UseRouting被写在了UseAuthentication和UseAuthorization的后面。// 错误的写法 app.UseAuthentication(); app.UseAuthorization(); app.UseRouting(); app.UseEndpoints(endpoints { ... });为什么会偶发401因为UseRouting这个中间件还没有执行路由匹配结果没有设置到当前请求上下文上后面的授权中间件在判断当前请求对应哪个认证方案时拿不到Endpoint信息只能走兜底逻辑。认证方案匹配不稳定的时候就会表现出偶发性的401。调整顺序之后问题立刻消失// 正确的写法 app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints { ... });类似的还有CORS中间件、异常处理中间件它们的位置都有特定要求。中间件顺序就是整个应用的架构图你一眼扫过去就能看出认证、授权、静态文件、路由、异常处理分别在哪一层也就大概能猜出请求会经历什么样的生命周期。这个习惯一定要养成排查在线问题的时候第一件事永远是打开Startup.Configure看一眼中间件写的顺序。2.3 响应已经开始之后什么都别改顺序问题之外中间件管道里还有一个特别隐蔽的坑Response.HasStarted。当任何代码执行了WriteAsync或者刷新了响应缓冲区之后响应头就已经被发送到客户端了。这时候你的中间件如果还想往Response.Headers里加东西或者修改Response.StatusCode框架会直接抛异常告诉你Headers are read-only, response has already started。这个问题在自定义中间件里非常常见。比如你想在某个中间件里统计响应耗时然后把它写进响应头如果统计代码放在await next()之后再写头部十有八九会踩中这个坑。因为_next调用链最深处的业务代码很可能已经往响应里写东西了。正确的做法是用Response.OnStarting注册一个回调让响应头在被真正发送之前执行app.Use(async (context, next) { context.Response.OnStarting(() { context.Response.Headers[X-Request-Id] Guid.NewGuid().ToString(N); context.Response.Headers[X-Server-Name] gateway; return Task.CompletedTask; }); await next(); });这个注册的时机必须在响应开始之前回调本身会在响应发送之前被调用这样就能安全地改写响应头。很多人遇到这个异常第一反应是去查为什么不能改header其实本质是管道执行到这一步的时候响应生命周期已经到了不可逆的阶段这不是bug是设计。3. EndpointRouting路由匹配和业务执行之间的新分界线3.1 从控制器内部路由到端点路由用过老ASP.NET MVC的朋友应该知道传统路由是由MVC框架自己在内部处理的。URL到控制器的匹配发生在MVC的执行流程里路由是MVC的私有逻辑其他框架想复用基本没门。ASP.NET Core做了个很关键的抽象——把路由从MVC里拆出来变成两个独立中间件的协作。这就是EndpointRouting端点路由的思路。在全新模型下一个URL匹配到的不是一个页面或控制器方法而是一个更通用的概念Endpoint。Endpoint代表应用里一个可执行的业务单元它可以是MVC的Action也可以是Razor Page甚至是一个完全自定义的处理器。3.2 两个中间件的配对与职责划分既然叫端点路由就绕不开两个配套的中间件UseRouting和UseEndpoints。UseRouting做的是纯匹配工作它接收当前请求的路径去路由表里找有没有匹配的Endpoint如果找到了就把这个匹配结果存到HttpContext上但此时它不会执行Endpoint。UseEndpoints才是真正干活的它检查当前请求上下文上有没有Endpoint有就去调用Endpoint对应的执行逻辑。很多第一次写ASP.NET Core的人会疑惑为什么UseRouting和UseEndpoints要拆成两个中间件而不是一个中间件一把梭原因就在于这个匹配和执行之间的缝隙给了其他中间件一个黄金窗口。app.UseRouting(); // 这个位置非常关键 app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints { endpoints.MapControllers(); });认证和授权中间件正好可以在这个窗口里读取路由匹配结果。它们可以知道这个请求到底要访问哪个Endpoint然后根据Endpoint上挂的元数据比如[Authorize]特性翻译过来的授权策略来决定放行还是拒绝。如果路由匹配和执行是一步到位的那认证授权中间件就没机会提前做判断或者只能在整个管线业务执行完之后才想起认证这回事那一切安全策略都会失去意义。3.3 怎么把路由匹配结果挂在HttpContext上调试排查路由问题的时候一个特别有用的技巧是在UseRouting之后加一个临时中间件把匹配到的Endpoint相关信息打出来app.UseRouting(); app.Use(async (context, next) { var endpoint context.GetEndpoint(); if (endpoint ! null) { Console.WriteLine($匹配到端点: {endpoint.DisplayName}); // 遍历元数据 foreach (var metadata in endpoint.Metadata) { Console.WriteLine($ 元数据: {metadata.GetType().Name}); } } else { Console.WriteLine($路径 {context.Request.Path} 没有匹配到任何端点); } await next(); }); app.UseEndpoints(endpoints { endpoints.MapControllers(); });这个临时中间件能帮你快速定位一类问题请求404了到底是路由没匹配上还是匹配上之后授权没通过导致的404很多框架为了安全会把未授权请求伪装成404通过这个方式你能看到真实的匹配结果排查效率会高很多。如果发现路径明明存在路由却匹配不到优先检查三件事路由模板里是不是有拼写错误、控制器上有没有缺失[Route]特性、有没有把MapControllers漏掉。这个排查路径我走过无数次基本百发百中。4. Scoped服务在中间件里为什么会越活越长4.1 生命周期模型不是越多越好是按需活依赖注入容器的三种生命周期是每个ASP.NET Core开发者都绕不开的基础Singleton单例整个应用一个实例、Scoped作用域内共享一个HTTP请求内一个实例、Transient每次解析都创建新实例。它们在请求管线里的对应关系可以浓缩成一张表生命周期存活范围典型例子Singleton应用进程级别创建一次反复用配置对象、日志器、缓存器Scoped一次HTTP请求的完整生命周期DbContext、当前用户信息、事务对象Transient每次解析都新建用完即弃轻量工具类、DTO处理器Scoped这个名字起得特别贴切它是跟着作用域走的。在Web应用里框架会在每个请求进来时自动创建一个请求作用域HttpContext.RequestServices就是这个作用域的服务提供器。请求结束作用域被销毁所有Scoped服务跟着释放。这套机制本意是让像DbContext这样的对象在一个请求里只创建一次避免在多个服务之间来回new导致连接浪费。4.2 中间件构造函数直接注入Scoped编译能过跑起来炸但这里有一个几乎所有人都会踩的坑中间件实例是应用启动时创建的处于根作用域根本没有当前的请求作用域。先看一段会出问题的代码public class MyScopedUsingMiddleware { private readonly RequestDelegate _next; private readonly AppDbContext _db; // 想直接在构造函数拿DbContext public MyScopedUsingMiddleware(RequestDelegate next, AppDbContext db) { _next next; _db db; } public async Task InvokeAsync(HttpContext context) { // 用 _db 做点什么 await _next(context); } }这段代码有两个结果取决于运行环境。开发环境下框架默认开启了作用域验证会在应用启动时直接抛异常提示你不能从根服务提供器中解析Scoped服务。生产环境下有时候不会抛异常代码能跑起来但藏着更深的坑这个中间件实例是单例的构造函数里注入的AppDbContext也是单例的DbContext的生命周期被硬生生拉长了。本来一次请求结束就释放的数据库上下文现在活到了进程结束连接池会被占满DbContext还会在多个并发请求之间共享表现就是偶发性的写库冲突和连接耗尽。4.3 正确的取数姿势在InvokeAsync里按需拿那中间件想用Scoped服务怎么办标准姿势是通过IServiceScopeFactory手动创建作用域用完就释放public class MyScopedUsingMiddleware { private readonly RequestDelegate _next; private readonly IServiceScopeFactory _scopeFactory; public MyScopedUsingMiddleware(RequestDelegate next, IServiceScopeFactory scopeFactory) { _next next; _scopeFactory scopeFactory; } public async Task InvokeAsync(HttpContext context) { using var scope _scopeFactory.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); // 用 db 完成本次请求的业务 await _next(context); } }还有一种更省事的写法直接把Scoped服务作为InvokeAsync的参数传进来。框架在调用中间件的InvokeAsync方法时会从请求作用域的服务提供器解析方法参数所以这种方法也能拿到正确的请求作用域内实例public async Task InvokeAsync(HttpContext context, AppDbContext db) { // db 是从当前请求作用域解析出来的 await _next(context); }关于IServiceScopeFactory.CreateScope这种写法有几个点值得注意。手动创建的作用域生命周期只由你控制一定要用using包起来确保异常时也能释放。另外在中间件里手动创建作用域意味着这个作用域和请求的默认作用域是两个不同的作用域如果两个作用域里都解析了同一个DbContext而底层数据库有事务或锁可能会引发相互等待。4.4 RequestServices会不会为空还有一个常见疑问是HttpContext.RequestServices什么时候会为空。在正常的中间件管道里框架在请求管道的最开始就设置了请求作用域RequestServices一定有值。但如果你在非常早期的地方想用这个属性比如在某些自定义的服务器实现里或者请求还没进入中间件流程时确实可能拿到null。遇到这种情况兜底做法就是像我上面那样持有IServiceScopeFactory自己创建作用域而不是依赖HttpContext.RequestServices。我在写自定义的请求诊断中间件时基本都是这个策略不赌环境只靠明确声明。5. 过滤器管道Action执行前方圆五百米的安保流程5.1 五类过滤器到底按什么顺序执行如果说中间件是请求处理管线的主动脉那过滤器就是MVC框架内部的那套安检流程。它们处理的是控制器Action生命周期里的事情。ASP.NET Core的过滤器体系总共有五种类型执行顺序绝对不能搞混过滤器类型作用执行时机AuthorizationFilter授权校验最早执行短路常用Action执行前且越权时直接返回ResourceFilter资源管理模型绑定前执行Action执行前最靠近模型绑定ActionFilterAction执行前后的钩子OnActionExecuting / OnActionExecutedExceptionFilter异常接管全局捕获Action异常Action抛异常时触发ResultFilterResult执行前后的钩子OnResultExecuting / OnResultExecuted顺序是这样的AuthorizationFilter先跑然后ResourceFilter在模型绑定前后跑再然后ActionFilter在Action方法调用前后跑如果Action抛了异常就轮到ExceptionFilter登场最后是ResultFilter在结果返回给客户端之前做收尾。用洋葱模型来理解最形象。请求先穿过授权过滤器再穿过资源过滤器再穿过动作过滤器进入Action本体。异常和结果从内往外返回异常过滤器在最外层能看到整个Action执行过程发生的一切。public class AuditActionFilter : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { // Action执行前可以记录参数、校验模型状态 var hasInvalidModel !context.ModelState.IsValid; if (hasInvalidModel) { context.Result new BadRequestResult(); return; } } public void OnActionExecuted(ActionExecutedContext context) { // Action执行后可以记录耗时、补充响应内容 if (context.Exception ! null) { // 这里也可以吞掉异常统一处理 } } }注意OnActionExecuting里的context.Result new BadRequestResult()这种短路用法。一旦在过滤器里直接给Result赋值后面的ActionFilter和Action就不会执行了但授权过滤器和资源过滤器的后置逻辑还是会跑。5.2 中间件和过滤器到底怎么分工很多新手会问既然中间件也能处理所有请求为什么还要过滤器答案是处理层级完全不同。中间件工作在MVC框架外部它看到的只是一个HttpContext不知道这个请求到底会映射到哪个Action也不知道Action的模型绑定结果是什么。过滤器工作在MVC框架内部它能拿到ActionDescriptorAction的描述信息、Action参数、模型绑定结果、模型验证状态等MVC层的专属上下文。所以选择依据很简单需要操作HttpContext层面的、全局的、要触及静态文件/路由等底层能力的用中间件需要针对MVC Action做精细化处理、拿到模型绑定和Action元数据的用过滤器。举几个典型场景。CORS、认证、HTTPS重定向、全局异常兜底这些用中间件。参数审计日志、模型验证、Action结果包装、权限粒度到每个Action的校验这些用过滤器。5.3 过滤器里做短路的几个真实场景过滤器短路最常见的场景是模型验证。接口接收到非法参数时与其让参数绑定那一层抛出异常不如在ActionFilter里统一拦截直接返回400public class ValidateModelFilter : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result new BadRequestObjectResult(context.ModelState); } } public void OnActionExecuted(ActionExecutedContext context) { } }还有一类场景是在ResourceFilter里做全局的响应压缩或者缓存拦截。因为ResourceFilter是离模型绑定最近的过滤器在它执行时模型绑定还没开始很多昂贵的资源解析可以被提前拦截。用过滤器还有一个好处就是它可以按控制器或Action粒度去标注。你不想让整个应用启用某个过滤器可以只给某个控制器打特性标签。这种灵活性中间件给不了中间件一注册就是全局的。6. 请求处理链路上最常翻车的五个点6.1 静态文件中间件和路由的顺序纠缠使用默认创建的项目模板时UseStaticFiles通常被放在UseRouting之前。这意味着静态文件请求会在路由匹配之前就被拦截处理。这个顺序本身没问题但如果你在UseStaticFiles之前又塞了一个需要认证的中间件那就可能把静态文件请求也拦截下来一堆图片和CSS全部403。常见需求是静态文件目录里有的文件直接放行没有的才走路由。实现方法有两个方向一是确保UseStaticFiles在认证中间件之前二是给静态文件中间件配置特定的路径策略。我习惯把静态文件中间件放在最外层除非有明确的部分目录需要登录才能访问需求否则越早放行性能越好。6.2 异常处理中间件的连锁吞异常UseExceptionHandler通常被放在管道最外层这个完全正确。但有个容易被忽略的点异常处理中间件捕获到异常后如果你原样返回500它没有任何问题。怕的是你写了一个自定义异常处理中间件把所有异常都转换成404。我在一个项目里见过开发人员为了前端体验统一把所有异常都映射成了404响应。结果API返回404让前端以为路由不对排查了一天最后发现是后端抛了空引用异常。异常处理中间件应该有区分的意识业务异常返回业务错误码系统异常返回500并记录日志千万别千错万错都是404。6.3 读了请求体就再也读不到了HttpRequest.Body是一个只向前的流默认情况下只能读一次。如果你在中间件里消耗了Body流后面MVC模型绑定想再读参数时就会拿到空流然后模型验证报错。解决方法是在读之前启用缓冲app.Use(async (context, next) { context.Request.EnableBuffering(); var body await new StreamReader(context.Request.Body).ReadToEndAsync(); context.Request.Body.Position 0; // 你来用 body 做点什么 await next(); });EnableBuffering会把Body缓存到临时缓冲区读取完把Position重置回0后面的模型绑定才能正常读取。做请求体日志、参数签名校验这类中间件一定要有这个步骤。6.4 响应头修改撞上HasStarted这个我在第二章的2.3小节已经详细说过这里再补充一个常见变体。很多人想用中间件做请求ID追踪代码写成了app.Use(async (context, next) { await next(); context.Response.Headers[X-Trace-Id] traceId; // 大概率炸 });在响应体已经发送之后去改Header绝大多数情况下会抛异常。解决办法就是提前用OnStarting注册或者干脆在业务代码执行之前就把Header写上。判断一个中间件该在next()之前做事还是之后做事核心就看你对响应有没有修改需求有修改需求就要么提前写要么用回调。6.5 异步用不对线程池饿死一片最后这个翻车点不是中间件特有的但在请求处理链路里特别突出。有些人习惯在中间件里用.Result或.Wait()来同步等待异步方法这在ASP.NET Core的同步上下文模型下会引起线程池饥饿并发一上来整个应用像死掉一样CPU不高但请求全部超时。我在实际项目中见过一次非常典型的例子一个中间件为了调用某个SDK的异步方法用了GetAwaiter().GetResult()单个请求没问题并发到30的时候请求排队时间直接飙到10秒以上。根源是同步等待占住了线程池线程异步回调又排不上队形成死锁式饥饿。正确的做法是全程用await从中间件到业务方法保持async一路到底。写完中间件以后检查一遍代码里有没有.Result、.Wait()、Task.Run这种阻塞调用的痕迹这个检查习惯能帮你避开大部分高并发场景下的诡异故障。我在实际排查请求链路问题时通常会把中间件顺序画一遍、把请求体是否被提前读取查一遍、把Scoped服务的注入方式翻一遍三步走下来绝大多数问题都能暴露。建议你把生产环境的Startup.Configure整段贴到本地对照这篇文章的五个节点自己review一遍大概率能挖出几个长期潜伏的坑。这些坑在文档里不会写但都在线上真实等你踩过才会懂。
阅读完成 · 觉得有帮助?
咨询建站