ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Midway 拦截器(AOP)完全指南:从 @Aspect 装饰器到 JoinPoint 生命周期与优先级机制

Midway 拦截器(AOP)完全指南:从 @Aspect 装饰器到 JoinPoint 生命周期与优先级机制 后端微服务云原生【免费下载链接】midway A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 项目地址https://gitcode.com/gh_mirrors/mi/midway点击查看免费下载Midway 提供了一套通用的方法拦截器切面Aspect能力让开发者可以在 Web、定时任务、消息队列等任意场景中统一编写错误处理、参数校验、日志记录等横切逻辑而无需侵入业务代码。读完本文你将掌握Aspect装饰器的完整用法、IMethodAspect各生命周期方法的执行时机与能力边界、JoinPoint 参数修改技巧以及多切面共存时的优先级洋葱模型控制方式。为什么需要方法拦截器在业务开发中我们经常有全局统一处理逻辑的需求例如统一处理错误、转换返回格式、记录访问日志、统计成功与失败次数等。Web 场景下可以通过 Web 中间件Middleware实现但在定时任务、事件监听、消息队列等其他场景中中间件无法发挥作用。Midway 因此设计了一套通用的方法拦截器切面用于在不同场景中统一编写逻辑。拦截器和传统的 Web 中间件、装饰器都不同它是 Midway 框架自身提供的能力在执行顺序上拦截器处于中间的位置——它既不是请求进入前的中间件也不是标记元数据的装饰器而是真正包裹在目标 Class 方法执行过程周围的切面它能对任意 Class 的方法做拦截不限于 Controller你不需要改动被拦截类的任何源码不需要在业务文件里加装饰器也不需要在主流程前后插入可见的代码。底层实现上Aspect装饰器会把切面类注册到装饰器管理器中packages/core/src/decorator/common/aspect.ts容器启动时由MidwayAspectService统一加载并完成对目标方法原型链的包装packages/core/src/service/aspectService.ts框架初始化流程中会调用aspectService.loadAspect()packages/core/src/service/frameworkService.ts#L240-L241。使用拦截器切面拦截器一般放在src/aspect目录。下面我们写一个对控制器Controller方法拦截的示例创建一个src/aspect/report.ts文件。项目的目录结构如下➜ my_midway_app tree . ├── src │ │── aspect ## interceptor directory │ │ └── report.ts │ └── controller ## Web Controller Directory │ └── home.ts ├── test ├── package.json └── tsconfig.json先定义一个普通的控制器// src/controller/home.ts import { Controller, Get } from midwayjs/core; Controller(/) export class HomeController { Get(/) async home() { return Hello Midwayjs!; } }再编写拦截器// src/aspect/report.ts import { Aspect, IMethodAspect, JoinPoint } from midwayjs/core; import { HomeController } from ../controller/home; Aspect(HomeController) export class ReportInfo implements IMethodAspect { async before(point: JoinPoint) { console.log(before home router run); } }项目启动后访问/路由时控制台会输出before home router run的字样。可以看到整个过程我们既没有 hack 进控制器的代码也没有给业务文件添加装饰器更不需要在主流程前后编写任何可见代码——这就是切面解耦横切逻辑的价值。需要提醒的是拦截器的能力非常强大必须谨慎、正确地使用。拦截器的实例范围拦截器固定为单例Singleton。从源码可以看到Aspect装饰器内部会自动对目标类执行Scope(ScopeEnum.Singleton)和Provide()packages/core/src/decorator/common/aspect.ts#L25-L26因此切面实例在整个容器生命周期内只创建一次适合存放共享状态如计数器但要注意避免在多请求并发下对共享状态的无保护读写。:::caution 在继承的情况下拦截器不会对父类的方法生效。因为MidwayAspectService.addAspect只遍历Object.getOwnPropertyNames(module.prototype)packages/core/src/service/aspectService.ts#L52即目标类自身原型上声明的方法父类原型上的方法不会进入匹配范围。 :::可切面的生命周期Aspectable Lifecycle方法拦截器可以包裹整个方法的执行过程拦截方式分为以下几个切面对应接口定义见 packages/core/src/interface.ts#L324-L330export interface IMethodAspect { after?(joinPoint: JoinPoint, result: any, error: Error); afterReturn?(joinPoint: JoinPoint, result: any): any; afterThrow?(joinPoint: JoinPoint, error: Error): void; before?(joinPoint: JoinPoint): void; around?(joinPoint: JoinPoint): any; }各方法的作用如下表MethodsDescriptionbeforeExecute before method callaroundBefore and after the execution of the package methodafterReturnExecute when content is returned correctlyafterThrowExecute when an exception is thrownafterFinal execution (whether correct or wrong)用伪代码简单理解整个执行流程try { // before // around or invokeMethod // afterReturn } catch(err) { // afterThrow } finally { // after }各切面的能力对比如下Revised input parametersCall the original methodGets the return valueModify return valueGet errorIntercept and throw an errorbefore√√around√√√√√√afterReturn√√afterThrow√√after√√对照 packages/core/src/service/aspectService.ts 中interceptPrototypeMethod的实现可以确认这套流程先执行before若定义了around则由around接管方法调用否则直接调用原方法随后执行afterReturn并以其返回值覆盖结果一旦发生异常进入afterThrow未定义时直接抛出原始错误无论成功与否最终都会进入after。before修改入参我们经常在before阶段修改输入参数、校验参数使其符合程序执行的逻辑。例如// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home(data1, data2) { return data1 data2; // 因为方法被拦截这里的返回值是 3 } } // src/aspect/report.ts Aspect(HomeController, home) // 这里只拦截 home 方法 export class ReportInfo implements IMethodAspect { async before(point: JoinPoint) { console.log(point.args); // 因为切了 Controller 方法原始参数为 [ctx, next] point.args [1, 2]; // 修改参数 } }这里的JoinPoint就是可以被修改的方法参数对象定义如下packages/core/src/interface.ts#L310-L316export interface JoinPoint { methodName: string; target: any; args: any[]; proceed(...args: any[]): any; }ParametersDescriptionmethodNameintercepted method nametargetThe instance when the method is called.argsThe parameters of the original method callproceedThe original method itself, only exists in before and around需要注意JoinPoint中还有一项未在原文档列出但真实存在于源码的字段proceedIsAsyncFunctionpackages/core/src/interface.ts#L315它标记被拦截的原方法是否为异步函数测试用例中正是用它断言同步/异步包装分支的正确性packages/core/test/service/aspectService.test.ts#L36-L66。另外原方法仅在before和around中可通过proceed调用一旦流程离开这些阶段框架会把joinPoint.proceed置为undefined防止后续切面误用packages/core/src/service/aspectService.ts#L109。around完全包裹方法调用around是全能型方法可以包裹整个方法调用过程自由决定是否调用原方法、如何传入参数以及如何加工返回值。// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home() { return hello; } } // src/aspect/report.ts Aspect(HomeController, home) // 这里只拦截 home 方法 export class ReportInfo implements IMethodAspect { async around(point: JoinPoint) { const result await point.proceed(...point.args); // 执行原方法 return result world; } }最终 Controller 会返回hello world。从源码看proceed的本质是保留原方法引用并以当前this调用const newProceed (...args) originMethod.apply(this, args)packages/core/src/service/aspectService.ts#L87-L89因此即使被拦截方法经过包装this上下文依然指向原实例。afterReturn修改返回结果afterReturn方法会多一个返回结果参数。如果只需要修改返回结果可以直接使用它——上面的around示例用afterReturn改写会更简单// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home() { return hello; } } // src/aspect/report.ts Aspect(HomeController, home) // 这里只拦截 home 方法 export class ReportInfo implements IMethodAspect { async afterReturn(point: JoinPoint, result) { return result world; } }实现细节上只有当afterReturn返回了非undefined的值时才会覆盖原结果返回undefined则保持原值不变result typeof resultTemp undefined ? result : resultTemppackages/core/src/service/aspectService.ts#L114。afterThrow拦截异常afterThrow用于拦截错误// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home() { throw new Error(custom error); } } // src/aspect/report.ts Aspect(HomeController, home) export class ReportInfo implements IMethodAspect { async afterThrow(point: JoinPoint, error) { if(/not found/.test(error.message)) { throw new Error(another error); } else { console.error(got custom error); } } }afterThrow可以拦截错误但相应地它不能在过程中返回结果一般用于记录错误日志。需要留意afterThrow内部如果再次抛出异常该异常会取代原始错误向上传播如果afterThrow正常返回则异常被吞掉方法调用以正常流程结束不会再有返回值结果为undefined。after最终处理after用于执行最终处理无论方法成功还是抛错都会执行可以用它来完成一些收尾任务比如记录成功或失败次数// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home() { throw new Error(custom error); } } // src/aspect/report.ts Aspect(HomeController, home) export class ReportInfo implements IMethodAspect { async after(point: JoinPoint, result, error) { if(error) { console.error(error); } else { console.log(result); } } }对应源码中的finally分支await aspectObject.after?.(joinPoint, result, error)packages/core/src/service/aspectService.ts#L124-L126无论try/catch哪条路径结束都会执行。异步切面的注意事项如果被拦截的方法是异步的原则上所有before等切面方法都应该是异步的反之被拦截方法是同步的则切面方法也应该保持同步。这是因为框架会根据被拦截方法是否为 async 选择不同的包装实现Types.isAsyncFunction判断见 packages/core/src/service/aspectService.ts#L84。异步方法配异步切面// src/controller/home.ts Controller(/) export class HomeController { Get(/) async home() { // 这里是异步的则下面的 before 也应该是异步的 } } // src/aspect/report.ts Aspect(HomeController, home) export class ReportInfo implements IMethodAspect { async before(point: JoinPoint) { } }同步方法配同步切面// src/controller/home.ts Controller(/) export class HomeController { Get(/) home() { // 这里是同步的则下面的 before 也应该是同步的 } } // src/aspect/report.ts Aspect(HomeController, home) export class ReportInfo implements IMethodAspect { before(point: JoinPoint) { } }应用到多个类Aspect装饰器的第一个参数可以是一个数组。我们可以传入多个类这些类的所有方法都会被拦截。例如可以把上面的拦截器应用到多个 Controller让每个类的每个方法都被拦截Aspect([HomeController, APIController]) export class ReportInfo implements IMethodAspect { async before(point: JoinPoint) { } }从源码实现看Aspect内部会执行const aspectTargets [].concat(aspectTarget)对数组中的每个目标类分别挂载元数据packages/core/src/decorator/common/aspect.ts#L12-L23。特定方法匹配通常我们只需要拦截某个类的特定方法。Aspect的第二个参数是带通配符的方法名字符串使用的规则是 picomatch注意不要在本仓库之外搜索该链接这里仅说明其匹配语义。假设我们的方法如下// src/controller/home.ts import { Controller, Get } from midwayjs/core; Controller(/) export class HomeController { Get(/1) async hello1() { return Hello Midwayjs!; } Get(/2) async hello2() { return Hello Midwayjs, too!; } }那么配置下面的切面后只有hello2方法会被匹配到Aspect([HomeController], *2) export class ReportInfo implements IMethodAspect { async before(point: JoinPoint) { console.log(hello method with suffix 2); } }实现上匹配发生在MidwayAspectService.addAspect中const isMatch aspectData.match ? pm(aspectData.match) : () true;packages/core/src/service/aspectService.ts#L53即不传第二个参数时默认匹配所有方法传入时则对方法名做 glob 匹配。另外需要注意匹配遍历的是Object.getOwnPropertyNames(module.prototype)constructor会被跳过packages/core/src/service/aspectService.ts#L55-L57且不可写descriptor.writable false的方法不会被拦截。切面的执行顺序如果多个拦截器同时作用于一个方法可能会产生执行顺序混乱的问题。如果两个切面写在两个不同文件里这个顺序是随机的取决于模块加载顺序。Aspect的第三个参数用于指定拦截器的优先级默认值为 0数值越大优先级越高。这里的语义是优先级高的方法先注册而先注册的方法在调用时后执行——这就是经典的洋葱模型。下面的代码是一个示例。MyAspect2的优先级高于MyAspect1所以会优先注册。整个拦截过程分为注册和执行两个阶段注册过程MidwayAspectService.loadAspect会先取出所有切面模块然后按(next.priority || 0) - (pre.priority || 0)降序排序packages/core/src/service/aspectService.ts#L37-L39优先级高的切面先对目标方法完成原型包装因此它成为洋葱模型最外层。执行过程由于后注册的包装会先执行before、后执行after最终形成“先注册的外层切面后执行、后注册的内层切面先执行”的洋葱调用链。Aspect([HomeController]) export class MyAspect1 implements IMethodAspect { before(point: JoinPoint) { console.log(111); } } Aspect([HomeController], *, 1) // 优先级可以在这里设置 export class MyAspect2 implements IMethodAspect { before(point: JoinPoint) { console.log(222); } }执行输出为111 222即优先级更高的MyAspect2先注册但由于洋葱模型优先级更低的MyAspect1的before反而先执行、后进入内层。一些限制拦截器不会对父类生效如前文所述拦截器只作用于目标类自身原型上声明的方法继承自父类的方法不会被拦截。如有需要应对父类本身单独声明切面。不可写方法不会被拦截源码中会跳过descriptor.writable false的方法packages/core/src/service/aspectService.ts#L63-L65。拦截器为单例切面实例全局共享状态管理需谨慎。同步/异步须保持一致切面方法的同步异步形态应与被拦截方法匹配否则可能出现返回 Promise 但未被 await 的时序问题。源码验证与测试若想深入了解实现细节可阅读以下文件装饰器定义packages/core/src/decorator/common/aspect.tsAspect的参数解析、单例注册与元数据挂载核心切面服务packages/core/src/service/aspectService.tsloadAspect、addAspect、interceptPrototypeMethod的同步/异步双分支包装类型定义packages/core/src/interface.ts#L310-L330JoinPoint、AspectMetadata、IMethodAspect单元测试packages/core/test/service/aspectService.test.ts验证多轮around叠加、before改参、afterThrow捕获错误以及proceedIsAsyncFunction标记装饰器测试packages/core/test/decorator/common/aspect.test.ts验证Aspect注册到ASPECT_KEY模块列表通过Aspect拦截器你可以在完全不侵入业务代码的前提下把日志、鉴权、限流、错误兜底、参数修正等横切能力以统一的方式复用到任意 Class 方法上——这正是 Midway 面向不同场景提供一致 AOP 体验的核心机制。赞分享后端微服务云原生【免费下载链接】midway A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 项目地址https://gitcode.com/gh_mirrors/mi/midway点击查看免费下载相关推荐Midway 拦截器AOP 切面机制全解析Aspect 装饰器、切面生命周期与执行顺序Midway 拦截器AOP 切面机制全解析Aspect 装饰器、切面生命周期与执行顺序 Midway 框架内置了一套与场景无关的通用方法拦截器AOP后端微服务云原生Midway 拦截器AOP实战指南基于 Aspect 的方法级切面编程Midway 拦截器AOP实战指南基于 Aspect 的方法级切面编程 Midway 框架内置了一套通用方法拦截器AOP/切面能力用于在不同场景下后端微服务云原生Quartz.NET Scheduler Listener 完全指南拦截调度器生命周期与错误的完整回调机制Quartz.NET Scheduler Listener 完全指南拦截调度器生命周期与错误的完整回调机制 导读 SchedulerListener调度器监任务调度后端上一篇ncmdump 上手攻略网易云 NCM 转 MP3一次讲透下一篇PotPlayer 字幕翻译三阶段实战指南免费实时字幕翻译插件的安装配置与进阶玩法创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进