以 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 的搭配使用,并不复杂。

相关推荐
Eric_见嘉20 小时前
NestJS 🧑‍🍳 厨子必修课(九):API 文档 Swagger
前端·后端·nestjs
XiaoYu20029 天前
第3章 Nest.js拦截器
前端·ai编程·nestjs
XiaoYu200210 天前
第2章 Nest.js入门
前端·ai编程·nestjs
实习生小黄11 天前
NestJS 调试方案
后端·nestjs
当时只道寻常14 天前
NestJS 如何配置环境变量
nestjs
濮水大叔1 个月前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
ovensi1 个月前
告别笨重的 ELK,拥抱轻量级 PLG:NestJS 日志监控实战指南
nestjs
ovensi1 个月前
Docker+NestJS+ELK:从零搭建全链路日志监控系统
后端·nestjs
Gogo8161 个月前
nestjs 的项目启动
nestjs