nestjs系列文章一:《终于搞懂了!没想到装饰器的作用那么大》

288 阅读9分钟

image.png

装饰器就是一个函数,通过装饰器能够扩展代码的功能但是不修改原代码。nestjs的很大一部分功能都是由装饰器实现的。

装饰器的缺点也很明显,它会让代码实现的细节被隐藏,让代码逻辑变的黑盒,会让结果变得难以理解。但是装饰器填补了javascript元编程能力的空白。虽然装饰器的提供的功能也可以通过辅助函数来实现,但是会让代码的设计和结构乱糟糟的,非常不合理。

看一下装饰器和辅助函数这两种方式实现添加日志记录功能的区别:

  • 使用装饰器给类成员函数添加日志记录的功能
function log(target:Object,propertyKey:string,descriptor:PropertyDescriptor){
  //获取老的函数
 const originalMethod = descriptor.value;
 //重定原型上的属性
  descriptor.value = function (...args: any[]) {
  console.log(`调用方法${propertyKey},记录下日志`, 'dosomething....');
  const result = originalMethod.apply(this,args);
  return result;
 }
}
​
class User{
  @log
  changeUserPwd() {
    // 修改密码
    return true
  }
  @log
  changeUserName() {
    // 修改姓名
    return true
  }
}
const user = new User();
user.changeUserPwd();
user.changeUserName();
  • 辅助函数的方式:
function log(fn: Function){
  //获取老的函数
  console.log(`调用方法${fn.name},记录下日志`, 'dosomething....');
  const result = fn();
  return result;
}
class User{
  changeUserPwd() {
    // 修改密码
    return true
  }
  changeUserName() {
    // 修改姓名
    return true
  }
}
const user = new User();
log(user.changeUserPwd)
log(user.changeUserName);

辅助函数的方式让log函数与类方法进行分离。不是声明式的。想要给记录日志,还要调用下log方法。

如果是装饰器的方式就可以直接调用user的changeUserPwd方法,方法内部会自动调用装饰器,这样就一步到位了,在定义的时候就决定了这个方法被调用时就要记录下日志。

装饰器最大作用就是可用于元编程,并为装饰的类型添加或改变功能,而不用通过外部行为来添加/修改功能。

装饰器是js的还是ts的,傻傻分不清

装饰器是js语法,但是目前还是提案阶段,装饰器提案已经五年了, 目前已经到了第三阶段, 因为是提案阶段,所以在浏览器环境或者node环境下是不识别装饰器语法的。

image-20240707125505731

所以如果我们想要使用装饰器则可以用babel或者TypeScript 的编译器tsc来编译装饰器语法,这样就可以在浏览器或者node环境中使用了。

装饰器的类型

我们使用typescript编译器来编译装饰器语法(在tsconfig文件中启用experimentalDecorators)。

  1. 类装饰器(Class Decorators) :应用于类构造函数,可以用于修改类的定义。
  2. 方法装饰器(Method Decorators) :应用于方法,可以用于修改方法的行为。
  3. 访问器装饰器(Accessor Decorators) :应用于类的访问器属性(getter 或 setter)。
  4. 属性装饰器(Property Decorators) :应用于类的属性。
  5. 参数装饰器(Parameter Decorators) :应用于方法参数。

类装饰器

  • 简单类装饰器

    参数是类本身

function logClass(constructor: Function) {
    console.log("Class created:", constructor.name);
}
​
@logClass
class Person {
    constructor(public name: string) {}
}
​
// 输出: Class created: Person
  • 类装饰器工厂

    返回装饰器函数

function logClassWithParams(message: string) {
    return function (constructor: Function) {
        console.log(message, constructor.name);
    };
}
​
@logClassWithParams("Creating class:")
class Car {
    constructor(public model: string) {}
}
​
// 输出: Creating class: Car
  • 修改类的行为

    扩展/修改类的属性

function addTimestamp<T extends { new(...args: any[])>(constructor: T) {
    return class extends constructor {
        timestamp = new Date();
    };
}
​
// 合并声明
interface Document{
    timestamp: Date;
}
@addTimestamp
class Document {
    constructor(public title: string) {}
}
​
const doc = new Document("My Document");
//const doc = new Document("My Document") as Document & { timestamp: Date };
console.log(doc.title); // My Document
console.log(doc.timestamp); // 当前日期和时间
export {}

扩展/修改类的构造函数

function replaceConstructor<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        constructor(...args: any[]) {
            super(...args);
            console.log("Instance created");
        }
    };
}
​
@replaceConstructor
class User {
    constructor(public name: string) {}
}
​
const user = new User("Alice");
// 输出: Instance created
  • 类装饰器还可以重写类方法
