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、C,C之后才是真正的端点执行(也就是MVC里的Action)。那么一次完整请求的执行路径是这样的:
- 请求进入中间件A,A执行进入前的逻辑(比如记录开始时间)。
- 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.UseMiddleware<MyCustomMiddleware>();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方法,框架会自动把它当成中间件类实例化。UseMiddleware<T>扩展方法会自动判断构造函数和Invoke方法的参数,从而完成注入。
public 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(); // 用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.UseMiddleware<RequestTimingMiddleware>();注意我是通过构造函数注入了ILogger<T>,这在约定式中间件里是允许的,但有一个前提:**中间件实例本身是单例的,构造函数里只能注入单例服务。**如果你试图在构造函数里注入DbContext这种Scoped服务,框架会毫不犹豫地给你抛异常。你会看到类似“Cannot resolve scoped service from root provider”的错误。解决方法是改用InvokeAsync的参数注入,下面会提到。
4.3 第二种写法:IMiddleware接口 + 工厂模式
约定式中间件足够灵活,但它有两个不那么舒服的点:一是它和UseMiddleware<T>强绑定,很难在运行时动态决定要不要用某个中间件;二是如果同一个中间件类型要在多个管道分支里以不同配置使用,得封装工厂方法。.NET Core从3.0开始提供了IMiddleware接口,把中间件注册周期交给DI容器管理,可以让中间件自身支持Scoped依赖注入。
public class RequestTimingMiddleware2 : IMiddleware { private readonly ILogger<RequestTimingMiddleware2> _logger; public RequestTimingMiddleware2(ILogger<RequestTimingMiddleware2> 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.AddTransient<RequestTimingMiddleware2>(); app.UseMiddleware<RequestTimingMiddleware2>();因为IMiddleware的实例由DI容器创建,所以你可以在这个类的构造函数里注入DbContext、IOptions<T>这类Scoped服务。
那到底选哪种写法?我的建议是:只做简单事情、不依赖Scoped服务的中间件,用约定式;需要依赖数据库上下文、需要动态配置、需要单元测试的复杂中间件,用IMiddleware。我自己写生产级中间件时,八成用的是IMiddleware,因为它和整个依赖注入体系融合得最自然。
4.4 注册与配置:三种挂载方式的取舍与扩展方法封装
中间件的注册方式总结起来有三种,我罗列成一个小表格,方便对比:
| 注册方式 | 写法 | 适用场景 | 特点 |
|---|---|---|---|
| 内联lambda | app.Use(async (ctx, next) => ...) | 极简一次性逻辑 | 快速、无类、难以测试复用 |
| UseMiddleware | app.UseMiddleware<RequestTimingMiddleware>() | 约定式中间件或IMiddleware | 支持DI注入,推荐 |
| Map/MapWhen分支 | app.Map("/health", x => x.UseMiddleware<T>()) | 按路径/条件启用 | 独立子管道,互不干扰 |
实际项目中我强烈建议给自定义中间件写一个IApplicationBuilder扩展方法,这样Program.cs里的UseXXX链会非常清爽,而且别人读你的代码时一眼就能看懂挂了个什么东西:
public static class RequestTimingMiddlewareExtensions { public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) { return app.UseMiddleware<RequestTimingMiddleware>(); } }然后在Program.cs里:
app.UseRequestTiming();这样做的另一个好处是,你可以在扩展方法里接收配置参数,比如是否启用、只记录哪些路径、阈值时间等。例如:
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app, Action<RequestTimingOptions> configure) { // 先注册配置,再用工厂模式挂载 ... }关于选项类的设计,我习惯使用IOptions<T>,这样在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链,它就像项目的“总架构图”,一眼就能看出这个系统做了哪些通用的横切处理、它们的顺序是否合理、有没有明显遗漏。如果你也能养成这个习惯,那么读代码、排问题、做优化的效率都会上一个台阶。真到了要写自己中间件的时候,别怕踩坑,把文章里这几个典型问题记在心里,先跑通一条最小管道,再往里加逻辑,你会比大多数人更早写出稳定、可维护的中间件代码。