什么是依赖注入?
- 什么是依赖注入?
- 装饰器在依赖注入中担任什么角色?
- 如何通过 Decorator Stage 3 实现依赖注入框架?
在 Node.js 生态中,NestJS 和 Angular 是两个非常经典的基于依赖注入的框架。
让我们通过一个现实中的例子来理解。假设有一个书店,它除了卖书,可能还卖文具、器材等。先定义这个书店的类:
class BookStore {
book() {
return 'book';
}
//...
}
在某些场景下,书店可能不止有一种书,文具也可能有多种类型。如果持续在 BookStore 类中添加方法,它很快就会变得臃肿不堪。这时,就需要依赖注入技巧来解耦。
最简单的依赖注入形式是,将书、文具等职责封装到独立的类中。
class BookCase1 {
book1() {
// ...
}
}
class BookStore {
constructor(private bookCase: BookCase1) {}
book() {
return this.bookCase.book1();
}
// ...
}
// execute
const bookstore = new BookStore(new BookCase1());
bookstore.book();
这个例子或许不够完美,但其底层逻辑是清晰的:通过外部的类为内部类提供依赖,从而实现职责分离与可配置性。
在 NestJS 这类成熟的前端框架中,依赖注入的实现方式如下:
// foo.service.ts
@Injectable()
export class FooService {
foo() {
// ...code
}
}
// app.service.ts
@Injectable()
export class BarService {
constructor(private fooService: FooService) {}
bar() {
this.fooService.foo();
}
// ...code
}
// app.module.ts
@Module({
providers: [AppService, FooService],
exports: [AppService]
})
export class AppModule {}
在 BarService 中,bar 方法调用了来自 FooService 类的 foo 方法。
装饰器扮演了什么角色?
可以看到,NestJS 处理依赖注入的方式与上面的简单例子不同。它通过 @Module 装饰器来创建和管理不同的服务模块,并为 providers 内部的模块提供所需的服务实例。
那么,这些装饰器(@Module、@Injectable)究竟扮演了什么角色?
简单来说,装饰器主要用于提供元数据标记,以便框架进行依赖分析和注入。
例如,@Module 装饰器为类标记了“这是一个模块”的元数据。当依赖注入容器检测到该元数据时,就能根据其中定义的 providers 信息来执行依赖注入。
而对于 @Injectable,在 Decorator Stage 2 的语法下,可以结合参数装饰器来标记需要注入的依赖。
Stage 2 的装饰器语法是目前 TypeScript 官方文档描述的主流实现方式。
@Injectable()
class FooService {
constructor(@Inject('barService') private barService: BarService) {}
// ...
}
@Module({
providers: [
FooService,
{ provide: 'barService', useClass: BarService }
]
})
class AppModule {}
在依赖解析阶段,框架已经知晓模块间的依赖关系,知道何时注入以及通过哪个 token(本例中即 provide: 'barService' 这个值)找到正确的依赖进行注入。上面的写法是一种完整的、显式的依赖声明形式。
以上分析都基于 Stage 2 的装饰器语法。而在 Stage 3 中,装饰器语法发生了显著变化:新增了 accessor 装饰器,但移除了参数装饰器。
如何用 Stage 3 装饰器实现 DI 框架?
accessor 装饰器可以理解为类属性的 getter/setter 语法糖,它同样能提供元数据标记。
最初我认为,在 Stage 3 版的装饰器基础上实现一个 DI 框架是可行的,但实际尝试后遇到了不少问题。
先考虑一个最简单的实现场景:
@Injectable()
class BarService {
barFunc() {
return 'bar';
}
}
@Injectable()
class FooService {
constructor(private bar: BarService) {}
fooFunc() {
return this.bar.barFunc();
}
}
@Module({
providers: [BarService, FooService]
})
class AppModule {}
bootstrap() {
const app = await AppContainerFactory.create(AppModule);
const foo = app.get(FooService);
}
bootstrap();
这是我为这个 DI 框架设计的最初构想。运行 AppContainerFactory,通过导入的 AppModule 进行依赖分析,最终获取到 FooService 实例,并能解析其内部依赖,供 fooFunc 方法调用。
那么,回到核心问题:如何通过 Decorator Stage 3 实现依赖注入框架?
首先,虽然 Stage 3 装饰器移除了参数装饰器,但这不代表无法获取构造函数的参数信息。我们可以利用 accessor 装饰器来实现这一目的。
@Injectable()
class AppService {
@Inject('demoService')
accessor #DemoService: DemoService;
constructor(demo: DemoService) {
this.#DemoService = demo;
}
}
这里的 @Inject 装饰器主要用于在依赖解析时提供一个标记。如果没有这个标记,运行时将无法确定应该注入哪个模块。这相当于将 Stage 2 的参数装饰器功能,转移到了属性(accessor)装饰器上来实现。
如果仅仅是为了实现一个简易的依赖注入框架,上述写法没有问题。然而,当遇到像 NestJS 中 Controller 这样的场景时,问题就出现了——至少目前我还没想出优雅的解决方案。
@Controller()
class AppController {
@Get()
getHello(@Query() query: string) {
return `query: ${query}`;
}
}
这里在方法参数上使用了装饰器(@Query()),这属于参数装饰器的范畴,而 Stage 3 并不支持参数装饰器。如何在新的语法规范下处理这类 Web 框架常见的需求,是一个待解的难题。
这次在 云栈社区 分享的探索过程,记录了我尝试基于新标准构建底层框架时遇到的真实挑战。技术规范的演进往往伴随着取舍,而如何在新约束下找到平衡点,正是开发者需要不断思考的。
参考链接
- stage3 装饰器使用参考
2ality.com/2022/10/javascript-decorators
- typescript支持stage3装饰器的blog
devblogs.microsoft.com/typescript/announcing-typescript-5-0
- stage3的metadata polyfill
github.com/daomtthuan/polyfill-symbol-metadata