# 一、前言

在 Nest.js 中,有一个非常非常鲜明的特点,就是几乎全方面的采用了 装饰器 (Decorator) 作为接口的封装,以实现 AOP 面向切面编程的编程范式。再结合其用依赖注入的形式实现了控制反转,使得整个框架和 SpringBoot 在形式上越来越接近。

那么,既然提到了装饰器,就不得不先聊一聊 AOP 面向切面编程究竟是什么,再来探讨一下 Decorator 具体是怎么实现的 AOP。

# 二、AOP 面向切面编程

AOP(Aspect-Oriented Programming,面向切面编程) 是一种编程范式,旨在将 横切关注点(cross-cutting concerns)主要业务逻辑 分离,以提高代码的 模块化可维护性可重用性 。横切关注点是那些不属于核心业务逻辑,但会影响多个模块或组件的功能,例如日志记录、性能监测、事务管理等。

在传统的面向对象编程中,业务逻辑常常分散在各个类中,横切关注点也会与业务逻辑混杂在一起,导致代码的复杂性和耦合度增加。AOP 将横切关注点单独提取出来,通过特定的技术和手段将其 织入(weave) 到主要业务逻辑中,而不是在业务逻辑中直接插入这些关注点,从而实现了关注点与业务逻辑的分离。

AOP 的核心概念是 切面(Aspect) ,切面是横切关注点的模块化表述,它包含了一系列与关注点相关的代码。AOP 使用一种称为 "织入" 的技术,将切面的代码插入到主要业务逻辑的特定点上,形成一个完整的运行时系统。织入可以在编译时、加载时或运行时进行,这取决于具体的实现方式。

常见的 AOP 实现方式包括:

  1. 静态代理:通过手动创建代理对象,在代理对象中插入横切关注点的代码,再调用原始对象的方法。
  2. 动态代理:利用反射或动态字节码生成技术,在运行时动态创建代理对象,实现横切关注点的织入。
  3. 字节码增强:使用字节码操作库,直接修改目标类的字节码,在其中插入横切关注点的代码。
  4. 注解:使用注解标记横切关注点,通过编译时或运行时的注解处理器,将切面代码织入到目标类中。

AOP 在现代软件开发中得到广泛应用,特别是在大型、复杂的应用程序中,它可以帮助开发人员更好地管理代码,提高系统的可维护性和可扩展性。

# 三、在 TS 中的具体体现:Decorator 装饰器

如果要在 TypeScript 中体现 AOP,可以通过装饰器(decorators)和 Aspect.js 等库来实现。装饰器是 TypeScript 的特性,可以用于在类、方法、属性等上添加元数据和功能,而 Aspect.js 是一个专门用于 AOP 的库,可以在 TypeScript 项目中实现 AOP。在这里面我们暂时不讨论 Aspect.js ,只讨论 Decorator。

当然,想要在 TypeScript 项目中使用 Decorator,前提还需要在 tsconfig.json 中开启对应的配置,也就是实验性功能:

image-20231202160927090

开启好了之后,下面是一个简单的示例,添加在某个类的方法上面,演示如何在 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 中的参数说明

# 修饰某个类的方法

当使用装饰器装饰类的方法时,装饰器函数将会接收三个参数: targetpropertyKeydescriptor 。它们分别代表着:

  1. target :装饰器的目标对象。在装饰器装饰类的方法时, target 就是该类的原型对象。如果装饰器装饰的是静态方法,则 target 是该类的构造函数。
  2. propertyKey :装饰器的目标属性名。对于装饰类的方法, propertyKey 是方法的名称。对于装饰类的属性, propertyKey 是属性的名称。对于装饰类的访问器(getter 和 setter), propertyKey 是访问器的名称。
  3. descriptor :属性描述符。 descriptor 是一个对象,包含了目标方法或属性的各种属性和配置。它是 TypeScript 内置的 PropertyDescriptor 类型。

在装饰器函数中,你可以使用这些参数来获取和修改目标方法或属性的元数据和行为。通过修改 descriptor.value 可以实现对目标方法的拦截和修改。通过修改 descriptor.getdescriptor.set 可以实现对访问器的拦截和修改。

具体到上述的例子当中,定义了一个装饰器 logExecutionTime ,用于记录方法的执行时间。然后将这个装饰器应用在 Example 类的 expensiveOperation 方法上。

  1. target :在这个例子中, targetExample 类的原型对象,也就是 Example.prototype 。装饰器被应用在实例方法上,所以 target 是类的原型对象。
  2. propertyKeypropertyKey 是被装饰的方法的名称,即 "expensiveOperation" 。在这里, propertyKey 就是要装饰的方法的名字。
  3. descriptordescriptor 是属性描述符对象,它包含了目标方法的一些属性和配置。在这里, 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 类的构造函数。这允许在装饰器函数中对类的构造函数进行修改或添加额外的逻辑。

在类装饰器中,通常会修改类的构造函数或原型,实现一些与类本身相关的操作,例如添加静态属性、添加实例方法、修改原型链等。装饰器在这里可以起到一种元编程的作用,让能够在编译阶段对类进行动态修改,提供更灵活的功能拓展。

需要注意的是,类装饰器的执行顺序是从上到下的,也就是说如果一个类上有多个装饰器,它们会按照从上到下的顺序依次执行。

# 修饰某个类的方法的参数

如果装饰器是修饰某个类的方法的参数,那么装饰器函数的参数将会有所不同。在这种情况下,装饰器函数接收三个参数:

  1. target: Object :表示被装饰的类的原型对象。在这个例子中, target 就是 People 类的原型对象。
  2. propertyKey: string | symbol :表示被装饰的方法的名称。在这个例子中, propertyKey 就是被装饰的方法 get 的名称。
  3. 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 将会被执行,并输出相应的参数信息。

# 修饰某个类的属性(成员变量)

如果装饰器是修饰某个类的属性(成员变量),那么装饰器函数的参数也会有所不同。在这种情况下,装饰器函数接收两个参数:

  1. target: Object :表示被装饰的类的原型对象。在这个例子中, target 就是 Property 类的原型对象。
  2. 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 参数的解析,我们不难看出装饰器的一些特点:

  1. 装饰器是用 注解 的形式,装饰在你想作用的方法、类、参数上面的,并且根据你装饰的对象的类型不同,会自动传入你所修饰对象的相关信息,供你在装饰器内部实现你想对所装饰的对象的一些操作。简单来说,就是 把对类里面的一些操作给拉到外面去进行
  2. 装饰器本身是一个 函数 ,你如果想使用它就要自己去定义它。它的类型是 依据其所修饰的对象的类型 而固定的,传入的参数的类型也是固定的。

可以看出,装饰器 本身的参数是被修饰的对象自动传进去给他的 ;那么如果我们想要实现能够给我们自定义的装饰器传参,然后不仅拿到我们所修饰的对象的信息,而且拿到我们自定义传进去的参数,从而进行一些自定义的操作,也就是所谓 装饰器的增强 ,要怎么办呢?就像下面的例子:

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 对象打印到控制台,当然可以拿到这两个参数之后进行一些更加复杂和符合业务的操作。