function aa(target: new (...args: any) => any) {
    return class extends target {
        eat() {
            super.eat() // 调用target类的eat方法,不写这行也行
            console.log('子类 eat方法')
        }
    }
}
​
@aa
class Animal {
    eat() {
        console.log('父类eat方法')
    }
}
​
const animal = new Animal()
console.log(animal)
animal.eat()

animal.eat()调用的是重写后的方法。

缺点:重写后的原来类本身的方法无法用类实例访问了。

方法装饰器

函数是javascript的一等公民,方法装饰器的功能非常强大,方法装饰器可以修改原方法的行为、添加元数据、日志记录、权限检查等。

方法装饰器是一个接受三个参数的函数:

  1. target:装饰的目标对象,对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. propertyKey:装饰的成员名称。
  3. descriptor:成员的属性描述符。
  • 日志记录
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        console.log(`Calling ${propertyKey} with arguments: ${args}`);
        const result = originalMethod.apply(this, args);
        console.log(`Result: ${result}`);
        return result;
    };
    return descriptor;
}
class Calculator {
    @log
    add(a: number, b: number): number {
        return a + b;
    }
}
​
const calc = new Calculator();
calc.add(2, 3);
  • 权限检查
//可以在方法调用前检查 用户的权限,决定是否可以调用
let users = {
  '001':{roles:['admin']},
  '002':{roles:['member']}
}
function authorize(target:any,propertyKey:string,descriptor:PropertyDescriptor){
//获取老的函数
 const originalMethod = descriptor.value;
 //重定原型上的属性
 descriptor.value = function(roleId: keyof typeof users){
    let user = users[roleId]
    if(user&&user.roles.includes('admin')){
      originalMethod.apply(this,[roleId])
    }else{
      throw new Error(`User is not authorized to call this method`)
    }
 }
 return descriptor;
}
​
class AdminPanel{
  @authorize
  deleteUser(userId:string){
      console.log(`User ${userId} is deleted`)
  }
}
const adminPanel = new AdminPanel();
adminPanel.deleteUser('002');
  • 方法缓存

缓存方法的返回结果,以提高性能。

function cache(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    const cacheMap = new Map<string, any>();
    descriptor.value = function (...args: any[]) {
        const key = JSON.stringify(args);
        if (cacheMap.has(key)) {
            return cacheMap.get(key);
        }
        console.log(`计算${key}`)
        const result = originalMethod.apply(this, args);
        cacheMap.set(key, result);
        return result;
    };
    return descriptor;
}
class MathOperations {
    @cache
    factorial(n: number): number {
        if (n <= 1) {
            return 1;
        }
        return n * this.factorial(n - 1);
    }
}
const mathOps = new MathOperations();
console.log(mathOps.factorial(5)); // 120
console.log(mathOps.factorial(5)); // 从缓存中获取结果
  • 让简写形式的类方法可枚举
function Enum(isEnum: boolean) {
  return function (target, propertyKey, descriptor) {
    console.log('target: ', target)
    console.log('propertyKey: ', propertyKey)
    console.log('descriptor: ', descriptor)
    descriptor.enumerable = isEnum
  }
​
}
class Animal {
  @Enum(true)
  eat() {
    console.log('anmial eat 方法')
  }
}
​
const animal = new Animal()
console.log(animal)

访问符装饰器

装饰类的访问器属性(getter 和 setter)。访问器装饰器可以用于修改或替换访问器的行为,添加元数据,进行日志记录等。

访问器装饰器是一个接受三个参数的函数:

  1. target:装饰的目标对象,对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. propertyKey:访问器的名称。
  3. descriptor:访问器的属性描述符。
  • 日志记录

可以在访问器的 getset 方法中添加日志记录,以跟踪属性的访问和修改

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalGet = descriptor.get;
    const originalSet = descriptor.set;
    if (originalGet) {
        descriptor.get = function() {
            const result = originalGet.apply(this);
            console.log(`Getting value of ${propertyKey}: ${result}`);
            return result;
        };
    }
    if (originalSet) {
        descriptor.set = function(value: any) {
            console.log(`Setting value of ${propertyKey} to: ${value}`);
            originalSet.apply(this, [value]);
        };
    }
    return descriptor;
}
class User {
    private _name: string;
    constructor(name: string) {
        this._name = name;
    }
    @log
    get name() {
        return this._name;
    }
    set name(value: string) {
        this._name = value;
    }
}
const user = new User("Alice");
console.log(user.name); // Getting value of name: Alice
user.name = "Bob"; // Setting value of name to: Bob
console.log(user.name); // Getting value of name: Bob
  • 权限控制

