# 一、前言
在 Nest.js 中,有一个非常非常鲜明的特点,就是几乎全方面的采用了 装饰器 (Decorator) 作为接口的封装,以实现 AOP 面向切面编程的编程范式。再结合其用依赖注入的形式实现了控制反转,使得整个框架和 SpringBoot
在形式上越来越接近。
那么,既然提到了装饰器,就不得不先聊一聊 AOP 面向切面编程究竟是什么,再来探讨一下 Decorator 具体是怎么实现的 AOP。
# 二、AOP 面向切面编程
AOP(Aspect-Oriented Programming,面向切面编程) 是一种编程范式,旨在将 横切关注点(cross-cutting concerns) 与 主要业务逻辑 分离,以提高代码的 模块化 、 可维护性 和 可重用性 。横切关注点是那些不属于核心业务逻辑,但会影响多个模块或组件的功能,例如日志记录、性能监测、事务管理等。
在传统的面向对象编程中,业务逻辑常常分散在各个类中,横切关注点也会与业务逻辑混杂在一起,导致代码的复杂性和耦合度增加。AOP 将横切关注点单独提取出来,通过特定的技术和手段将其 织入(weave) 到主要业务逻辑中,而不是在业务逻辑中直接插入这些关注点,从而实现了关注点与业务逻辑的分离。
AOP 的核心概念是 切面(Aspect) ,切面是横切关注点的模块化表述,它包含了一系列与关注点相关的代码。AOP 使用一种称为 "织入" 的技术,将切面的代码插入到主要业务逻辑的特定点上,形成一个完整的运行时系统。织入可以在编译时、加载时或运行时进行,这取决于具体的实现方式。
常见的 AOP 实现方式包括:
- 静态代理:通过手动创建代理对象,在代理对象中插入横切关注点的代码,再调用原始对象的方法。
- 动态代理:利用反射或动态字节码生成技术,在运行时动态创建代理对象,实现横切关注点的织入。
- 字节码增强:使用字节码操作库,直接修改目标类的字节码,在其中插入横切关注点的代码。
- 注解:使用注解标记横切关注点,通过编译时或运行时的注解处理器,将切面代码织入到目标类中。
AOP 在现代软件开发中得到广泛应用,特别是在大型、复杂的应用程序中,它可以帮助开发人员更好地管理代码,提高系统的可维护性和可扩展性。
# 三、在 TS 中的具体体现:Decorator 装饰器
如果要在 TypeScript 中体现 AOP,可以通过装饰器(decorators)和 Aspect.js 等库来实现。装饰器是 TypeScript 的特性,可以用于在类、方法、属性等上添加元数据和功能,而 Aspect.js 是一个专门用于 AOP 的库,可以在 TypeScript 项目中实现 AOP。在这里面我们暂时不讨论 Aspect.js ,只讨论 Decorator。
当然,想要在 TypeScript 项目中使用 Decorator,前提还需要在 tsconfig.json
中开启对应的配置,也就是实验性功能:
开启好了之后,下面是一个简单的示例,添加在某个类的方法上面,演示如何在 TypeScript 中使用装饰器实现 AOP:
// 定义一个装饰器,用于记录方法的执行时间 | |
function logExecutionTime( | |
target: any, | |
propertyKey: string, | |
descriptor: PropertyDescriptor | |
) { | |
// 修饰的方法的原本函数体内容 | |
const originalMethod = descriptor.value; | |
descriptor.value = function (...args: any[]) { | |
const start = Date.now(); | |
const result = originalMethod.apply(this, args); | |
const end = Date.now(); | |
console.log(`Method ${propertyKey} execution time: ${end - start} ms`); | |
return result; | |
}; | |
return descriptor; | |
} | |
class Example { | |
@logExecutionTime // 在类的方法上添加装饰器以实现特定功能 | |
expensiveOperation() { | |
// 模拟一个耗时的操作 | |
for (let i = 0; i < 1000000000; i++) { | |
// 你的代码 | |
} | |
} | |
} | |
const example = new Example(); | |
example.expensiveOperation(); |
在上面的示例中,定义了一个装饰器 logExecutionTime
,它可以用于记录方法的执行时间。将这个装饰器应用在 expensiveOperation
方法上,即可在方法执行前后输出执行时间。
这样,就实现了一个简单的 AOP,将横切关注点(记录执行时间)与核心业务逻辑( expensiveOperation
方法)分离开来,使得代码更加模块化和可维护。
实际中 AOP 可以应用于更复杂的场景,例如日志记录、权限控制、事务管理等。如果需要更高级的 AOP 功能,可以考虑使用 Aspect.js 等专门的 AOP 库。
# 四、Decorator 中的参数说明
# 修饰某个类的方法
当使用装饰器装饰类的方法时,装饰器函数将会接收三个参数: target
、 propertyKey
和 descriptor
。它们分别代表着:
target
:装饰器的目标对象。在装饰器装饰类的方法时,target
就是该类的原型对象。如果装饰器装饰的是静态方法,则target
是该类的构造函数。propertyKey
:装饰器的目标属性名。对于装饰类的方法,propertyKey
是方法的名称。对于装饰类的属性,propertyKey
是属性的名称。对于装饰类的访问器(getter 和 setter),propertyKey
是访问器的名称。descriptor
:属性描述符。descriptor
是一个对象,包含了目标方法或属性的各种属性和配置。它是 TypeScript 内置的PropertyDescriptor
类型。
在装饰器函数中,你可以使用这些参数来获取和修改目标方法或属性的元数据和行为。通过修改 descriptor.value
可以实现对目标方法的拦截和修改。通过修改 descriptor.get
和 descriptor.set
可以实现对访问器的拦截和修改。
具体到上述的例子当中,定义了一个装饰器 logExecutionTime
,用于记录方法的执行时间。然后将这个装饰器应用在 Example
类的 expensiveOperation
方法上。
target
:在这个例子中,target
是Example
类的原型对象,也就是Example.prototype
。装饰器被应用在实例方法上,所以target
是类的原型对象。propertyKey
:propertyKey
是被装饰的方法的名称,即"expensiveOperation"
。在这里,propertyKey
就是要装饰的方法的名字。descriptor
:descriptor
是属性描述符对象,它包含了目标方法的一些属性和配置。在这里,descriptor.value
就是expensiveOperation
方法的实际函数体。
装饰器函数 logExecutionTime
中,通过修改 descriptor.value
来拦截 expensiveOperation
方法的执行,添加了计时功能。原始的 expensiveOperation
方法会在执行前后分别记录开始时间和结束时间,并输出执行时间。
当实例化 Example
类并调用 example.expensiveOperation()
时,装饰器会在方法执行前后输出执行时间,从而实现了 AOP,将记录执行时间的横切关注点与 expensiveOperation
方法的业务逻辑分离开来。
# 修饰某个类
当装饰器用于修饰某个类时,装饰器函数会接收一个参数,即目标类的构造函数。
假设有以下装饰器函数:
function FunctionName(target: Function) { | |
// 在这里可以修改目标类的行为 | |
console.log("Class decorated:", target); | |
} |
当将装饰器应用于类时,例如:
@FunctionName | |
class People { | |
// ... | |
} |
装饰器函数 FunctionName
将被调用,传入的参数 target
就是 People
类的构造函数。这允许在装饰器函数中对类的构造函数进行修改或添加额外的逻辑。
在类装饰器中,通常会修改类的构造函数或原型,实现一些与类本身相关的操作,例如添加静态属性、添加实例方法、修改原型链等。装饰器在这里可以起到一种元编程的作用,让能够在编译阶段对类进行动态修改,提供更灵活的功能拓展。
需要注意的是,类装饰器的执行顺序是从上到下的,也就是说如果一个类上有多个装饰器,它们会按照从上到下的顺序依次执行。
# 修饰某个类的方法的参数
如果装饰器是修饰某个类的方法的参数,那么装饰器函数的参数将会有所不同。在这种情况下,装饰器函数接收三个参数:
target: Object
:表示被装饰的类的原型对象。在这个例子中,target
就是People
类的原型对象。propertyKey: string | symbol
:表示被装饰的方法的名称。在这个例子中,propertyKey
就是被装饰的方法get
的名称。parameterIndex: number
:表示被装饰的参数在方法参数列表中的索引。在这个例子中,如果@name()
装饰器修饰的是get
方法的第一个参数,那么parameterIndex
就是 0。
因此,当在 @name()
装饰器内部访问这三个参数时,它们分别代表了被装饰的类的原型对象、被装饰的方法名称以及被装饰的参数在方法参数列表中的索引。
例如,可以定义一个装饰器函数 name
来演示:
function name( | |
target: Object, | |
propertyKey: string | symbol, | |
parameterIndex: number | |
) { | |
console.log("target:", target); | |
console.log("propertyKey:", propertyKey); | |
console.log("parameterIndex:", parameterIndex); | |
} |
然后在 People
类的 get
方法上使用 @name()
装饰器:
class People { | |
get(@name() name: string) {} | |
} |
当创建一个 People
类的实例并调用 get
方法时,装饰器函数 name
将会被执行,并输出相应的参数信息。
# 修饰某个类的属性(成员变量)
如果装饰器是修饰某个类的属性(成员变量),那么装饰器函数的参数也会有所不同。在这种情况下,装饰器函数接收两个参数:
target: Object
:表示被装饰的类的原型对象。在这个例子中,target
就是Property
类的原型对象。propertyKey: string | symbol
:表示被装饰的属性的名称。在这个例子中,propertyKey
就是被装饰的属性name
的名称。
因此,当在 @Name
装饰器内部访问这两个参数时,它们分别代表了被装饰的类的原型对象和被装饰的属性的名称。
例如,可以定义一个装饰器函数 Name
来演示:
function Name(target: Object, propertyKey: string | symbol) { | |
console.log("target:", target); | |
console.log("propertyKey:", propertyKey); | |
} |
然后在 Property
类的 name
属性上使用 @Name
装饰器:
class Property { | |
@Name | |
name: string; | |
constructor() { | |
this.name = "non_hana"; | |
} | |
} |
当创建一个 Property
类的实例时,装饰器函数 Name
将会被执行,并输出相应的参数信息。
# 总结一下
-
当装饰器用于修饰某个类时,装饰器函数接收的参数是目标类的构造函数。
-
在类装饰器内部,可以修改类的构造函数,添加额外的属性或方法,或者修改类的原型链。
-
类装饰器的执行顺序是从上到下的,如果有多个装饰器应用于同一个类,则它们按顺序依次执行。
# 五、实际应用:Decorator+Axios 实现请求发送
根据上面对 Decorator 参数的解析,我们不难看出装饰器的一些特点:
- 装饰器是用 注解 的形式,装饰在你想作用的方法、类、参数上面的,并且根据你装饰的对象的类型不同,会自动传入你所修饰对象的相关信息,供你在装饰器内部实现你想对所装饰的对象的一些操作。简单来说,就是 把对类里面的一些操作给拉到外面去进行 。
- 装饰器本身是一个 函数 ,你如果想使用它就要自己去定义它。它的类型是 依据其所修饰的对象的类型 而固定的,传入的参数的类型也是固定的。
可以看出,装饰器 本身的参数是被修饰的对象自动传进去给他的 ;那么如果我们想要实现能够给我们自定义的装饰器传参,然后不仅拿到我们所修饰的对象的信息,而且拿到我们自定义传进去的参数,从而进行一些自定义的操作,也就是所谓 装饰器的增强 ,要怎么办呢?就像下面的例子:
class Demo { | |
constructor() {} | |
@Decorator("action1") | |
getData(msg: string) { | |
console.log(msg); | |
} | |
} |
涉及到这样 传参层次不同的函数调用 ,那自然第一想法便是利用 闭包 ,也就是 函数柯里化 。简单来说,就是把函数往外面包一层,最外层是用来接收自定义参数的;里面再返回一个新的函数,这个新的函数用来接收自动传入的参数。
接下来实现一个 Decorator+Axios 实现请求发送的例子。我们定义一个方法装饰器 Get,里面可以自定义传入我们需要请求的 get 方法的 Url,然后通过 axios 进行请求发送,拿到结果后调用被修饰的方法,把结果的参数传进去,最终打印出来请求的结果。
import axios from "axios"; | |
// 方法装饰器 | |
const Get = (url: string) => { | |
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { | |
// 这里的 descriptor.value 就是被装饰的方法 | |
const fnc = descriptor.value; | |
axios | |
.get(url) | |
.then((res) => { | |
fnc(res, { | |
status: 200, | |
success: true, | |
}); | |
}) | |
.catch((err) => { | |
fnc(err, { | |
status: 500, | |
success: false, | |
}); | |
}); | |
}; | |
}; | |
class Controller { | |
constructor() {} | |
@Get("XXXXXX") | |
getList(res: any, params: any) { | |
console.log(res.data); | |
console.log(params); | |
} | |
} |
在上面的这个例子当中, Get
是一个 工厂函数 ,它接收一个 URL 作为参数,并返回一个装饰器函数。这个装饰器函数接收三个参数: target
(被装饰的类的原型), propertyKey
(被装饰的方法的名称),和 descriptor
(被装饰的方法的属性描述符)。
在装饰器函数(被返回的这个函数)内部,原始的方法(通过 descriptor.value
获取)被保存在 fnc
中。然后使用 axios 发起一个 GET 请求到提供的 URL。请求成功时,调用原始方法 fnc
,传入响应对象和一个包含状态码 200 和成功标志的对象。如果请求失败,调用 fnc
时传入错误对象和一个包含状态码 500 和失败标志的对象。
Controller
类中定义了一个方法 getList
,它被 @Get("XXXXXX")
装饰器装饰。也就是说在调用 getList
方法时,它实际上会发送一个 GET 请求到你定义的具体 URL,然后根据请求的成功或失败来处理响应或错误。 getList
方法接收两个参数: res
(响应或错误对象)和 params
(包含状态码和成功 / 失败标志的对象)。在方法内部,它简单地将响应数据和 params
对象打印到控制台,当然可以拿到这两个参数之后进行一些更加复杂和符合业务的操作。