☰
Midway 拦截器(AOP)完全指南:从 @Aspect 装饰器到 JoinPoint 生命周期与优先级机制
2026/10/9 10:05:13 网站建设 项目流程
  • 后端
  • 微服务
  • 云原生

【免费下载链接】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-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; }

各方法的作用如下表:

MethodsDescription
beforeExecute before method call
aroundBefore and after the execution of the package method
afterReturnExecute when content is returned correctly
afterThrowExecute when an exception is thrown
afterFinal 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 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; }
ParametersDescription
methodNameintercepted method name
targetThe instance when the method is called.
argsThe parameters of the original method call
proceedThe 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. 🌈

项目地址:https://gitcode.com/gh_mirrors/mi/midway
点击查看免费下载

相关推荐

上一篇:ncmdump 上手攻略:网易云 NCM 转 MP3,一次讲透
下一篇:PotPlayer 字幕翻译三阶段实战指南:免费实时字幕翻译插件的安装配置与进阶玩法

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询