VSCode用它管理上千个服务:依赖注入从入门到实战
摘要 :你的代码里到处都是 new
?类之间紧密耦合难以测试?本文从最基础的依赖注入概念讲起,逐步深入到装饰器与DI容器,最后剖析VSCode如何用依赖注入系统优雅地管理整个应用架构。读完你将理解为什么依赖注入是现代应用架构的基石。
引言
在日常开发中,你可能经常写出这样的代码:
typescript
class UserService {
private userRepo = new UserRepo();
private logger = new Logger();
public findUser(id: string) {
this.logger.log('查询用户');
return this.userRepo.findById(id);
}
}
这段代码看起来很直观,但隐藏着一个严重的问题:UserService
和它的依赖 UserRepo
、Logger
紧紧地绑在了一起。想要替换数据库实现?想要在测试中 Mock Logger?几乎不可能。
更糟糕的是,当你的应用规模扩大,类之间的依赖关系变成一张复杂的网,你会发现:
- 修改一个类的构造函数,可能需要修改几十个地方
- 单元测试变得异常困难,因为无法隔离依赖
- 想要支持不同环境的配置,需要到处修改
new
语句
有没有更优雅的方式?有的,这就是依赖注入(Dependency Injection,DI)。
VSCode 作为一个拥有数百万行代码的大型项目,正是通过依赖注入系统来管理成百上千个服务。今天,我们就从最基础的概念开始,一步步理解依赖注入的精髓,最后看看 VSCode 是如何实现工业级的 DI 系统的。
什么是依赖注入
依赖注入(Dependency Injection,DI) 是一种设计模式,核心思想非常简单:
不要在类内部创建依赖对象,而是从外部传入所需的依赖。
这个简单的改变,带来了四个重要的好处:
- 降低耦合度:类不需要知道具体的实现,只依赖于抽象接口
- 提高可测试性:可以轻松注入 Mock 对象进行单元测试
- 增强灵活性:可以在运行时动态切换不同的实现
- 符合 SOLID 原则:特别是依赖倒置原则(Dependency Inversion Principle)
让我们用一个简单的例子来理解这个转变:
传统方式(紧耦合)
typescript
class UserService {
// ❌ 在内部创建依赖
private userRepo = new UserRepo();
public findUser(id: string) {
return this.userRepo.findById(id);
}
}
在这个例子中,UserRepo
是在 UserService
内部实例化的,两者紧密耦合。如果想把 UserRepo
替换成其他实现(比如换一个数据库),你必须修改 UserService
的代码。
依赖注入方式(松耦合)
typescript
class UserService {
private userRepo: UserRepo; // 只声明依赖类型
// ✅ 从外部传入依赖
constructor(userRepo: UserRepo) {
this.userRepo = userRepo;
}
public findUser(id: string) {
return this.userRepo.findById(id);
}
}
// 使用时在外部创建依赖并注入
const userRepo = new UserRepo();
const userService = new UserService(userRepo);
现在,UserRepo
在 UserService
外部实例化,并通过构造函数传入。UserService
只需要知道依赖的接口,不需要关心具体实现。这样就可以轻松替换成其他实现,甚至在测试中使用 Mock 对象:
typescript
// 生产环境使用真实实现
const userService = new UserService(new UserRepo());
// 测试环境使用 Mock
const userService = new UserService(new MockUserRepo());
这就是依赖注入的本质:控制权的反转(Inversion of Control,IoC)。依赖对象的创建权从类内部转移到了外部,类本身只负责使用这些依赖。
手动依赖注入:最基础的实践
理解了概念之后,让我们通过一个更完整的例子来看看依赖注入的实际应用。
第一步:定义接口,而非具体实现
这是依赖注入的关键原则:依赖于抽象,而非具体实现。
typescript
// 定义日志接口
interface Logger {
log(message: string): void;
}
// 定义数据库接口
interface DatabaseService {
save(data: any): Promise<void>;
findById(id: string): Promise<any>;
}
注意,这里我们只定义了接口,没有涉及任何具体实现。这样做的好处是:UserService
将只依赖这些接口,不依赖任何具体的实现类。
第二步:为不同场景提供不同实现
接口定义好之后,我们可以为不同的场景提供不同的实现:
typescript
// 控制台日志实现
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}
}
// 文件日志实现
class FileLogger implements Logger {
log(message: string): void {
// 实际项目中这里会写入文件
console.log(`[FILE] ${message}`);
}
}
// MongoDB 数据库实现
class MongoDBService implements DatabaseService {
async save(data: any): Promise<void> {
console.log('保存到 MongoDB:', data);
}
async findById(id: string): Promise<any> {
console.log('从 MongoDB 查询:', id);
return { id, name: 'John' };
}
}
// MySQL 数据库实现
class MySQLService implements DatabaseService {
async save(data: any): Promise<void> {
console.log('保存到 MySQL:', data);
}
async findById(id: string): Promise<any> {
console.log('从 MySQL 查询:', id);
return { id, name: 'Jane' };
}
}
现在我们有了多种实现:日志可以输出到控制台或文件,数据库可以是 MongoDB 或 MySQL。关键是,这些实现都遵循同样的接口。
第三步:业务服务只依赖接口
现在来实现我们的业务服务 UserService
。注意,它只依赖接口,不依赖任何具体实现:
typescript
// 使用依赖注入的业务服务
class UserService {
constructor(
private logger: Logger, // 依赖 Logger 接口
private database: DatabaseService // 依赖 DatabaseService 接口
) {}
async createUser(userData: any): Promise<void> {
this.logger.log('开始创建用户');
try {
await this.database.save(userData);
this.logger.log('用户创建成功');
} catch (error) {
this.logger.log(`用户创建失败: ${error}`);
throw error;
}
}
async getUser(id: string): Promise<any> {
this.logger.log(`查询用户: ${id}`);
return await this.database.findById(id);
}
}
UserService
不关心具体用的是哪种 Logger,也不关心用的是哪种数据库。它只需要知道"能打日志"和"能存储数据"就够了。
第四步:灵活组装,随心切换
现在,我们可以根据不同的需求,灵活地组装不同的服务组合:
typescript
// 开发环境:使用控制台日志 + MongoDB
const devLogger = new ConsoleLogger();
const devDatabase = new MongoDBService();
const devUserService = new UserService(devLogger, devDatabase);
// 生产环境:使用文件日志 + MySQL
const prodLogger = new FileLogger();
const prodDatabase = new MySQLService();
const prodUserService = new UserService(prodLogger, prodDatabase);
// 测试环境:使用 Mock 对象
const mockLogger = new MockLogger();
const mockDatabase = new MockDatabase();
const testUserService = new UserService(mockLogger, mockDatabase);
这就是依赖注入的魅力:
- 解耦合 -
UserService
不依赖具体的日志和数据库实现,只依赖抽象接口 - 可扩展 - 可以轻松替换不同的
Logger
(控制台/文件)和DatabaseService
(MongoDB/MySQL)实现 - 易测试 - 可以注入 Mock 对象进行单元测试
- 灵活配置 - 同一个业务逻辑可以在不同环境下使用不同的底层服务实现
但是,你可能注意到了一个问题:每次创建 UserService
,我们都需要手动创建并注入所有依赖。当依赖关系变得复杂时,这会变得非常繁琐。有没有办法自动化这个过程?
自动化DI:装饰器与容器
当应用规模扩大,手动管理依赖注入会变得非常繁琐。想象一下,如果你有几十个服务,每个服务又依赖其他几个服务,依赖关系会形成一张复杂的网。
这时候,我们需要一个**DI容器(DI Container)**来自动管理这些依赖关系。
什么是DI容器?
DI 容器的核心职责是:
- 注册(Register):记录服务标识符和具体实现之间的映射关系
- 解析(Resolve):当需要某个服务时,自动创建实例并注入所有依赖
- 生命周期管理:决定服务是单例还是每次都创建新实例
让我们从零开始实现一个简单但完整的 DI 容器。
第一步:定义核心类型
typescript
// 构造函数类型
type Constructor<T = any> = new (...args: any[]) => T;
// 服务标识符:可以是类本身、字符串或Symbol
type ServiceIdentifier<T = any> = Constructor<T> | string | symbol;
// 服务生命周期
enum ServiceLifetime {
Transient = 'transient', // 每次都创建新实例
Singleton = 'singleton' // 单例模式
}
// 服务描述符
interface ServiceDescriptor<T = any> {
implementation: Constructor<T>; // 具体实现类
lifetime: ServiceLifetime; // 生命周期
dependencies: ServiceIdentifier[]; // 依赖的服务列表
}
// 依赖元数据
interface DependencyMetadata {
index: number; // 参数位置
identifier: ServiceIdentifier; // 服务标识符
}
第二步:实现DI容器
typescript
class DIContainer {
// 存储服务注册信息
private services = new Map<ServiceIdentifier, ServiceDescriptor>();
// 缓存单例实例
private singletonInstances = new Map<ServiceIdentifier, any>();
// 解析栈,用于检测循环依赖
private resolutionStack: ServiceIdentifier[] = [];
// 注册瞬态服务(每次创建新实例)
register<T>(identifier: ServiceIdentifier<T>, implementation: Constructor<T>): void {
this.registerWithLifetime(identifier, implementation, ServiceLifetime.Transient);
}
// 注册单例服务(全局唯一实例)
registerSingleton<T>(identifier: ServiceIdentifier<T>, implementation: Constructor<T>): void {
this.registerWithLifetime(identifier, implementation, ServiceLifetime.Singleton);
}
// 统一的注册逻辑
private registerWithLifetime<T>(
identifier: ServiceIdentifier<T>,
implementation: Constructor<T>,
lifetime: ServiceLifetime
): void {
// 分析构造函数的依赖
const dependencies = this.getDependencies(implementation);
this.services.set(identifier, { implementation, lifetime, dependencies });
}
// 解析服务:获取服务实例
resolve<T>(identifier: ServiceIdentifier<T>): T {
// 检测循环依赖
if (this.resolutionStack.includes(identifier)) {
throw new Error(`循环依赖: ${[...this.resolutionStack, identifier].join(' -> ')}`);
}
// 获取服务描述符
const descriptor = this.services.get(identifier);
if (!descriptor) {
throw new Error(`服务未注册: ${String(identifier)}`);
}
// 加入解析栈
this.resolutionStack.push(identifier);
try {
// 单例模式:使用缓存
if (descriptor.lifetime === ServiceLifetime.Singleton) {
if (!this.singletonInstances.has(identifier)) {
this.singletonInstances.set(identifier, this.createInstance(descriptor));
}
return this.singletonInstances.get(identifier);
}
// 瞬态模式:每次创建新实例
return this.createInstance(descriptor);
} finally {
// 移出解析栈
this.resolutionStack.pop();
}
}
// 创建实例:递归解析所有依赖
private createInstance<T>(descriptor: ServiceDescriptor<T>): T {
// 递归解析所有依赖
const resolvedDependencies = descriptor.dependencies.map(dep => this.resolve(dep));
// 使用解析后的依赖创建实例
return new descriptor.implementation(...resolvedDependencies);
}
// 获取类的依赖信息(通过装饰器添加的元数据)
private getDependencies(target: Constructor): ServiceIdentifier[] {
const metadata: DependencyMetadata[] = (target as any).__dependencies__ || [];
return metadata.sort((a, b) => a.index - b.index).map(meta => meta.identifier);
}
}
这个 DI 容器实现了三个核心功能:
- 循环依赖检测 :通过
resolutionStack
追踪解析链,防止无限递归 - 单例管理:对于单例服务,只创建一次并缓存
- 自动依赖解析:递归解析并创建所有依赖
第三步:定义装饰器
为了简化使用,我们定义两个装饰器:
typescript
// @Injectable 装饰器:标记类可以被注入
function Injectable<T extends Constructor>(target: T): T {
return target;
}
// @Inject 装饰器:标记构造函数参数需要注入的服务
function Inject(identifier: ServiceIdentifier) {
return function (target: any, propertyKey: string | symbol | undefined, parameterIndex: number) {
// 在类的原型上记录依赖信息
const metadata: DependencyMetadata[] = target.__dependencies__ || [];
metadata.push({ index: parameterIndex, identifier });
target.__dependencies__ = metadata;
};
}
第四步:使用示例
现在,我们可以用装饰器来简化依赖注入的使用:
typescript
// 定义唯一标识符(使用 Symbol 避免命名冲突)
const TOKENS = {
LOGGER: Symbol('Logger'),
DATABASE: Symbol('Database'),
USER_SERVICE: Symbol('UserService')
};
// 定义服务
@Injectable
class Logger {
log(message: string): void {
console.log(`[LOG] ${message}`);
}
}
@Injectable
class Database {
// 使用 @Inject 指定要注入的服务
constructor(@Inject(TOKENS.LOGGER) private logger: Logger) {}
async save(data: any): Promise<void> {
this.logger.log(`保存数据: ${JSON.stringify(data)}`);
}
}
@Injectable
class UserService {
constructor(
@Inject(TOKENS.DATABASE) private database: Database,
@Inject(TOKENS.LOGGER) private logger: Logger
) {}
async createUser(userData: any): Promise<any> {
this.logger.log('创建用户');
await this.database.save(userData);
return { id: Date.now(), ...userData };
}
}
// 注册服务
const container = new DIContainer();
container.registerSingleton(TOKENS.LOGGER, Logger); // Logger 是单例
container.register(TOKENS.DATABASE, Database); // Database 每次创建新实例
container.register(TOKENS.USER_SERVICE, UserService);
// 使用服务(所有依赖自动注入)
const userService = container.resolve<UserService>(TOKENS.USER_SERVICE);
userService.createUser({ name: 'John', email: 'john@example.com' });
现在,依赖注入变得非常简洁:
- 用
@Injectable
标记类 - 用
@Inject(TOKENS.XXX)
标记依赖 - 注册到容器
- 容器自动解析所有依赖并创建实例
关键概念Q&A
Q:register
和 registerSingleton
有什么区别?
A:生命周期不同。registerSingleton
注册的是单例,不管多少个类依赖它,使用的总是同一个实例,适合全局唯一的资源(如日志服务、配置服务)。register
注册的不是单例,每次有类依赖时,都会创建一个全新的实例。
Q:DI 注册的 class 是在什么阶段实例化的?
A:在 register
阶段,DI 容器仅仅记录了 class 和标识符之间的映射关系,以及构造函数依赖的元数据(__dependencies__
),并没有实例化。只有在调用 resolve
时,才会实例化该类。如果此类依赖其他类,会深度递归解析所有依赖并逐个实例化。这是 DI 容器**延迟实例化(Lazy Instantiation)**的特性,可以提高启动性能。
VSCode的DI系统:工业级实现
前面我们学习了依赖注入的基本原理和实现。现在让我们看看,VSCode 这样一个拥有数百万行代码的大型项目,是如何应用依赖注入系统的。
VSCode DI 的使用方式
VSCode 使用了自定义的依赖注入系统,使用起来非常优雅。让我们通过一个真实的例子来看看:
第一步:创建服务标识符
typescript
// 来源:src/vs/workbench/services/textfile/common/textfiles.ts
// 使用 createDecorator 创建服务标识符
const ITextFileService = createDecorator<ITextFileService>('textFileService');
第二步:全局注册单例
typescript
// 来源:src/vs/workbench/services/textfile/electron-sandbox/nativeTextFileService.ts
// 注册为单例服务
registerSingleton(ITextFileService, NativeTextFileService, InstantiationType.Eager);
第三步:在其他服务中使用
typescript
// 在任何需要文件服务的地方,直接通过装饰器注入
class XXXService {
constructor(
@ITextFileService private readonly _textFileService: ITextFileService
) {}
someMethod() {
// 直接使用注入的服务
this._textFileService.save(/* ... */);
}
}
是不是非常简洁?只需要三步:
createDecorator
创建服务标识符registerSingleton
全局注册- 在构造函数中用装饰器注入
这背后有三个关键的实现。
关键实现一:createDecorator
createDecorator
函数用于创建服务标识符和对应的装饰器:
typescript
// 来源:src/vs/platform/instantiation/common/instantiation.ts
export namespace _util {
export const serviceIds = new Map<string, ServiceIdentifier<any>>();
export const DI_TARGET = '$di$target';
export const DI_DEPENDENCIES = '$di$dependencies';
export function getServiceDependencies(ctor: any): { id: ServiceIdentifier<any>; index: number }[] {
return ctor[DI_DEPENDENCIES] || [];
}
}
export interface ServiceIdentifier<T> {
(...args: any[]): void;
type: T;
}
// 在类原型上存储依赖信息
function storeServiceDependency(id: Function, target: Function, index: number): void {
if ((target as any)[_util.DI_TARGET] === target) {
(target as any)[_util.DI_DEPENDENCIES].push({ id, index });
} else {
(target as any)[_util.DI_DEPENDENCIES] = [{ id, index }];
(target as any)[_util.DI_TARGET] = target;
}
}
// 创建服务标识符和装饰器
export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
// 避免重复创建
if (_util.serviceIds.has(serviceId)) {
return _util.serviceIds.get(serviceId)!;
}
// 返回一个装饰器函数
const id = <any>function (target: Function, key: string, index: number) {
if (arguments.length !== 3) {
throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
}
// 在类的原型上记录依赖关系
storeServiceDependency(id, target, index);
};
id.toString = () => serviceId;
_util.serviceIds.set(serviceId, id);
return id;
}
createDecorator
的作用是生成一个装饰器,当装饰器被使用时,会在被装饰的类原型上挂载 DI_TARGET
和 DI_DEPENDENCIES
属性,用于后续的依赖分析。
关键实现二:registerSingleton
registerSingleton
用于全局注册服务:
typescript
// 来源:src/vs/platform/instantiation/common/extensions.ts
// 全局注册表
const _registry: [ServiceIdentifier<any>, SyncDescriptor<any>][] = [];
// 实例化类型
export const enum InstantiationType {
Eager = 0, // 立即实例化
Delayed = 1 // 延迟实例化
}
// 注册单例服务
export function registerSingleton<T, Services extends BrandedService[]>(
id: ServiceIdentifier<T>,
ctorOrDescriptor: { new(...services: Services): T } | SyncDescriptor<any>,
supportsDelayedInstantiation?: boolean | InstantiationType
): void {
if (!(ctorOrDescriptor instanceof SyncDescriptor)) {
ctorOrDescriptor = new SyncDescriptor<T>(
ctorOrDescriptor as new (...args: any[]) => T,
[],
Boolean(supportsDelayedInstantiation)
);
}
// 记录到全局注册表
_registry.push([id, ctorOrDescriptor]);
}
// 获取所有已注册的服务
export function getSingletonServiceDescriptors(): [ServiceIdentifier<any>, SyncDescriptor<any>][] {
return _registry;
}
在 register
阶段,服务只是被记录到全局注册表 _registry
中,并没有被实例化。实例化发生在第一次使用时。
关键实现三:InstantiationService
InstantiationService
是 VSCode DI 系统的核心,负责实际的依赖解析和实例创建:
typescript
// 来源:src/vs/platform/instantiation/common/instantiationService.ts
export class InstantiationService implements IInstantiationService {
constructor(
private readonly _services: ServiceCollection = new ServiceCollection(),
) {
// 把自己也注册到容器中,这样其他服务也可以依赖 InstantiationService
this._services.set(IInstantiationService, this);
}
createInstance<T>(ctor: any, ...args: any[]): T {
// 获取构造函数的依赖
const serviceDependencies = _util.getServiceDependencies(ctor)
.sort((a, b) => a.index - b.index);
const serviceArgs: any[] = [];
// 解析每个依赖
for (const dependency of serviceDependencies) {
const service = this._getOrCreateServiceInstance(dependency.id);
serviceArgs.push(service);
}
// 使用 Reflect.construct 创建实例
return Reflect.construct<any, T>(ctor, serviceArgs);
}
private _getOrCreateServiceInstance(id: ServiceIdentifier<any>): any {
// 如果已经创建过,直接返回
// 否则创建新实例(递归解析依赖)
// ...
}
}
createInstance
方法的核心逻辑:
- 通过
getServiceDependencies
获取要创建类的依赖列表 - 递归解析每个依赖,调用
_getOrCreateServiceInstance
获取依赖实例 - 使用
Reflect.construct
创建目标实例,把所有依赖作为构造函数参数传入
DI 系统的初始化
那么,VSCode 的 DI 系统是在哪里启动的呢?答案是在应用的入口文件:
typescript
// 来源:src/vs/code/electron-main/main.ts
class CodeMain {
private createServices() {
const services = new ServiceCollection();
// 手动创建一些基础服务
const environmentMainService = new EnvironmentMainService(xxx);
services.set(IEnvironmentMainService, environmentMainService);
// ... 其他基础服务
// 创建 InstantiationService
return [new InstantiationService(services, true)];
}
}
你可能会疑惑:为什么有些服务(如 EnvironmentMainService
)还是手动 new
创建的,而不是通过 DI 系统自动创建?
这是因为:
- DI 系统本身无法自举:必须先有第一批手动创建的服务,才能启动 DI 系统
- 基础服务的特殊性:一些核心服务(如环境服务、配置服务)是 DI 系统本身的依赖,必须先手动创建
- 初始化顺序控制:某些服务的初始化顺序很重要,手动创建可以精确控制
等基础服务启动完成后,后续的服务就可以利用 DI 系统自动实例化了。
ServiceCollection:简单的服务容器
typescript
// 来源:src/vs/platform/instantiation/common/serviceCollection.ts
export class ServiceCollection {
private _entries = new Map<ServiceIdentifier<any>, any>();
constructor(...entries: [ServiceIdentifier<any>, any][]) {
for (const [id, service] of entries) {
this.set(id, service);
}
}
set<T>(id: ServiceIdentifier<T>, instanceOrDescriptor: T | SyncDescriptor<T>): T | SyncDescriptor<T> {
const result = this._entries.get(id);
this._entries.set(id, instanceOrDescriptor);
return result;
}
has(id: ServiceIdentifier<any>): boolean {
return this._entries.has(id);
}
get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
return this._entries.get(id);
}
}
ServiceCollection
的实现非常简单,就是一个 Map
,用于存储服务标识符和服务实例(或描述符)之间的映射关系。
总结
依赖注入的核心思想很简单:不要在类内部创建依赖,而是从外部传入。这个简单的改变带来了解耦、易测试、可扩展等诸多好处。
我们从三个层次理解了依赖注入:手动注入展示了基本概念,DI 容器实现了自动化管理,VSCode 则演示了工业级的应用实践。关键技术包括装饰器、服务容器、延迟实例化和循环依赖检测。
依赖注入不是银弹,但当你的代码库变得复杂,类之间的依赖关系让你焦头烂额时,它确实是让代码变得更优雅、更易维护的重要工具。
写在最后
依赖注入是我在阅读 VSCode 源码时最先注意到的设计模式之一。起初看到到处都是 @IXXXService
装饰器时,我还有些困惑。但深入理解后,我被它的优雅深深折服:整个应用的几百个服务,就像积木一样可以自由组合,每个服务只关注自己的职责,依赖关系清晰明了。
留给你的思考题
写完这篇文章,我想问问你:
- 你的项目中是否也遇到过"到处都是
new
,依赖关系一团乱麻"的问题? - 你是如何解决的?是引入了 DI 框架,还是有其他更好的方案?
- 对于小型项目,你觉得依赖注入是不是"过度设计"?
交流与分享
如果你对 VSCode 的架构设计感兴趣,欢迎关注我的 VSCode 源码寻宝:那些藏在代码里的设计智慧 专栏,我会持续分享 VSCode 源码中的精彩设计。
最后,如果你在实践中遇到了问题,或者有更好的实践经验,欢迎在评论区分享。期待你的见解:
- 你在引入 DI 时遇到过哪些坑?
- 你的团队是如何平衡"灵活性"和"复杂度"的?
- 有没有什么"不适合用 DI"的场景?