以 NestJS 为原型看懂 Node.js 框架设计:依赖注入 之 Provider + @Inject + Container

前言

上一章,我们了解了装饰器和元数据,并实现了简单的Controller、Get装饰器,这一章我们继续为这个Controller添加新的功能:

问题一:如何优雅地注入service?

基于之前的内容,我们可以轻松写出以下代码。

ts 复制代码
@Controller()
export class UserController {
  @Get("/user/:id")
  getUserInfo(
    @Param("id") userId: string,
  ): UserInfo {
    return "Hello World!";
  }
}

但这只是demo,没有实际意义。现在我们要创建一个真正可用的接口,为UserController添加一个UserService专门处理业务逻辑,并且通常这个时候我们得发点日志什么的。

ts 复制代码
// logger.service.ts
export class LoggerService {
  log(message: string) {
    console.log(message);
  }
}

// user.service.ts
export class UserService {
  constructor(private loggerService: LoggerService) {}
  async getUserInfo(id: string): Promise<UserInfo> {
    this.loggerService.log(`getUserInfo: ${id}`);
    // 假装访问了数据库返回了这些数据
    return {
      id,
      name: "Sherry",
      age: 18,
    };
  }
}

// user.controller.ts
@Controller()
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  @Get("/user/:id")
  async getUserInfo(
    @Param("id") userId: string,
  ): Promise<UserInfo> {
    return await this.userService.getUserInfo(userId);
  }
}

注意:这里通过 private 修饰符隐式定义了 this.userService = userService; 不了解的可以查看typescript官方文档:parameter-properties章节

现在问题来了,我们需要在实例化UserController的地方实例化它所有依赖的Service,然后一层一层传进去:

ini 复制代码
const logger = new LoggerService();
const userService = new UserService(logger);
const userController = new UserController(userService);

再多加两个service,controller的话就会变成这样,永远靠手动 new,太难维护了:

ini 复制代码
const config = new ConfigService();
const logger = new LoggerService(config);

const mailer = new MailService(config);
const userService = new UserService(logger, mailer);

const userController = new UserController(userService);
const adminController = new AdminController(userService, logger, config);
// ...

如果可以创建一个函数resolve,通过resolve(UserController),可以自动 new 对象、自动生成构造参数就好了。解决办法就是通过MetaData:design:paramtypes获取构造函数参数的类型,然后直接实例化作为参数。原理是Class 既可作为类型使用,也可作为值使用。核心代码如下:

ts 复制代码
function resolve(targetClass: Constructor) {
  // 核心:获取 constructor 参数的类型列表
  const paramTypes: Constructor[] =
    Reflect.getMetadata("design:paramtypes", targetClass) || [];
  const args = paramTypes.map((dep) => resolve(dep));  // 递归
  const instance = new targetClass(...args);  // 实例化
  return instance;
}

resolve(UserService)

举个例子:

ts 复制代码
class XXXService {
    constructor(
        private loggerService: LoggerService,
        private userService: UserService,
        private mailService: MailService
    ) {}
}
const paramTypes = Reflect.getMetadata("design:paramtypes", XXXService) || [];
// 此时,paramTypes = [[class LoggerService],[class UserService],[class MailService]]
const args = paramTypes.map((dep) => resolve(dep));
// 此时,args就是这三个Service的实例,如果service有其他依赖就递归下去。
const instance = new targetClass(...args);
return instance;
// 最终返回XXXService的实例

上述实现,还需要加上缓存,这样就可避免多次实例化同一个依赖:

kotlin 复制代码
class SimpleContainer {
  private instances = new Map<Constructor, any>();
  resolve(target: Constructor) {
    // 如果已经创建过了,就直接返回
    if (this.instances.has(target)) {
      return this.instances.get(target);
    }
    
    // ...
    
    // 添加缓存
    this.instances.set(target, instance);
    return instance;
  }
}

现在我们通过MetaData:design:paramtypes初步做到了解析出constructor的参数类型,实例化后传给constructor,实现了自动依赖注入,缓存依赖。

Tips:想要TS收集并编译出以下3类完整的元类型信息,有3个前提:

  1. Tsconfig 中,experimentalDecorators 和 emitDecoratorMetadata 为 true
  2. import "reflect-metadata";
  3. 类的任意成员上使用了装饰器(不管是类装饰器、方法装饰器、参数装饰器、属性装饰器)
ts 复制代码
Reflect.getMetadata("design:paramtypes", target, propertyKey?) // 方法(包括构造函数 也就是constructor)的参数的类型
Reflect.getMetadata("design:type", target, propertyKey) // 属性的类型:string ,number之类的
Reflect.getMetadata("design:returntype", target, propertyKey) // 方法返回值的类型

问题二:如何自定义依赖注入的方式?

目前的依赖注入的逻辑非常局限,默认每个参数都是由某个构造函数创建的实例,那么思考一下:

  1. 如果用户想传一个plain object呢?
  2. 如果有个service需要在构造的时候传入动态参数呢?
ts 复制代码
interface LoggerService {
}
interface Config {
    secret: string;
}
class XXXService { 
    constructor( 
        @Inject('Logger')private loggerService: LoggerService, 
        @Inject('Config')private config: Config, 
        private mailService: MailService // 其中 mailService = new MailService(xxx)
    ) {} 
}

其实维护一个映射表就可以了,每一个依赖都声明一个对外暴露的名字和实现,当被其他依赖通过@Inject引用的时候,就根据这个key值寻找对应的实现。简单理解:Nest中Provider 就是告诉容器:"当别人需要某个依赖时,该怎么创建并提供它"。

typescript 复制代码
type Constructor<T = any> = new (...args: any[]) => T;

interface Provider<T = any> {
  provide: string | symbol | Constructor<T>; //注入令牌,也就是Key
  useClass?: Constructor<T>; // 使用类来创建实例
  useFactory?: (...args: any[]) => T; //使用工厂函数来生成实例
  useValue?: T; // 直接使用指定的值作为实例
  useExisting?: string | symbol | Constructor<T>; // 使用已有的 provider 的实例(代理已有的 provider)
}

稍稍解释一下这张图:

  1. 如果不是要自定义provider的key,正常是用不到这4个use*属性的,可直接配置 provides: [UserService, LoggerService,...]
  2. 虽然已经有了B的实例,但Fake_B + useClass仍然会再次实例化B,因为key值不同
  3. useExisting:复用已有的实例
  4. useFactory:允许传递参数,如果是某个service,需要在inject中显式声明,nestjs不会自动推导,因此要注意参数的顺序与useFactory中形参的顺序一致
  5. useValue:通常是为了注入config

因此,在原先代码基础上,将resolve的部分功能抽出来作为instantiateClass,只是增加了 provider map,没有特别复杂的,相关代码实现:

ts 复制代码
export class Container {
  private providers = new Map<Token, Provider>();
  private instances = new Map<Token, any>();

  register(providers: Provider[]) {
    for (const provider of providers) {
       this.providers.set(provider.provide, provider);
    }
  }
  resolve<T>(token: Token): T {
    // ... 读取instance缓存
    
    const provider = this.providers.get(token);
    let instance: any;
    
    if (typeof provider === "function") {
      instance = this.instantiateClass(provider);
    } else if ("useClass" in provider) {
      instance = this.instantiateClass(provider.useClass);
    } else if ("useValue" in provider) {
      instance = provider.useValue;
    } else if ("useFactory" in provider) {
      // useFactory使用场景下,还有个 inject 参数
      const deps = (provider.inject || []).map((dep) => this.resolve(dep));
      instance = provider.useFactory(...deps);
    } else if ("useExisting" in provider) {
      instance = this.resolve(provider.useExisting);
    }

    this.instances.set(token, instance);
    return instance;
  }
  
  private instantiateClass<T>(target: Constructor<T>): T {
    const paramTypes: Token[] =
      Reflect.getMetadata("design:paramtypes", target) || [];
    const args = paramTypes.map((paramType) => this.resolve(paramType));
    return new target(...args);
  }
}

接下来还需要创建一个Inject装饰器手动传递providerkey(token),和参数索引绑定起来

ts 复制代码
// packages/common/decorators/core/inject.decorator.ts
export function Inject(
  token: string | symbol | Constructor
): ParameterDecorator {
  return (target, propertyKey, index) => {
    let dependencies =
      Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, target) || [];
    dependencies = [...dependencies, { index, param: token }];
    Reflect.defineMetadata(SELF_DECLARED_DEPS_METADATA, dependencies, target);
  };
}

Nest 应用中Inject还可以装饰属性和访问器,本质上就是定义另外一份元数据,在初始化的时候对相关属性进行赋值,这里就不深入说明了。

然后我们只要稍稍修改一下 instantiateClass,拿到刚才的元数据,生成完整的args列表:

ts 复制代码
  private instantiateClass<T>(target: Constructor<T>): T {
    const paramTypes: Token[] =
      Reflect.getMetadata("design:paramtypes", target) || [];
    const injectTokens: { index: number; param: Token }[] =
      Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, target) || [];

    const args = paramTypes.map((paramType, index) => {
      const override = injectTokens.find((dep) => dep.index === index);
      // 优先使用Inject传进来的Token
      const token = override ? override.param : paramType;
      return this.resolve(token);
    });

    return new target(...args);
  }

到目前为止,代码已经完全可以运行了,不过还有一些瑕疵,开始我们提到过,若未在类上使用任何装饰器(包括构造函数参数装饰器),TS 编译器不会为 design:paramtypes 生成 metadata。这是 emitDecoratorMetadata 的行为限制。因此推荐至少使用 @Injectable() 类装饰器兜底。

ts 复制代码
export function Injectable(): ClassDecorator {
  return (target: object) => {
    Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
  };
}

// 用法
@Injectable()
export class UserService {
  constructor(@Inject("Logger") private loggerService: LoggerService) {}
  async getUserInfo(id: string): Promise<UserInfo> {
    this.loggerService.log(`getUserInfo: ${id}`);
    return {
      id,
      name: "Sherry",
      age: 18,
    };
  }
}

以上是依赖注入(Dependency Injection)容器 的基础功能实现,具体实现可点击这里查看。这种不再由组件自己控制依赖的创建和生命周期,而是由容器统一管理的设计思想就是控制反转(IoC)

小结

所有你想在构造函数中 @Inject() 的东西,都必须在 providers 中注册,你可以把它看作是一份依赖注入系统的规则配置表 ,当然这并不是provider的全部,这篇文章涉及到的是非常基础的功能,实际上拦截器、管道、守卫等也是 provider。到目前为止,实现原理还是在于 装饰器 + metadata 的搭配使用,并不复杂。

相关推荐
GDAL2 小时前
nest generate从入门到实战
javascript·nestjs·generate
林太白20 小时前
NestJS-角色模块
前端·javascript·nestjs
麻辣小蜗牛3 天前
以 NestJS 为原型看懂 Node.js 框架设计:装饰器、元数据与路由注册
nestjs
林太白3 天前
NestJS-菜单模块
前端·后端·nestjs
Wang's Blog12 天前
Nestjs框架: 关于controller中的常用装饰器
装饰器·nestjs·控制器
sanhuamao15 天前
Taro+nestjs+mongodb实现健身记录微信小程序
微信小程序·taro·nestjs
孟陬16 天前
Nestjs 常见问题和最佳解决方案(一)
nestjs
今夜星辉灿烂20 天前
nestjs微服务-系列5
后端·nestjs
一生躺平的仔24 天前
NestJS Swagger 使用说明文档
nestjs