- 后端
- 微服务
- 云原生
【免费下载链接】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. 🌈
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-L330):
export 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; }各方法的作用如下表:
| Methods | Description |
|---|---|
| before | Execute before method call |
| around | Before and after the execution of the package method |
| afterReturn | Execute when content is returned correctly |
| afterThrow | Execute when an exception is thrown |
| after | Final execution (whether correct or wrong) |
用伪代码简单理解整个执行流程:
try { // before // around or invokeMethod // afterReturn } catch(err) { // afterThrow } finally { // after }各切面的能力对比如下:
| Revised input parameters | Call the original method | Gets the return value | Modify return value | Get error | Intercept and throw an error | |
|---|---|---|---|---|---|---|
| before | √ | √ | ||||
| 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-L316):
export interface JoinPoint { methodName: string; target: any; args: any[]; proceed(...args: any[]): any; }| Parameters | Description |
|---|---|
| methodName | intercepted method name |
| target | The instance when the method is called. |
| args | The parameters of the original method call |
| proceed | The original method itself, only exists in before and around |
需要注意,JoinPoint中还有一项未在原文档列出但真实存在于源码的字段proceedIsAsyncFunction(packages/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 : resultTemp(packages/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.ts(
@Aspect的参数解析、单例注册与元数据挂载) - 核心切面服务:packages/core/src/service/aspectService.ts(
loadAspect、addAspect、interceptPrototypeMethod的同步/异步双分支包装) - 类型定义:packages/core/src/interface.ts#L310-L330(
JoinPoint、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. 🌈
相关推荐
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(调度器监
任务调度后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考