在访问器中添加权限检查,以控制属性的访问权限。

function adminOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalGet = descriptor.get;
    descriptor.get = function() {
        const user = { role: 'user' }; // 示例用户对象
        if (user.role !== 'admin') {
            throw new Error("Access denied");
        }
        return originalGet.apply(this);
    };
    return descriptor;
}
class SecureData {
    private _secret: string = "top secret";
    @adminOnly
    get secret() {
        return this._secret;
    }
}
const data = new SecureData();
try {
    console.log(data.secret); // 抛出错误: Access denied
} catch (error) {
    console.log(error.message);
}

访问符装饰器在nestjs中用到的极少。

属性装饰器

修饰类的属性。属性装饰器用于添加元数据或进行属性初始化等操作,但不同于方法装饰器和类装饰器,它不能直接修改属性的值或属性描述符。

属性装饰器是一个接受两个参数的函数:

  1. target:装饰的目标对象,对于静态属性来说是类的构造函数,对于实例属性是类的原型对象。
  2. propertyKey:装饰的属性名称。
  • 元数据添加

属性装饰器常用于添加元数据,可以结合 Reflect API 使用,以便在运行时获取元数据。

import "reflect-metadata";
function required(target: any, propertyKey: string) {
    Reflect.defineMetadata("required", true, target, propertyKey);
}
class User {
    @required
    username: string;
}
function validate(user: User) {
    for (let key in user) {
        if (Reflect.getMetadata("required", user, key) && !user[key]) {
            throw new Error(`Property ${key} is required`);
        }
    }
}
const user = new User();
user.username = "";
validate(user); // 抛出错误:Property username is required
  • 属性访问控制

使用属性装饰器来定义属性的访问控制或初始值设置。

function defaultValue(value: string) {
  return function (target: any, propKey: string) {
    let val = value;
    const getter = function () {
      return val;
    };
    const setter = function (newVal: string) {
      val = newVal;
    };
    Object.defineProperty(target, propKey, {
      enumerable: true,
      configurable: true,
      get: getter,
      set: setter,
    });
  };
}
​
class Settings {
  @defaultValue("dark")
  theme!: string;
}
​
const s1 = new Settings();
console.log(s1.theme, "--theme");//dark --theme
  • 对属性进行修改
  1. 属性装饰器不能直接修改属性值或描述符,只能用于添加元数据或做一些初始化操作。
  2. 属性装饰器通常与其他类型的装饰器(如方法装饰器、类装饰器)配合使用,以实现更复杂的功能。

参数装饰器

修饰类构造函数或方法的参数。参数装饰器主要用于为参数添加元数据,以便在运行时能够获取这些元数据并进行相应的处理。与其他装饰器不同,参数装饰器不直接修改参数的行为或值。

参数装饰器是一个接受三个参数的函数:

  1. target:装饰的目标对象,对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. propertyKey:参数所属的方法的名称。
  3. parameterIndex:参数在参数列表中的索引。
  • 参数验证

使用参数装饰器在方法调用时验证参数的值。

// 引入 reflect-metadata 库,用于反射元数据操作
import "reflect-metadata";
// 参数装饰器函数,用于验证方法参数
function validate(target: any, propertyKey: string, parameterIndex: number) {
    // 获取现有的必需参数索引数组,如果不存在则初始化为空数组
    const existingRequiredParameters: number[] = Reflect.getOwnMetadata("requiredParameters", target, propertyKey) || [];
    // 将当前参数的索引添加到必需参数索引数组中
    existingRequiredParameters.push(parameterIndex);
    // 将更新后的必需参数索引数组存储到方法的元数据中
    Reflect.defineMetadata("requiredParameters", existingRequiredParameters, target, propertyKey);
}
// 方法装饰器函数,用于在方法调用时验证必需参数
function validateParameters(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // 保存原始方法
    const method = descriptor.value;
    // 修改方法,使其在调用时验证必需参数
    descriptor.value = function (...args: any[]) {
        // 获取方法的必需参数索引数组
        const requiredParameters: number[] = Reflect.getOwnMetadata("requiredParameters", target, propertyKey) || [];
        // 遍历必需参数索引数组,检查相应的参数是否为 undefined
        for (let parameterIndex of requiredParameters) {
            if (args[parameterIndex] === undefined) {
                // 如果必需参数为 undefined,则抛出错误
                throw new Error(`Missing required argument at position ${parameterIndex}`);
            }
        }
        // 调用原始方法并返回其结果
        return method.apply(this, args);
    };
}
// 定义 User 类
class User {
    // 构造函数,初始化 name 属性
    constructor(private name: string) {}
    // 使用 validateParameters 方法装饰器装饰 setName 方法
    @validateParameters
    setName(@validate newName: string) {
        // 设置新的 name 属性值
        this.name = newName;
    }
}
// 创建一个 User 实例
const user = new User("Alice");
// 调用 setName 方法,传入有效参数
user.setName("Bob"); // 正常
// 调用 setName 方法,传入 undefined 作为参数,触发参数验证错误
user.setName(undefined); // 抛出错误: Missing required argument at position 0
// 导出一个空对象,以避免模块级别作用域污染
export {}
  1. 参数装饰器只能应用于方法的参数,不能应用于类或属性。
  2. 参数装饰器通常依赖 Reflect API 来存储和访问元数据,因此需要引入 reflect-metadata 库,并在 tsconfig.json 中启用 emitDecoratorMetadata 选项。

