资讯详情

资讯详情

中间件四个钩子与一次短路:从打日志到掌控请求生命周期

一提到中间件我周围不少人的第一反应就是“打日志嘛”最多再加一个“登录鉴权”。这个认知不能说错但实在太可惜了。你手上的中间件体系真正值钱的是它对一次请求全生命周期的控制力。本篇文章我会把中间件里的四个钩子、一次短路机制以及框架自带的那几个工具一次讲透讲清楚它们分别在什么时机触发、能帮我们省下哪些事、又会埋下哪些坑。不管是刚接触服务端开发的新人还是想把项目里的中间件用明白的开发者这篇文章应该都能让你有收获。先说明一点不同框架的中间件写法不完全一样有的叫中间件有的叫过滤器有的叫管道。但核心的钩子设计、短路逻辑和内置工具思路是通用的。下面我用一套最常见的约定来讲你在自己项目里很容易对照上。1. 中间件的本来面目为什么说它不只是打日志1.1 一次请求要经过多少道关卡你先想一个场景一个用户请求从客户端发出到你服务器上的业务代码真正开始干活中间要经历什么路由匹配、参数解析、跨域判断、鉴权校验、限流检查、请求日志记录等业务代码返回之后还要统一包装响应格式、计算耗时、记录审计日志、处理全局异常。这些事有一个共同特点它们都不属于具体业务逻辑但又离不开对请求和响应的观察与干预。如果全部写在业务方法里代码会迅速膨胀一百个接口就得重复一百遍。中间件解决的问题就是把这些“横切关注点”从业务代码里抽出来变成一条流水线上的独立工位。请求像传送带上的包裹依次经过每个工位每个工位只干自己那件事。1.2 打日志只是顺手的事真正值钱的是控制流打日志确实是最常见的中间件用法因为日志本质上是“观察”——你通过中间件看清请求长什么样。但观察只是中间件的入口级能力它更重要的能力是“控制”。比如说一个接口要求必须登录才能访问。如果只在业务代码里加校验你会面临两个问题一是每个需要登录的接口都要重复写校验逻辑二是将来要改校验规则比如改成“部分接口允许匿名访问”你得挨个接口去改。用中间件就简单了在业务逻辑之前加一道关卡不满足条件就中断请求直接返回401业务代码根本不会被触达。这就叫控制流。再比如限流。你想让某个接口每分钟最多被访问100次在中间件里统计一下次数超过就短路返回429。这同样是控制。还有缓存命中直接返回结果、黑名单IP直接拒绝、灰度发布时按规则分流这些本质上都是在业务执行之前“截胡”——中间件不只会记录还能决定后续代码跑不跑。1.3 为什么几乎所有现代框架都离不开它你可以把中间件理解为“框架给你开的一扇窗”。如果没有中间件框架和业务代码之间是模糊的日志、鉴权、异常处理这些事只能到处散落。有了中间件框架把一次请求的完整旅程拆成若干阶段在每个阶段的入口和出口留出注册位置你把自己的逻辑挂上去就行。这就是解耦。而且中间件之间天然具有顺序性。这种顺序性非常珍贵它让你可以精细控制每道逻辑的先后关系先做跨域再做鉴权先记日志再校验参数先解析请求体再进入业务。顺序就是架构中间件让你用“谁先注册谁先执行”这一条规则就完成了整个链路的编排。2. 四个钩子把请求生命周期切成四段2.1 钩子确实是四个不是凭空凑出来的一次请求不管看起来多复杂生命周期都可以粗略切成四段进来到达、业务执行前、业务执行后、响应发出后。框架如果只给你一个“在任意位置插一段代码”的能力你很难控制好执行的精确时机。但分成四个钩子之后你能很清晰地知道我现在写的这段逻辑到底是想在业务之前拦一下还是在业务之后补一刀。我常用的四个钩子命名是onEnter、beforeHandler、afterHandler、onExit。再次强调不同框架的叫法可能不同有的叫OnActionExecuting、OnActionExecuted有的叫preHandler、postHandler但对应关系是明确的。下面是思路导览钩子触发时机典型用途onEnter请求刚进入中间件链未做任何业务处理请求ID生成、基础日志、跨域配置、链路信息注入beforeHandler业务代码执行之前参数已基本就绪登录鉴权、参数校验、限流、黑名单拦截afterHandler业务代码正常返回之后响应尚未发出统一响应包装、审计日志、耗时计算onExit响应已经发送给客户端整段请求结束资源清理、连接释放、指标上报、兜底日志2.2 第一个钩子 onEnter请求刚落地onEnter是整个中间件链的起点你的请求刚被框架接收还没解析也还没做任何业务动作。这个钩子的特点是“早”适合做一切希望在整个请求生命周期里最先完成的事。我做得最多的三件事给请求注入一个唯一ID放在请求上下文里。后续所有日志只要带上这个ID排错的时候就能把一次请求的完整链路串起来。做一些不依赖业务数据的统计比如记下请求进来的时刻为最终的耗时计算做铺垫。处理跨域相关的预检请求。因为浏览器在正式请求之前经常发一个OPTIONS预检这个预检不该进入业务代码配合短路直接返回即可。这个钩子也有一个很容易被忽略的禁忌不要在onEnter里做重量级操作尤其是阻塞式的数据库查询或者远程调用。原因很简单——此时请求连路径参数都可能还没完整准备好你在这里慢一毫秒整个请求就慢一毫秒而且如果后面校验不通过那这次查询就白做了。保持轻量是onEnter的生存法则。2.3 第二个钩子 beforeHandler业务代码开始前beforeHandler是我个人使用频率最高的钩子。它处于一个很微妙的位置请求已经经过基本解析该有的参数大概率已经有了但业务代码还没执行。这意味着你既可以做“判断”也可以做“拦劫”。登录鉴权就挂在这里。我在这个钩子里拿请求头里的凭证验证身份校验通过就把用户信息塞到请求上下文里然后放行校验失败就返回401后续业务代码根本不会执行。限流也挂在这里从计数存储里读取当前时间窗内的访问量超过阈值就返回429。这里需要梳理一个很重要的设计习惯能通过短路解决的拦截逻辑优先放beforeHandler。不要放在onEnter里也不要放在afterHandler里。onEnter里做拦截你还没解析参数很多判断做不精确afterHandler里做拦截业务代码已经执行了资源白白消耗。2.4 第三个钩子 afterHandler业务代码完成后afterHandler在业务代码正常返回之后触发此时响应还没有真正写给客户端这给了你“最后修改”的机会。我常用它来做三件事第一统一包装响应格式。假设你的接口返回值是业务数据但前端期望的是一套固定结构比如包含code、message、data。如果在每个业务方法里写这套包装写吐了也容易漏。在afterHandler里统一包装代码量减少不说格式保证一致。第二记录审计日志。业务执行完之后记录操作结果、操作人、操作对象信息更完整。在beforeHandler里记审计日志你只能记录“打算做什么”结果还没出来在afterHandler里记能把成功或失败都带上。第三计算业务耗时。配合onEnter里记录的开始时间这里一减得到了不包含网络传输的纯服务端处理时间这个指标对性能分析很有用。要特别警惕的是afterHandler里不是你为所欲为的地方。响应对象可能已经被写入了部分内容如果你在这里强行修改状态码或响应体很容易触发“响应头已发送”的经典报错。正确做法是在业务方法里返回一个统一的结构afterHandler只做包装和补充不做破坏性修改。2.5 第四个钩子 onExit响应发出去之后onExit是四个钩子里最容易被忽略的它触发时响应已经发给客户端了。很多开发者的第一反应是“响应都发出去了还能干什么”其实这个阶段的意义很大。我做过的比较典型的onExit场景有两个。一是资源清理如果你在请求某个阶段打开了数据库连接、占用了文件句柄、创建了临时目录onExit是统一的回收点即使前面某些中间件短路跳过了业务逻辑onExit作为最外层兜底仍然会被响应结束触发可以用来释放资源。二是指标上报请求已经完成这时把耗时、状态码、是否命中缓存这些数据异步发给监控系统不会影响到客户端体验。这个钩子很容易踩的坑是把“发送响应”和“请求结束”看成同一件事。有些框架的onExit并非百分之百能执行比如服务器进程崩溃、连接被客户端断开。因此onExit适合处理“最好能做”的收尾不要把它当作绝对可靠的事务保证。3. 一次短路中间件如何直接终结请求3.1 短路不是bug是特性中间件链路是一节接一节往下传的业务代码位于整条链的最末端。如果你在某一个中间件里选择不调用“放行方法”也就是不执行next后面的中间件和业务代码就统统不会执行这个请求就被你直接摁住了。这在框架术语里有很多叫法短路、中断、提前返回。本质上都一样请你停下不必再往前走。短路是中间件体系里最核心的控制能力。你可以把中间件链想象成一条流水线业务代码是流水线末端的装配工。短路就是流水线上的质检员发现这个产品不合格直接按下停止键后续工位全都不用干活了。没有短路机制中间件就只能“看看”什么都做不了。3.2 三个常见场景鉴权失败、限流命中、缓存命中场景一鉴权失败。用户没有携带有效凭证访问受保护接口在beforeHandler里判断凭证无效直接返回401响应。代码逻辑大概是beforeHandler(ctx) { const token ctx.request.headers[authorization]; if (!token || !isValidToken(token)) { ctx.response.status 401; ctx.response.body { code: 401, message: 未登录或登录已过期 }; return; // 不调用后续放行方法实现短路 } // 校验通过把用户信息放入上下文 ctx.user parseToken(token); }场景二限流命中。某个IP在一分钟内超过100次请求直接在中间件里返回429。这里的关键是判断逻辑要快不能在身陷拥堵时还去做慢速统计查询否则限流本身就是拖垮系统的原因。场景三缓存命中。这是经常被忽略的一个场景。你给接口做了缓存请求进来之后在beforeHandler里查缓存如果命中直接把缓存里的响应体返回没命中才放行到业务代码业务代码计算完结果之后写入缓存。这一做法的价值在于下次同样参数的请求在中间件层面就直接被“截胡”了业务代码完全不用跑性能和压力完全是两个量级。3.3 短路和钩子怎么配合才不会踩坑短路的强大之处也埋了它最深的坑它会导致某些钩子不执行。比如你在beforeHandler里短路返回了401那afterHandler就不会执行因为它要等业务代码返回之后才触发。如果你的所有逻辑都堆在afterHandler里做一旦短路日志丢了、指标丢了、资源回收也没了。所以我的习惯是把逻辑按风险等级拆开拦截判断放beforeHandler业务后处理放afterHandler资源回收和指标上报放onExit。onExit这个钩子一般与短路关系不大它是响应结束后的统一收尾点最好能覆盖到短路场景。如果框架不提供onExit这样的最终钩子那就要把全链路最外层包一个try/finally确保短路时关键清理逻辑不会被整体跳过。另外短路不一定非得放在中间件链的某个特定位置。CORS的预检OPTIONS请求在onEnter阶段就该短路根本不用等业务逻辑。而鉴权类拦截放beforeHandler更合适因为你有机会读参数了。缓存命中短路放beforeHandler也比放onEnter更合适因为缓存key可能依赖参数解析结果。理解每个场景适合在哪个钩子短路效果会好很多。4. 自带的工具别重复造轮子4.1 自带工具都有哪些如果不看框架文档很多人不知道自己每天都在用的框架里其实内置了一批开箱即用的中间件/工具组件。它们跟框架的生命周期挂钩你不用从零手写配置一下就能接入。自带工具解决的核心问题常见配置项跨域CORS组件浏览器跨域访问限制允许的域名、请求方法、是否携带凭证请求体解析组件将POST/PUT请求体解析为结构体大小上限、支持的编码格式静态文件托管组件直接服务前端静态资源目录路径、缓存策略、目录列表是否可见全局异常处理组件捕获业务代码抛出的异常统一返回错误结构是否记录堆栈、自定义错误映射请求日志组件记录请求路径、状态码、耗时是否记录请求体、是否记录响应体会话管理组件维护用户登录状态会话存储介质、过期时间、Cookie密钥文件上传组件处理multipart格式数据上传大小限制、临时目录、文件类型白名单压缩组件对响应体做gzip压缩压缩级别、最小压缩体积阈值4.2 为什么优先用框架自带的工具刚接触服务端开发的时候我特别喜欢自己写工具。自己写CORS、自己写请求体解析、自己写日志记录写出来的东西能跑但总是有奇怪的边界问题CORS漏了预检请求body解析没考虑到超大数据流日志记录在实际高并发下居然成了性能瓶颈。后来我意识到一个道理自带工具的可靠性是经过大规模请求验证的而且它们已经跟框架的生命周期对接好了能正确感知钩子触发、短路返回、异常事件。自己造轮子很多时候是你以为省事实际是在给自己挖坑。举一个最典型的CORS例子。手写一个允许跨域的中间件你可能只加了一个响应头Access-Control-Allow-Origin。但浏览器复杂请求会先发OPTIONS预检预检通过才会发正式请求。如果你没有在预检里正确返回允许的方法和头部前端控制台会报错而你的业务代码压根没有机会执行。框架自带的CORS组件早就把预检、允许头、凭证这些细节处理好了何必自己写。4.3 几个自带工具的实战配置参考以一套常见配置为例说明这些工具怎么接入最稳。跨域组件我一般只做最小开放开发环境允许本机调试地址生产环境指定真实域名列表同时开启允许携带凭证。不建议直接设置成允许所有来源这会让浏览器把携带凭证的请求直接拦掉因为Access-Control-Allow-Origin为*和凭证不能共存。请求体解析组件的重点是大小的限制。你总不希望用户传一个2GB的请求体把你的服务内存打满。我给JSON请求体限制在1MB以内文件上传单独走上传组件设置100MB上限并同时约束文件白名单比如只允许图片或特定文档类型。静态文件托管看似简单其实有一个容易忽略的点缓存。不要把静态资源缓存策略设成永不缓存。要按扩展名区分比如图片和压缩包可以设置较长的缓存时间HTML文件设置较短缓存避免用户更新后拿到旧版本。全局异常处理是压轴配置。我会注册一个兜底异常中间件放在中间件链最外层的位置。这样无论内部哪个中间件或业务代码抛出异常这个最外层的异常处理都能接管把堆栈信息写到日志同时给客户端返回一个结构统一的错误响应。这是保证线上稳定性的重要一环。业务代码里尽量不要写一堆try/catch吞掉异常把异常统一抛到这一层处理更干净。5. 中间件实操中最容易踩的五个坑5.1 中间件顺序错了CORS直接失效中间件的执行顺序不是小事我见过不少服务端新人把CORS中间件放在鉴权中间件之后注册结果一跨域就报错。为什么因为浏览器先发预检请求如果跨域相关的响应头还没设置这个预检本身就失败了正式请求根本不会发出去后面鉴权代码就算写对了也轮不到执行。正确做法是把CORS放在链表最靠外的位置先让它处理跨域然后才是全局异常处理然后是请求日志再往后是请求体解析和鉴权最后才进入业务逻辑。顺序即架构在中间件里体现得格外淋漓尽致。每新增一个中间件先问自己它应该在哪些中间件之前或之后执行5.2 异步中间件忘记await钩子位置全乱中间件里经常要调用异步方法比如查询数据库、调用远程接口。如果你在中间件里写了一个异步方法但没有等待它完成后面代码会继续往下走执行顺序就完全乱套了。最简单的例子你在中间件里想“先读取用户信息再判断是否有权限”结果忘记等待查询完成代码跑到了判断那里用户信息还是空的。排查起来非常隐蔽看起来逻辑没错但行为完全是另外一套。为了避免这种问题中间件的异步处理函数务必用await串联辅助的异步操作也尽量用显式的await或Promise语义不要“隐式并行”去依赖最终等到的结果。5.3 响应头已发送还在改响应体在afterHandler里最经典的一个报错是修改状态码或者设置响应头时提示响应头已发送。原因是前面某个环节已经把响应部分写出去了你再想修改已经来不及。这个坑在两种场景里特别容易踩一是你试图在afterHandler里做“统一异常响应”但根本没走到异常处理那层二是业务代码里已经直接调用了响应对象的写入方法afterHandler再包装时就会冲突。解决方案是统一约束业务代码不要直接操作响应对象而是返回一个结果对象。中间件统一在afterHandler里拿这个结果对象设置响应头和响应体避免提前写入导致的多次修改冲突。5.4 短路之后没有任何兜底资源泄漏每短路一次就有一段收尾逻辑被跳过。我早期遇到过一个线上故障请求在beforeHandler里被拦截返回但那个拦截过程里已经打开了一个数据库连接后面没有释放一段时间后连接池被占满服务直接不可用。这给我的教训是短路逻辑越早越好同时资源获取的时间尽量延后。如果必须在某些阶段获取资源一定要确保短路路径上也能释放。最稳妥的做法是依赖onExit或者全链路的finally来统一清理而不是赌每个中间件都记得释放。不要把资源管理逻辑散落在各钩子里。5.5 中间件太厚性能下降中间件数量不是越多越好。每个中间件都是一层函数调用哪怕每个只消耗0.1毫秒几十个中间件叠加起来也是一笔不小的开销。更糟的是有些中间件会重复解析数据、重复遍历头信息、重复记录日志大量无效操作拉伸了请求链路。我的自查办法是定期看一下生产环境的链路耗时分布。如果发现请求都在中间件层面耗时远高于业务方法那就用“二分排查法”逐个关闭中间件看耗时变化定位是哪一层在拖速度。另外日志中间件可以考虑按需采样千万不要每个请求都完整记录请求体和响应体生产环境流量一大很可能会拖垮性能。下面用一张表把这五个坑汇总起来问题典型现象排查思路解决方案中间件顺序错误跨域报错、鉴权不生效检查注册顺序是否符合链路预期把CORS和异常处理放最外一层业务相关放内层异步未等待中间件逻辑执行顺序与代码书写顺序不一致检查是否有未await的异步调用所有异步操作显式await响应头已发送修改状态码/响应体时报错确认response是否已被提前写入业务代码返回结果对象由中间件统一写响应短路导致资源泄漏连接数涨、资源被占满检查短路路径是否执行了release收尾逻辑放onExit或外层finally中间件过于臃肿请求耗时高、吞吐下降对比各钩子耗时分布精简中间件、日志采样、避免重复解析6. 从打日志到控制流我的实操体会我之前维护过一个老项目中间件只用来记日志鉴权写在每个接口里限流全靠不断的重构修修补补异常处理更是一团乱麻。后来我花了两天时间把鉴权、限流、响应包装、全局异常处理和请求日志全部抽到中间件里业务代码一下子少了将近一半。那一刻我才彻底明白中间件不是“框架的边角料”它是整套服务端控制流的指挥中枢。如果你也想在自己的项目里做类似的整理我的个人建议是先不要一刀切重写。先画一版当前请求的完整链路图标出所有和业务无关的逻辑逐项确认它们应该放在哪个钩子。然后一个一个往里加中间件每加一个都问自己三个问题它需要放在哪个钩子之前还是之后它可能会在什么条件下短路如果它短路了后面的逻辑会丢失什么把这三个问题想明白你的中间件体系基本上就不会出现大乱子。中间件不是越多越好而是每个都有清晰的职责能在正确的时机精准出手。这才是一个有经验的开发者请求中控制的真正体感。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →