Typescript: Nest.js 中使用声明文件定义依赖注入的类型
Nest 靠 emitDecoratorMetadata 把构造函数的参数类型写进 design:paramtypes,运行时再读回来。接口在这一步会塌成 Object,这就是按接口注入必须另给 token 的全部原因。
Nest 里的构造函数注入看着有点像魔术:
@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 底下是 LoggerService、ConsoleLogger 这些日志相关的东西,没有哪个 .d.ts 声明过 type Injectable<T> = T;就算真有,它也是个恒等别名,构不成任何约束。声明文件在运行时是完全不存在的东西,它不可能参与解析。
真正的答案不在声明文件里,在编译产物里。
答案在编译产物里
打开 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() 指名道姓:
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 并存时用 |
useFactory 的 inject 数组值得单说:工厂函数是普通函数,不是类,没有构造函数,也就没有 design:paramtypes 可读。所以它的依赖必须手写:
{ provide: LOGGER, useFactory: (config: ConfigService) => config.get('NODE_ENV') === 'production' ? new JsonLogger() : new ConsoleLogger(), inject: [ConfigService],}useExisting 和 useClass 的差别容易被忽略:useClass 会造出第二个实例。同一个类既用 useClass 注册给 token A、又直接注册给它自己,容器里就有两份,各自持有各自的状态。要共用一份得用 useExisting。
循环依赖靠 forwardRef 拖延一步
两个 service 互相注入时,装饰器求值的那一刻其中一个的类还没定义完,design:paramtypes 里拿到的是 undefined。forwardRef 的作用是把「现在就要这个类」换成「一会儿再去取这个类」:
@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 和 AOP 和 TypeScript:MobX 中使用映射类型设计实现对象的响应式代理。