前言
上一章,我们了解了装饰器和元数据,并实现了简单的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个前提:
- Tsconfig 中,experimentalDecorators 和 emitDecoratorMetadata 为 true
- import "reflect-metadata";
- 类的任意成员上使用了装饰器(不管是类装饰器、方法装饰器、参数装饰器、属性装饰器)
ts
Reflect.getMetadata("design:paramtypes", target, propertyKey?) // 方法(包括构造函数 也就是constructor)的参数的类型
Reflect.getMetadata("design:type", target, propertyKey) // 属性的类型:string ,number之类的
Reflect.getMetadata("design:returntype", target, propertyKey) // 方法返回值的类型
问题二:如何自定义依赖注入的方式?
目前的依赖注入的逻辑非常局限,默认每个参数都是由某个构造函数创建的实例,那么思考一下:
- 如果用户想传一个
plain object
呢? - 如果有个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)
}

稍稍解释一下这张图:
- 如果不是要自定义provider的key,正常是用不到这4个use*属性的,可直接配置 provides: [UserService, LoggerService,...]
- 虽然已经有了B的实例,但Fake_B + useClass仍然会再次实例化B,因为key值不同
- useExisting:复用已有的实例
- useFactory:允许传递参数,如果是某个service,需要在inject中显式声明,nestjs不会自动推导,因此要注意参数的顺序与useFactory中形参的顺序一致
- 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
装饰器手动传递provider
的key(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 的搭配使用,并不复杂。