Typescript: Nest.js 中使用声明文件定义依赖注入的类型

Nest 靠 emitDecoratorMetadata 把构造函数的参数类型写进 design:paramtypes,运行时再读回来。接口在这一步会塌成 Object,这就是按接口注入必须另给 token 的全部原因。

预计
7 分钟

Nest 里的构造函数注入看着有点像魔术:

没有任何一处写了「注入 UserService」
@Injectable()
export class PostService {
constructor(private readonly userService: UserService) {}
createPost(userId: number, content: string) {
const user = this.userService.getUser(userId);
}
}

没有配置、没有字符串键,只有一个类型标注。而 TypeScript 的类型标注编译完是要被全部擦掉的 —— private readonly userService: UserService 里的 UserService 三个字,按理说在运行时根本不存在。容器凭什么知道该塞什么进来?

这个问题有一个流传很广的错误答案:说 @nestjs/common 的声明文件里定义了一个 Injectable<T> 类型,各个 service 都是它的子类型,容器靠这层关系找到实现。这个类型不存在。 @nestjs/common/services 底下是 LoggerServiceConsoleLogger 这些日志相关的东西,没有哪个 .d.ts 声明过 type Injectable<T> = T;就算真有,它也是个恒等别名,构不成任何约束。声明文件在运行时是完全不存在的东西,它不可能参与解析。

真正的答案不在声明文件里,在编译产物里。

答案在编译产物里

打开 emitDecoratorMetadata 编译上面那个类,产物末尾会多出一段:

tsc --experimentalDecorators --emitDecoratorMetadata 的产物
UserService = __decorate([
Injectable(),
__metadata("design:paramtypes", [UserRepository, Object])
], UserService);

design:paramtypes 是一个数组,按顺序记着构造函数每个参数的类型。注意它记的不是类型的名字,是类型对应的那个值UserRepository 是一个类,类在运行时是一个真实的构造函数对象,所以它原样留在数组里。

容器要做的事情就此变得很朴素 —— 读出这个数组,拿第 0 项那个构造函数当键去查自己的 provider 表,找到对应实例,new PostService(实例)。类型标注在这里之所以还能用,是因为类同时是类型也是值,擦掉类型之后那个值还在。

这份元数据要能读出来,运行时得有 Reflect.getMetadata。Nest 的入口文件第一行 import 'reflect-metadata' 就是干这个的,漏了它拿到的是一张空表。这套机制怎么被用来做通用容器,从架构分层设计到 IOC 和 AOP那篇拆得更细。

接口注入不进去,因为它塌成了 Object

上面那段产物里还有第二项,Object。它对应的参数原本写的是 private logger: ILogger,而 ILogger 是个接口。

接口是纯类型,编译后一点痕迹都不剩,design:paramtypes 里那个位置只好填一个 Object 占着。容器拿到 Object 无从判断你想要哪个实现,报出来就是那句常见的 Nest can't resolve dependencies of the PostService

所以按接口注入必须自己给一个运行时存在的 token,用 @Inject() 指名道姓:

接口靠 token 注入,token 得是个运行时真实存在的值
export const LOGGER = Symbol('LOGGER'); // 也可以用字符串,symbol 不会撞名
@Injectable()
export class PostService {
constructor(@Inject(LOGGER) private readonly logger: ILogger) {}
}

@Inject() 写在参数上,它是一个参数装饰器。这件事值得单独记一笔:TypeScript 5.0 起默认启用的那套新装饰器没有参数装饰器,同样的代码不开 experimentalDecorators 会直接报 error TS1206: Decorators are not valid here.。再加上 emitDecoratorMetadata 不配 experimentalDecorators 时编译器直接拒绝(error TS5052),Nest 这套依赖注入的两根支柱都只长在老装饰器上 —— 这就是它至今仍在 experimentalDecorators 上的原因,不是没来得及升级。

@Injectable() 本身几乎什么都没做

既然解析靠的是 design:paramtypes,那 @Injectable() 是干什么的?

它在 packages/common/decorators/core/injectable.decorator.ts,做的事情是往类上写两份元数据:一个标记这个类是 provider 的水印(INJECTABLE_WATERMARK),一份作用域选项(SCOPE_OPTIONS_METADATA,也就是 @Injectable({ scope: Scope.REQUEST }) 里传的东西)。它不创建实例,也不注册任何依赖。

