ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ASP.NET Core中间件实战:从管道模型到自定义中间件开发

ASP.NET Core中间件实战:从管道模型到自定义中间件开发 1. 为什么要懂中间件它解决的远不只是“请求处理”先从一个真实场景说起。假设你接手了一个老项目业务逻辑全写在控制器里每个Action大概长这样先判断有没有登录再记一条访问日志再try-catch处理异常发现参数不对还要统一返回格式最后才是真正的业务代码。刚开始只有几个接口这么写还能忍。等接口量到了30个、50个你会发现自己变成了一个只会复制粘贴的“日志搬运工”改一处认证逻辑要把几十个Controller全部打开改一遍。更崩溃的是谁少写了一个try-catch线上就会出现一个赤裸裸的异常堆栈直接甩到用户脸上。这个痛点的本质是横切关注点被散落到了业务代码里。所谓横切关注点指的就是认证、日志、异常处理、响应包装这类“多数接口都需要、但和具体业务无关”的逻辑。中间件就是专门把这部分逻辑抽出来统一挂在请求进入业务代码之前或之后执行的一整套机制。ASP.NET Core的中间件Middleware解决的还不只是“代码复用”这么简单。它引入了一套“管道模型”把整个HTTP请求的生命周期从进入服务器到返回响应组织成了一条可以分段插拔的处理链。每一个中间件只专注做一件事做完之后把请求交给下一个依次流转到最终的业务处理。这就有点像流水线作业安检是第一站质检是第二站组装是第三站任何一个环节出问题都可以立刻中断不再往下走。这套机制带来的实际收益是巨大的。第一代码职责清晰每个中间件只干一件事情调试时顺着管道走一遍就能定位问题出在哪一段。第二扩展成本极低新增加一个需求比如全接口限流写一个新的中间件在管道里挂上去就完事业务代码一行不用改。第三你可以精确控制“时机”请求到达业务代码之前做什么业务代码返回之后做什么中间件都可以拦截。这篇文章我打算按我自己的学习路径来讲先拆管道模型弄明白请求到底是怎么流过这一串中间件的然后过一遍内置中间件搞清楚项目模板里那一串UseXXX到底谁先谁后最后带着你从零写一个真实可用的自定义中间件。看完之后你不仅会写中间件还会知道什么时候该写、写在哪里、怎么写才不会踩坑。2. 管道模型剖开来看从委托到请求流转的全过程2.1 管道的本质一条链式的“委托”很多人第一次接触中间件是被那一堆UseXXX吓到的。其实中间件底层的数学模型非常简单你甚至可以不用框架纯代码把它模拟出来。在ASP.NET Core里请求处理的核心是一个委托类型public delegate Task RequestDelegate(HttpContext context);就这么一句话。RequestDelegate接收一个HttpContext返回一个Task。所谓管道本质上是多个RequestDelegate像链表一样串起来每个节点在执行完自己的逻辑后调用链中的“下一个节点”。而这个“下一个节点”的传递方式靠的是另一个委托类型public delegate RequestDelegate RequestDelegateFactory(RequestDelegate next);翻译成人话就是一个中间件本质上是一个“传入下一个处理者的函数”。你给它下一个处理者它还给你一个“先执行我自己的逻辑、再调用下一个处理者”的新处理函数。用代码模拟一下。假设管道里只有一个最终处理逻辑和一个自定义中间件// 最终处理器请求走到这里返回Forbidden RequestDelegate terminal context { context.Response.StatusCode 403; return context.Response.WriteAsync(Forbidden); }; // 第一个中间件先设置一个响应头再交给下一个 RequestDelegate middleware next { return context { context.Response.Headers[X-Processed-By] my-middleware; return next(context); }; }; // 组装管道 var pipeline middleware(terminal); // 执行 var context new DefaultHttpContext(); await pipeline(context);看到没有中间件就是一个包装函数传入下一个处理者返回一个新的处理者。平时你在app.Use()里写的那个next参数就是“下一个处理者”你写的逻辑就是“包装逻辑”。理解了这个模型后面所有内容都是顺水推舟的事。2.2 请求怎么穿过整条管道进入、等待、返回现在我们把管道的执行路径完整走一遍。假设你在Program.cs里写了三个中间件顺序是A、B、CC之后才是真正的端点执行也就是MVC里的Action。那么一次完整请求的执行路径是这样的请求进入中间件AA执行进入前的逻辑比如记录开始时间。A调用await next()把请求交给B。B执行进入前的逻辑再调用await next()把请求交给C。C执行进入前的逻辑再调用await next()请求进入最终的业务处理器。业务处理器执行完生成响应开始往回“返回”。C的await next()之后的代码开始执行C执行退出后的逻辑。B的await next()之后的代码执行同理轮到A。A的await next()之后的代码执行整个管道处理完成响应发给客户端。这个“进入-返回”的过程就是我在实操中觉得最值得反复体会的一个点。如果中间件只写在await next()之前它就只能影响请求进来的方向如果只写在await next()之后它就只能影响响应回去的方向想两边都处理就两边都写。你是想记录请求参数、还是想统一修改响应头、还是想统计整个请求耗时决定了你的代码应该放在调用的哪一侧。这里有一个初学者很容易踩的坑以为await next()之后HttpContext里的内容已经定下来了。实际上因为管道是异步的返回时数据可能还在流式写入中你在await next()之后直接读context.Response.Body拿到的甚至可能是空内容或者已经触发“响应开始”的保护机制。后面我会在第5节专门讲这个Response.HasStarted的坑。2.3 管道的三个核心扩展方法Use、Map、Run内置管道组装方法日常开发高频碰到的是三个Use、Map、Run。Use是最常规的给管道追加一个中间件并显式或隐式地把next传下去。语法有两种// 方式一lambda显式调用next app.Use(async (context, next) { // 进入前逻辑 await next(); // 返回后逻辑 }); // 方式二UseMiddleware扩展方法传入中间件类型 app.UseMiddlewareMyCustomMiddleware();Map用于分支管道根据请求路径把请求分支到另一个独立的管道去处理。和很多人想的不一样Map分支不会回到主管道继续执行它更像高速路上的出口匝道app.Map(/health, healthApp { healthApp.Run(async context { context.Response.ContentType text/plain; await context.Response.WriteAsync(OK); }); });凡是路径以/health开头的请求都会走healthApp这条管道处理完直接返回不会回去走主管道的后续中间件。这个语义在健康检查、Webhook回调、独立管理后台的搭建中非常常用。Run是终结中间件Terminal Middleware它不再调用next意味着管道在这里短路并直接返回。如果你用过老版本ASP.NET应该有印象app.Run()方法是以前入口的标配在中间件语境里Run则是负责“处理完就结束”的最后一个中间件。举个例子你用Map分出来的健康检查管道最后用来写响应的那段逻辑就是Run。实操中初学者最常见的困惑是Use里不写next会怎样结果就是管道在当前位置中断之后的所有中间件不再执行请求直接返回。这个行为有时候是有意的短路比如认证失败直接返回401有时候却是你忘了写next导致的意外排查时务必留意。3. 内置中间件梳理默认模板里那串UseXXX到底在干什么3.1 默认模板的中间件清单用.NET 6以上版本创建ASP.NET Core Web API项目Program.cs里会像变魔术一样自动生成一段精简管线很多新人根本不知道这些默认配置是怎么来的。我建议你把生成器隐藏代码的功能关掉亲手写一遍完整版。下面这段是从老模板抽出来的典型完整配置我加了注释var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); var app builder.Build(); if (app.Environment.IsDevelopment()) { // 开发环境的异常页面中间件必须放最前面 app.UseDeveloperExceptionPage(); } else { // 生产环境的异常处理中间件捕获异常后重定向或返回统一错误页 app.UseExceptionHandler(/Home/Error); } app.UseHttpsRedirection(); // HTTP 自动跳转 HTTPS app.UseStaticFiles(); // 支持静态文件wwwroot app.UseRouting(); // 路由匹配但不执行端点 app.UseAuthentication(); // 认证 app.UseAuthorization(); // 授权 app.MapControllers(); // 映射Controller端点 app.Run();这段代码的顺序不是我随便排的它背后有严格的约束逻辑。我一条一条说。3.2 内置中间件的正确顺序与原因UseDeveloperExceptionPage或UseExceptionHandler必须放在最靠前的位置。原因很简单中间件捕获异常是靠try-catch包住后面的管道实现的如果你把异常处理中间件放在靠后的位置前面中间件抛出的异常根本轮不到它处理。这一点是实战中新手最容易踩的坑等下第5节会再展开。UseHttpsRedirection放在第二梯队因为它做的事是检测当前请求是不是HTTPS如果不是就返回一个重定向响应。重定向响应一旦发出后续中间件就没有机会执行了所以它必须在业务逻辑之前越靠近管道头越好。UseStaticFiles要在UseRouting之前这是我被问得最多的地方。一个静态文件请求比如/css/site.css如果先经过路由走的还是静态文件的那一套判断逻辑不仅多了一次路由匹配开销还可能因为路由规则把静态文件的路径当成Controller路径造成冲突。放在路由之前静态文件中间件自己就能判断文件是否存在存在就直接短路返回不存在才继续往下传递给路由。这样既快又干净。UseRouting和UseEndpoints在.NET 6之后的模板里被简化成了MapControllers()但核心语义没变UseRouting负责根据URL匹配路由生成端点信息UseAuthentication和UseAuthorization作为中间件插在路由匹配之后、端点执行之前最终MapControllers才真正调用匹配到的Action。认证和授权为什么必须插在这两者之间因为认证需要知道“请求匹配到了哪个端点”才能决定用什么策略去验证身份而授权则必须在认证通过之后、业务代码执行之前拦截。3.3 静态文件、异常、认证几个关键中间件的边界感内置中间件里有几个比较特殊的我单独拎出来讲因为它们的行为很容易被误解。UseStaticFiles是典型的可短路中间件。它判断文件在磁盘上是否存在存在就返回文件管道到此结束之后的认证、授权统统不执行。这意味着放在wwwroot下的文件默认就是公开的不需要登录就能访问。如果你有“必须登录才能访问的PDF”之类的需求不能放在wwwroot下被静态文件直接暴露要落地到单独的文件存储服务里。UseExceptionHandler是生产环境的标准异常处理中间件它比开发环境的异常页更克制捕获到异常后会重新发起一次请求到指定路径比如/Home/Error在那个路径里渲染统一的错误页面。这里有个细节异常处理器内部会把原始异常清掉然后在请求的HttpContext.Items里塞一个ExceptionHandlerFeature错误页可以通过它读取原始异常信息。UseAuthentication和UseAuthorization则是成对出现的好搭档。认证解决的是“你是谁”授权解决的是“你能干什么”。认证中间件会解析请求里的令牌或Cookie把用户身份塞进HttpContext.User授权中间件则根据Endpoint上的[Authorize]标签决定放行还是返回403/401。这两个中间件的顺序绝对不能颠倒授权必须在认证之后否则你连用户身份都没有怎么判断权限这些内置中间件就像一整套标准家具熟悉了它们的行为边界你再写自定义中间件时就能找到自己那块“插槽”应该嵌在哪两件家具之间。4. 自定义中间件开发一个生产级场景的完整落地4.1 先定需求写一个“接口耗时统计 响应头注入”中间件理论知识讲得再多不如动手写一个。这里我选择一个在真实项目中几乎必用的场景给所有API请求统计耗时并在响应头上输出耗时数据。这个中间件在生产环境用来做性能看板、排查慢接口特别有用而且它同时涉及了“进入前逻辑”“返回后逻辑”和“响应流操作”一次能演示好几个关键点。先定义一下需求进入管道时记录请求开始时间。调用next让请求继续走。请求处理完成后把耗时写入响应的X-Process-Time响应头。用日志输出路径和耗时信息方便在控制台、文件和日志平台里查看。顺便给响应统一加一个X-Server-Node响应头用于标识是哪台服务器处理的。这个需求里“把耗时写入响应头”就是最容易出问题的地方。因为响应头一旦发送给客户端就不能再改而耗时又只有在next执行完之后才能计算出来所以不能简单地写在await next()之后。这时候就要用Response.OnStarting这个回调机制。4.2 第一种写法约定式中间件ASP.NET Core最早支持也是最灵活的写法是“约定式”你只需要定义一个类构造函数接收RequestDelegate next里面写一个InvokeAsync方法框架会自动把它当成中间件类实例化。UseMiddlewareT扩展方法会自动判断构造函数和Invoke方法的参数从而完成注入。public class RequestTimingMiddleware { private readonly RequestDelegate _next; private readonly ILoggerRequestTimingMiddleware _logger; public RequestTimingMiddleware(RequestDelegate next, ILoggerRequestTimingMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { var stopwatch Stopwatch.StartNew(); // 用OnStarting注册回调响应发出前执行 context.Response.OnStarting(() { context.Response.Headers[X-Process-Time] ${stopwatch.ElapsedMilliseconds} ms; context.Response.Headers[X-Server-Node] Environment.MachineName; return Task.CompletedTask; }); _logger.LogInformation(请求进入: {Method} {Path}, context.Request.Method, context.Request.Path); await _next(context); stopwatch.Stop(); _logger.LogInformation(请求结束: {Method} {Path} 耗时 {Elapsed}ms, context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds); } }注册方式是在Program.cs里// 中间件本身不需要注册到DI容器UseMiddleware会处理 app.UseMiddlewareRequestTimingMiddleware();注意我是通过构造函数注入了ILoggerT这在约定式中间件里是允许的但有一个前提**中间件实例本身是单例的构造函数里只能注入单例服务。**如果你试图在构造函数里注入DbContext这种Scoped服务框架会毫不犹豫地给你抛异常。你会看到类似“Cannot resolve scoped service from root provider”的错误。解决方法是改用InvokeAsync的参数注入下面会提到。4.3 第二种写法IMiddleware接口 工厂模式约定式中间件足够灵活但它有两个不那么舒服的点一是它和UseMiddlewareT强绑定很难在运行时动态决定要不要用某个中间件二是如果同一个中间件类型要在多个管道分支里以不同配置使用得封装工厂方法。.NET Core从3.0开始提供了IMiddleware接口把中间件注册周期交给DI容器管理可以让中间件自身支持Scoped依赖注入。public class RequestTimingMiddleware2 : IMiddleware { private readonly ILoggerRequestTimingMiddleware2 _logger; public RequestTimingMiddleware2(ILoggerRequestTimingMiddleware2 logger) { _logger logger; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { var stopwatch Stopwatch.StartNew(); context.Response.OnStarting(() { context.Response.Headers[X-Process-Time] ${stopwatch.ElapsedMilliseconds} ms; return Task.CompletedTask; }); await next(context); stopwatch.Stop(); _logger.LogInformation(IMiddleware方式请求 {Path} 耗时 {Elapsed}ms, context.Request.Path, stopwatch.ElapsedMilliseconds); } }注册方式和约定式有个关键区别// IMiddleware必须先注册到DI容器然后UseMiddleware builder.Services.AddTransientRequestTimingMiddleware2(); app.UseMiddlewareRequestTimingMiddleware2();因为IMiddleware的实例由DI容器创建所以你可以在这个类的构造函数里注入DbContext、IOptionsT这类Scoped服务。那到底选哪种写法我的建议是只做简单事情、不依赖Scoped服务的中间件用约定式需要依赖数据库上下文、需要动态配置、需要单元测试的复杂中间件用IMiddleware。我自己写生产级中间件时八成用的是IMiddleware因为它和整个依赖注入体系融合得最自然。4.4 注册与配置三种挂载方式的取舍与扩展方法封装中间件的注册方式总结起来有三种我罗列成一个小表格方便对比注册方式写法适用场景特点内联lambdaapp.Use(async (ctx, next) ...)极简一次性逻辑快速、无类、难以测试复用UseMiddlewareapp.UseMiddlewareRequestTimingMiddleware()约定式中间件或IMiddleware支持DI注入推荐Map/MapWhen分支app.Map(/health, x x.UseMiddlewareT())按路径/条件启用独立子管道互不干扰实际项目中我强烈建议给自定义中间件写一个IApplicationBuilder扩展方法这样Program.cs里的UseXXX链会非常清爽而且别人读你的代码时一眼就能看懂挂了个什么东西public static class RequestTimingMiddlewareExtensions { public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) { return app.UseMiddlewareRequestTimingMiddleware(); } }然后在Program.cs里app.UseRequestTiming();这样做的另一个好处是你可以在扩展方法里接收配置参数比如是否启用、只记录哪些路径、阈值时间等。例如public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app, ActionRequestTimingOptions configure) { // 先注册配置再用工厂模式挂载 ... }关于选项类的设计我习惯使用IOptionsT这样在appsettings.json里就能动态调节参数中间件的通用性会好很多。4.5 分支管道与条件短路Map、MapWhen、短路response自定义中间件写多了就会遇到“部分路径不想要某中间件”的场景。比如异常页面在/health请求里就没必要触发因为健康检查本身就是为了检测探活。这时候就可以用MapWhen做条件分支app.MapWhen( context !context.Request.Path.StartsWithSegments(/health), healthApp { healthApp.UseExceptionHandler(/Home/Error); });另一个非常常用的场景是短路响应。比如我写过一个简单的API Token校验中间件在认证逻辑之前检查请求头里的Token如果校验失败直接写401并返回不再调用next。但注意一旦你直接await context.Response.WriteAsync(...)并返回是不需要调用next的。而且如果管道里还有其他中间件你要清楚它们是收不到这次请求的。这种“短路”机制和Run终结中间件本质上是一回事合理使用能显著节省性能但用错了位置会让后续的认证、鉴权形同虚设。5. 常见问题与排查技巧实战中被卡住过的那些地方5.1 响应头改不了Response.HasStarted这个问题我印象太深了。刚写中间件那会儿我想统计接口耗时然后写在await next()之后设置响应头结果跑起来就报这个错InvalidOperationException: Headers are read-only, response has already started。原因前面提过await next()之后下游的Action可能已经开始写入响应体HTTP协议规定响应头发送必须在响应体之前一旦写入响应体响应就“启动”了此时的Header集合是只读的。解决办法有两种用Response.OnStarting在响应真正发送给客户端之前注册一个回调此时Header还没锁定可以安全修改。在next之前就设置Header只把耗时计算放到next之后这要求你使用Stopwatch这种引用型计时器而不是在返回后再去取一个局部值。实测中OnStarting是最稳妥的方案。有一点要注意OnStarting注册的回调顺序是按注册顺序执行的如果你有多个中间件都注册了回调执行顺序和注册顺序一致。如果回调里发生了异常会导致整个响应出错所以回调里尽量只做简单赋值。5.2 在构造函数里注入Scoped服务经典异常脚手架写多了很容易一上来就在中间件构造函数里写MyDbContext db。运行起来直接炸InvalidOperationException: Cannot resolve scoped service MyDbContext from root provider.原因在于中间件实例的生命周期。为了性能框架会把中间件实例按单例的方式复用约定式中间件所以你不能把Scoped服务塞进单例的构造函数里。解决办法是改用InvokeAsync方法参数注入public async Task InvokeAsync(HttpContext context, MyDbContext db) { // 框架会从当前请求的DI容器里解析db await _next(context); }或者直接用IMiddleware写法并注册为AddTransient注意每次请求都会重建实例开销略大。我自己的习惯是能用InvokeAsync参数注入就用参数注入它既没有额外分配生命期也和请求完全对齐是最符合直觉的做法。5.3 中间件顺序错误写了一个“永远执行不到”的中间件排查一个“我的中间件不生效”的问题先别怀疑代码逻辑先检查顺序。我见过最典型的问题是把UseStaticFiles写在了UseRouting之后静态文件请求全都进路由了。或者把异常处理中间件写在业务中间件后面业务抛异常直接500裸奔。一个非常实用的自查方法把Program.cs里的UseXXX链从上读到下每看到一个中间件就在纸上画一个“进入箭头”和“返回箭头”然后把你自己的中间件放到流程图里看它在请求生命周期里会覆盖哪一段。但凡你对这个图有一点犹豫顺序十有八九有问题。核心原则就两个需要处理异常的中间件尽量放在管道头部异常处理中间件之后。需要短路响应比如认证失败、静态文件命中的中间件放在越前面后续中间件的开销就越小。5.4 异步忘记return或await管道提前“漏”了写中间件时很多人会惯性写出这种代码app.Use(async (context, next) { // 一些逻辑 // 忘了next…… });结果就是管道在这里微妙地“结束了”。请求不会报错但后面所有中间件和Action都不执行。排查时看日志最直观你的Action里打印的日志压根没出现。另一个常见的异步问题是在try-finally里忘记await next()app.Use(async (context, next) { try { await next(); } finally { // 如果不写await上下文切换可能提前发生 } });无论哪种情况核心心法就是中间件的“下一步”必须被显式调用要么await next()要么return next()。如果你写了return next()那之后你的代码就不会再执行了这是有意的短路如果你写了await next()之后你还有“后半场”可以处理响应。5.5 排查工具如何快速看清当前管道里都有哪些中间件最后分享一个调试技巧。想知道当前应用实际组装了哪些中间件不需要瞎猜可以在启动时把管线信息打出来。网上有各种第三方库但最简单的方式是在Program.cs的最后把app.Properties里的中间件类型名称打印一下或者用app.Use(async (context, next) ...)在管道最前面记录一个日志把当前请求经过的中间件顺序记录下来。我自己的调试习惯是在自定义中间件里加一个_logger.LogDebug请求进来和出去各打一条日志ID用同一个TraceIdentifier关联。这样顺着日志看就能知道中间件的执行路径是否符合预期。更硬核的方法是使用DiagnosticSource订阅框架内部事件但日常根本用不到了解即可。5.6 真实踩坑案例一个响应体被“吞”掉的中间件再分享一个我在写响应包装中间件时踩过的坑。需求是想把所有成功响应统一包装成{ code:0, data: ... }格式。我当时写了一个中间件把context.Response.Body替换成一个MemoryStream在await next()执行完后读流、包装、然后再写回原Body。看起来没问题但上线后所有接口返回的Content-Length全都是错的有些接口浏览器直接显示“服务器错误”。排查才发现替换Body时下游的Action已经往MemoryStream里写入了响应但我在包装时没有重新设置Content-Length头导致HTTP响应头里的长度和实际写入的长度对不上。解决办法是在包装完写回后清掉Response.Content-Length让框架自动计算。这个案例想说明的是自定义中间件可以很强大但它深入了HTTP协议的细节任何一点偏差都可能在线上爆雷。如果你不是真的必须做响应格式统一我建议尽量在MVC的IResultFilter或IActionFilter层做而不是在中间件里动Response.Body。中间件应该专注做“管道级”的事情业务级的响应格式归MVC管这个边界要清楚。文章写到这里我自己也把中间件的知识体系重新过了一遍。最后再唠叨一句我这些年养成的习惯看一个陌生项目的代码我第一件事就是打开Program.cs看那串UseXXX链它就像项目的“总架构图”一眼就能看出这个系统做了哪些通用的横切处理、它们的顺序是否合理、有没有明显遗漏。如果你也能养成这个习惯那么读代码、排问题、做优化的效率都会上一个台阶。真到了要写自己中间件的时候别怕踩坑把文章里这几个典型问题记在心里先跑通一条最小管道再往里加逻辑你会比大多数人更早写出稳定、可维护的中间件代码。
返回列表