【Nest源码系列】装饰器元数据的奇妙联合(下)

上一节我们学习了装饰器在TypeScript和babel中的支持情况,过了一遍5种类型的装饰器以及它们在Nest中源码是如何定义的,并且最后会调用Metadata相关API来操作装饰器参数。

有同学可能会问,学习了装饰器之后能做什么,或者装饰器和元数据之间有什么关系呢?这节我们来详细聊一聊!

组合装饰器

知道了每种类型的装饰器如何使用之后,对于一些复杂的场景,我们可能需要结合使用不同的装饰器,例如我们不仅想给我们的接口添加静态检查,还想加上运行时检查的能力。

要实现上面功能,需要下面2个步骤:

  • 参数装饰器优先于方法装饰器,首先要标记需要检查的参数
  • 重写descriptor的value值,运行参数检查器,失败则抛出异常

首先定义一个参数装饰器,同时收集所有传入的参数和对应的验证器

typescript 复制代码
type Validator = (x: any) => boolean;

// 保存标记
const validateMap: Record<string, Validator[]> = {};

// 标记需要检查的参数
function typedDecoratorFactory(validator: Validator): ParameterDecorator {
  return (_, key, index) => {
    const target = validateMap[key as string] ?? [];
    target[index] = validator;
    validateMap[key as string] = target;
  }
}

定义一个方法装饰器,用于批量遍历并验证参数的合法性,如果不合格,抛出参数名称和对应位置索引

javascript 复制代码
function validate(_: Object, key: string, descriptor: PropertyDescriptor) {
  const originalFn = descriptor.value;
  descriptor.value = function(...args: any[]) {

    // 运行检查器
    const validatorList = validateMap[key];
    if (validatorList) {
      args.forEach((arg, index) => {
        const validator = validatorList[index];

        if (!validator) return;

        const result = validator(arg);

        if (!result) {
          throw new Error(
            `参数校验失败: ${arg} 索引为: ${index}`
          );
        }
        
        console.log(`参数 ${arg} 校验成功!`)
      });
    }

    // 运行原有的方法
    return originalFn.call(this, ...args);
  }
}

最后我们在User类中定义一个方法,同时声明参数验证器,绑定需要验证的参数

less 复制代码
const isInt = typedDecoratorFactory((x) => Number.isInteger(x));
const isString = typedDecoratorFactory((x) => typeof x === 'string');

class Person {
  @validate
  sayHello(@isString name: string, @isInt age: number) {
    return `姓名:${name}, 年龄:${age}`;
  }
}

最后的代码是这样

typescript 复制代码
type Validator = (x: any) => boolean;

// 保存标记
const validateMap: Record<string, Validator[]> = {};

// 标记需要检查的参数
function typedDecoratorFactory(validator: Validator): ParameterDecorator {
  return (_, key, index) => {
    const target = validateMap[key as string] ?? [];
    target[index] = validator;
    validateMap[key as string] = target;
  }
}

function validate(_: Object, key: string, descriptor: PropertyDescriptor) {
  const originalFn = descriptor.value;
  descriptor.value = function(...args: any[]) {

    // 运行检查器
    const validatorList = validateMap[key];
    if (validatorList) {
      args.forEach((arg, index) => {
        const validator = validatorList[index];

        if (!validator) return;

        const result = validator(arg);

        if (!result) {
          throw new Error(
            `参数校验失败: ${arg} 索引为: ${index}`
          );
        }
        
        console.log(`参数 ${arg} 校验成功!`)
      });
    }

    // 运行原有的方法
    return originalFn.call(this, ...args);
  }
}

const isInt = typedDecoratorFactory((x) => Number.isInteger(x));
const isString = typedDecoratorFactory((x) => typeof x === 'string');

class Person {
  @validate
  sayHello(@isString name: string, @isInt age: number) {
    return `姓名:${name}, 年龄:${age}`;
  }
}

const p = new Person();
p.sayHello('hello', 2); // pass
p.sayHello('hello', '333'); // error

通过codeopen执行结果如下:

javascript 复制代码
"参数 hello 校验成功!"
"参数 2 校验成功!"
"参数 hello 校验成功!"
Uncaught Error: 参数校验失败: 333 索引为: 1 
 at https://cdpn.io/cpe/boomboom/pen.js?key=pen.js-0dbca2df-e214-0345-2f83-bcfc6dd9de47:37

到这里,你应该可以实现一个类似于Nest的参数验证器了