装饰器的执行顺序

执行顺序

  1. 属性装饰器(Property Decorators)方法装饰器(Method Decorators) 以及访问器装饰器(Accessor Decorators)

    • 按照它们在类中出现的顺序,从上到下依次执行。
  2. 参数装饰器(Parameter Decorators)

    • 在执行方法装饰器之前执行,按照参数的位置从右到左依次执行。
    • 对于同一个参数的多个装饰器,也是从从右向左依次执行
  3. 类装饰器(Class Decorators)

    • 最后执行。
// 不同类型的装饰器的执行顺序
/**
 * 1.属性装饰器、方法装饰器、访问器装饰器它们是按照在类中出现的顺序,从上往下依次执行
 * 2.类装饰器最后执行
 * 3.参数装饰器先于方法执行
 */
function classDecorator1(target){
  console.log('classDecorator1')
}
function classDecorator2(target){
  console.log('classDecorator2')
}
function propertyDecorator1(target,propertyKey){
  console.log('propertyDecorator1')
}
function propertyDecorator2(target,propertyKey){
  console.log('propertyDecorator2')
}
function methodDecorator1(target,propertyKey){
  console.log('methodDecorator1')
}
function methodDecorator2(target,propertyKey){
  console.log('methodDecorator2')
}
function accessorDecorator1(target,propertyKey){
  console.log('accessorDecorator1')
}
function accessorDecorator2(target,propertyKey){
  console.log('accessorDecorator2')
}
function parametorDecorator4(target,propertyKey,parametorIndex:number){
  console.log('parametorDecorator4',propertyKey)//propertyKey方法名
}
function parametorDecorator3(target,propertyKey,parametorIndex:number){
  console.log('parametorDecorator3',propertyKey)//propertyKey方法名
}
function parametorDecorator2(target,propertyKey,parametorIndex:number){
  console.log('parametorDecorator2',propertyKey)//propertyKey方法名
}
function parametorDecorator1(target,propertyKey,parametorIndex:number){
  console.log('parametorDecorator1',propertyKey)//propertyKey方法名
}
@classDecorator1
@classDecorator2
class Example{
 
  @accessorDecorator1
  @accessorDecorator2
  get myProp(){
      return this.prop;
  }
  @propertyDecorator1
  @propertyDecorator2
  prop!:string
  @methodDecorator1
  @methodDecorator2
  method(@parametorDecorator4 @parametorDecorator3 param1:any,@parametorDecorator2 @parametorDecorator1 param2:any){}
}
//如果一个方法有多个参数,参数装饰器会从右向左执行
//一个参数也可有会有多个参数装饰 器,这些装饰 器也是从右向左执行的

总结

装饰器本质上是一个函数,它可以用来修改类、方法、属性、访问器或者参数。添加装饰器就像是给它们穿上了一层魔法铠甲。每次调用方法的时候都要先经过这个装饰器魔法铠甲,经过铠甲处理后然后才是我们代码逻辑本身进行处理。装饰器也不都是优点,就像上文中说的,装饰器的执行顺序、装饰器代码黑盒,影响我们判断返回结果,调试这些问题,但是装饰器就是nestjs的基础,nestjs中大量使用了装饰器功能,还利用装饰器实现了很多很酷的设计模式、如AOP切面编程,把横切的一些关注点(比如日志、权限验证)从业务逻辑中进行分离,让代码更加清晰。