但它有一个不写就出事的副作用:design:paramtypes 只在声明上存在装饰器时才会生成。 一个类如果构造函数有参数却忘了加 @Injectable(),编译产物里根本不会有那段 __metadata,容器读到空数组,于是认为这个类不需要任何依赖,new 出来的实例上所有依赖都是 undefined

这解释了一条常被当作口诀背的规则:没有依赖的 provider 不加 @Injectable() 也能用,有依赖的必须加。原因不在 Nest,在编译器什么时候才肯 emit。

四种 provider 写法,区别在「值从哪来」

providers: [UserService] 是简写,展开是 { provide: UserService, useClass: UserService }。token 和实现分开之后就有了四种组合:

写法 值从哪来 典型用途
useClass 容器 new 一个这个类,并递归解析它的依赖 换实现:测试环境换成 MockUserService
useValue 直接给一个现成的对象 常量、配置、第三方 SDK 实例、测试替身
useFactory 调用一个函数拿返回值,依赖写在 inject 数组里 值要在运行时算出来,比如按环境变量选实现
useExisting 不新建,指向另一个已注册的 token 给同一个实例起别名,新旧 token 并存时用

useFactoryinject 数组值得单说:工厂函数是普通函数,不是类,没有构造函数,也就没有 design:paramtypes 可读。所以它的依赖必须手写:

工厂的依赖靠 inject 数组显式列出,顺序和参数一一对应
{
provide: LOGGER,
useFactory: (config: ConfigService) =>
config.get('NODE_ENV') === 'production' ? new JsonLogger() : new ConsoleLogger(),
inject: [ConfigService],
}

useExistinguseClass 的差别容易被忽略:useClass 会造出第二个实例。同一个类既用 useClass 注册给 token A、又直接注册给它自己,容器里就有两份,各自持有各自的状态。要共用一份得用 useExisting

循环依赖靠 forwardRef 拖延一步

两个 service 互相注入时,装饰器求值的那一刻其中一个的类还没定义完,design:paramtypes 里拿到的是 undefinedforwardRef 的作用是把「现在就要这个类」换成「一会儿再去取这个类」:

两边都要包,只包一边不行
@Injectable()
export class UserService {
constructor(@Inject(forwardRef(() => AuthService)) private authService: AuthService) {}
}

模块之间循环引用同理,写在 imports 里:imports: [forwardRef(() => UserModule)]。两边都得包,只改一边解不开。

不过 forwardRef 是止血,不是治疗。需要它通常说明两个 service 的职责切错了 —— 把互相要用的那部分抽成第三个 service,比两头包 forwardRef 更值得先试一次。

代价

  • 类型标注在这里承担了它设计之外的职责。 正常的 TypeScript 类型是可以整个擦掉的,emitDecoratorMetadata 让一部分类型漏进了运行时。代价是这套写法离不开 TypeScript 加 tsc:换成 esbuild、SWC 这类不做类型检查的转译器,就得靠各自的插件补上这段 emit,配置漏了不会报错,只会在启动时说解析不了依赖。
  • 接口在 DI 里是不好用的抽象。 想按接口编程就必须维护一套 token 常量,token 和接口两处要手动保持一致,编译器不检查这个对应关系。
  • 错误从编译期挪到了启动期。 手写 new PostService(userService) 少一个参数编译就过不去;交给容器之后,忘了在 providers 里注册要等应用启动才报出来。好在 Nest 是启动时一次性解析完整棵依赖树,不是等到第一次请求 —— 这类错误至少不会漏到线上跑一半才出现。
  • exports 是另一道闸。 provider 注册在模块里默认只有本模块可见,别的模块 imports 了这个模块也拿不到,除非它写进 exports。这一步和类型系统完全无关,编辑器里 import 得进来不代表容器给得出。

这篇归在 TypeScript 下。同一主题里最近的另外两篇是 TypeScript:从架构分层设计到 IOC 和 AOPTypeScript:MobX 中使用映射类型设计实现对象的响应式代理