元数据

元数据和装饰器在ECMAScript中是两个独立的部分,在stage 3中被分为两个提案跟进,但是想要实现类似Java中反射的能力, 你总同时需要它们。

什么是反射?

反射技术是在运行时(runtime)可以访问、检测和修改它本身状态或行为的一种能力。例如在 Nest.js 中,是利用 TypeScript 的装饰器和元数据功能,在运行时动态地获取和操作类、方法、属性等元素的信息的能力。

元数据有什么用呢,我们通过装饰器+元数据来改造一下上面的例子,现在有这样一个需求,我不希望每次新增参数都需要加一个@IsString、@IsBoolean这样的验证器,而是借助TypeScript自身的类型声明来自动检查参数类型。

上面我们说了,元数据提供这样的能力,让我们可以获取编译时的类型。由于metadata还在stage阶段,所以我们使用reflect-metadata来实现这个功能。

typescript 复制代码
// 在codeopen中导入这个 等价于 import 'reflect-metadata'
import "https://esm.sh/reflect-metadata";

function validate(target: Object, key: string, descriptor: PropertyDescriptor) {
  const originalFn = descriptor.value;
  // 获取参数的编译期类型
  const designParamTypes = Reflect.getMetadata('design:paramtypes', target, key);
  
  descriptor.value = function(...args: any[]) {
    args.forEach((arg, index) => {
      const paramType = designParamTypes[index];

      const result = arg.constructor === paramType
        || arg instanceof paramType;

      if (!result) {
        throw new Error(
          `参数校验失败: ${arg} 索引为: ${index}`
        );
      }
      
      console.log(`参数 ${arg} 校验成功!`)
    });

    // 运行原有的方法
    return originalFn.call(this, ...args);
  }
}

class Person {
  @validate
  sayHello(name: string, age: number) {
    return `姓名:${name}, 年龄:${age}`;
  }
}

const p = new Person();
p.sayHello('hello', 2); // pass
p.sayHello('hello', '333'); // error

执行结果是一样的:

这样我们就通过元数据实现了参数验证功能,核心是这行代码,它表示获取参数类型

javascript 复制代码
Reflect.getMetadata('design:paramtypes', target, key)

三种内置类型

目前为止一共有三种编译期类型可以拿到,是内置的:

  • design:type: 属性的类型。
  • desin:paramtypes: 方法的参数的类型。
  • design:returntype: 方法的返回值的类型。

这三种方式拿到的结果都是构造函数(例如String和Number)。规则是:

  • number -> Number
  • string -> String
  • boolean -> Boolean
  • void/null/never -> undefined
  • Array/Tuple -> Array
  • Class -> 类的构造函数
  • Enum -> 如果是纯数字枚举则为Number , 否则是 Object
  • Function -> Function
  • 其余都是Object

打印一下案例中的类型就知道了

那在Nest中是不是这样的呢?我们一起来看看,在package/common/constants.ts源码中也找到了这个常量

Nest中的TypeScript默认开启了emitDecoratorMetadata这个配置,它会将这个常量注入到运行时,这样我们就可以拿到设置的元数据信息进行动态操作了,依赖注入也是基于这个实现的。

总结

我们通过组合装饰器的方式实现了类似Nest中的参数验证器,掌握这个可以写很多复杂的装饰器了。但是在这个场景下,需要不断新增验证器函数,我们利用TypeScript自带的类型系统,加上metadata运行时特性,实现了自动参数校验。

metadata内置了三种类型,Nest中源码中也可以找到design:paramtypes定义,到这里基本就清楚Nest中各种装饰器是如何实现的了。

最后,Nest是依赖TypeScript配置中默认开启的emitDecoratorMetadata配置,实现在运行时注入metadata类型来获取装饰器信息,依赖注入和路由映射也是基于这个来实现的,相关核心内容我们后续再学习。

相关推荐
丁总学Java15 分钟前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
It'sMyGo24 分钟前
Javascript数组研究09_Array.prototype[Symbol.unscopables]
开发语言·javascript·原型模式
懒羊羊大王呀26 分钟前
CSS——属性值计算
前端·css
李是啥也不会40 分钟前
数组的概念
javascript
无咎.lsy1 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec1 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron
fishmemory7sec1 小时前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
豆豆2 小时前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
JUNAI_Strive_ving2 小时前
番茄小说逆向爬取
javascript·python
看到请催我学习2 小时前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5