前段时间在写一个玩Minecraft游戏的Agent项目maicraft-next ,它是原python项目maicraft的typescript重构。原项目中各种组件的依赖已经非常复杂了,于是重构的时候就打算引入类似Java Spring一样的IoC的实现。
为了学习相关的概念和实现,我并没有引入第三方的现成的框架,而是和Cursor 加Claude Sonnet 4.5协作写了一套轻量化的IoC容器。本文记录下这个实现,方便后续回看。
1. 概述
在后端开发中,传统模式下组件需自己通过new创建依赖对象,导致代码紧耦合。为解决这一问题,有了IoC(控制反转)设计思想------把"创建、管理依赖"的控制权从组件转移到第三方;
而依赖注入(DI) 是实现IoC最主流的方式,即第三方主动创建依赖对象,通过构造器、Setter等方式"注入"给组件;我们常说的IoC容器(如Spring的ApplicationContext),则是承载IoC思想、执行DI操作的落地工具。
由于项目本身比较小,不打算弄得太复杂导致过度设计,就没用反射机制和类似java注解的机制了,而是在一个初始化文件中手动管理依赖。
先展示一下最后的效果:
1.1服务消费端
需要用到某个组件的时候,无需关心依赖关系,只需要声明自己要哪个组件就能从容器中获取到。
TypeScript
// Agent.ts - 组件不依赖容器
class Agent {
constructor(
private bot: Bot,
private executor: ActionExecutor,
private llmManager: LLMManager,
private memory: MemoryManager,
// ...其他依赖
) {
// 直接使用注入的依赖
}
}
// 使用时无需关心依赖关系
const agent = await container.resolveAsync<Agent>(ServiceKeys.Agent);
await agent.start();
1.2服务配置端
在bootstrap.ts这个文件注册所有的组件,这里传入的回调函数是在初始化的时候调用来构建该组件的工厂函数,在此处声明依赖关系并且注入(这也是简化的部分,如果要进一步设计,可以用类似反射的机制来避免每个组件都要手动解析依赖和手动注入),因为不是注册的时候立刻初始化所以用回调。
TypeScript
// bootstrap.ts - 集中配置所有依赖
export function configureServices(container: Container): void {
container
.registerSingleton(ServiceKeys.Agent, async c => {
const bot = c.resolve<Bot>(ServiceKeys.Bot);
const executor = c.resolve(ServiceKeys.ActionExecutor);
const llmManager = await c.resolveAsync(ServiceKeys.LLMManager);
const memory = await c.resolveAsync(ServiceKeys.MemoryManager);
return new Agent(bot, executor, llmManager, memory);
})
.withInitializer(ServiceKeys.Agent, async agent => {
await agent.initialize();
})
.withDisposer(ServiceKeys.Agent, async agent => {
await agent.stop();
});
}
1.3应用启动
TypeScript
// main.ts - 清晰的启动流程
class MaicraftNext {
async initialize(): Promise<void> {
const container = new Container();
// 注册基础实例
container.registerInstance(ServiceKeys.Config, this.config);
container.registerInstance(ServiceKeys.Bot, this.bot);
// 配置所有服务依赖
configureServices(container);
// 获取完全初始化的代理
const agent = await container.resolveAsync<Agent>(ServiceKeys.Agent);
await agent.start();
}
}
2. 核心组件设计
2.1 服务标识符
使用 Symbol 确保类型安全和唯一性:
TypeScript
// ServiceKeys.ts
export const ServiceKeys = {
Config: Symbol('Config'),
Bot: Symbol('Bot'),
Agent: Symbol('Agent'),
LLMManager: Symbol('LLMManager'),
MemoryManager: Symbol('MemoryManager'),
// ...更多服务
} as const;
// 类型映射提供编译时类型推断
export interface ServiceTypeMap {
[ServiceKeys.Agent]: Agent;
[ServiceKeys.Bot]: Bot;
[ServiceKeys.LLMManager]: LLMManager;
// ...
}
为什么选择 Symbol 而非字符串?
主要就下面两个原因,其他性能优化啥的这个项目并不看重,不过既然Claude设计了就留着。
1. 唯一性保证
TypeScript
// Symbol - 绝对唯一
const botKey1 = Symbol('Bot');
const botKey2 = Symbol('Bot');
console.log(botKey1 === botKey2); // false
// 字符串 - 可能重复
const botKey1 = 'Bot';
const botKey2 = 'Bot'; // 不同模块可能定义相同的键
console.log(botKey1 === botKey2); // true - 可能导致冲突
2. 防止键冲突
这个特性其实项目足够大才能体现出用处,本项目比较小,一个对象记录就可以,不用分多个。
TypeScript
// 场景:不同模块的服务可能同名
const userServiceKeys = {
Cache: 'Cache', // 用户缓存
Logger: 'Logger', // 用户日志
};
const productServiceKeys = {
Cache: 'Cache', // 产品缓存 - 冲突!
Logger: 'Logger', // 产品日志 - 冲突!
};
// Symbol 方式无此问题
const userServiceKeys = {
Cache: Symbol('Cache'),
Logger: Symbol('Logger'),
};
const productServiceKeys = {
Cache: Symbol('Cache'), // 不同 Symbol,无冲突
Logger: Symbol('Logger'),
};
2.2 服务生命周期
TypeScript
export enum Lifetime {
Singleton = 'singleton', // 全局单例
Transient = 'transient', // 每次创建新实例
Scoped = 'scoped', // 作用域单例(未实现)
}
2.3 服务描述符
TypeScript
interface ServiceDescriptor<T = any> {
key: ServiceKey;
factory: Factory<T>;
lifetime: Lifetime;
instance?: T;
initializer?: (instance: T) => Promise<void> | void;
disposer?: (instance: T) => Promise<void> | void;
}
3. 容器核心实现
3.1 Container 类结构
TypeScript
export class Container {
private services = new Map<ServiceKey, ServiceDescriptor>();
private resolving = new Set<ServiceKey>(); // 循环依赖检测
constructor(logger?: Logger) {
this.logger = logger || getLogger('Container');
}
//...
//注册、解析相关方法
}
3.2 服务注册
TypeScript
// 注册单例作用域的组件
registerSingleton<T>(key: ServiceKey, factory: Factory<T>): this {
return this.register(key, factory, Lifetime.Singleton);
}
// 注册瞬时作用域的组件
registerTransient<T>(key: ServiceKey, factory: Factory<T>): this {
return this.register(key, factory, Lifetime.Transient);
}
// 直接注册实例
registerInstance<T>(key: ServiceKey, instance: T): this {
this.services.set(key, {
key,
factory: () => instance,
lifetime: Lifetime.Singleton,
instance,
});
return this;
}
register<T>(key: ServiceKey, factory: Factory<T>, lifetime: Lifetime = Lifetime.Singleton): this {
if (this.services.has(key)) {
this.logger.warn(`服务 ${String(key)} 已存在,将被覆盖`);
}
this.services.set(key, {
key,
factory,
lifetime,
});
this.logger.debug(`注册服务: ${String(key)} (${lifetime})`);
return this;
}
3.3 循环依赖检测
TypeScript
resolve<T>(key: ServiceKey): T {
const descriptor = this.services.get(key);
// 检测循环依赖
if (this.resolving.has(key)) {
const chain = Array.from(this.resolving)
.map(k => String(k))
.join(' -> ');
throw new Error(`检测到循环依赖: ${chain} -> ${String(key)}`);
}
try {
this.resolving.add(key);// 标记正在解析
// 解析逻辑...
// 这部分和下文的异步解析基本一致,都是调用注册时的工厂函数来创建实例,此处不赘述
} finally {
this.resolving.delete(key);
}
}
由于我对命名有些强迫症,所以顺便问了问为什么要用resolve()而不是get(),在我眼里IoC容器就是个复杂版的Map,而且Spring的命名好像也是getBean()。
回答是:从语义上来说,get通常返回已经存在的元素且不会创建新的元素,而resolve则需要经过复杂的解析依赖过程,且可能创建新的元素;
4. 异步支持
4.1 异步工厂函数
和同步解析相比,核心差别只是async和await关键字,分开两个方便调用方await。
TypeScript
export type Factory<T = any> = (container: Container) => T | Promise<T>;
// 支持异步解析
async resolveAsync<T>(key: ServiceKey): Promise<T> {
const descriptor = this.services.get(key);
// 单例已存在直接返回
if (descriptor.lifetime === Lifetime.Singleton && descriptor.instance) {
return descriptor.instance;
}
try {
this.resolving.add(key);
// 支持异步工厂函数
const instance = await descriptor.factory(this);
if (descriptor.lifetime === Lifetime.Singleton) {
descriptor.instance = instance;
}
// 执行异步初始化器
if (descriptor.initializer && descriptor.lifetime === Lifetime.Singleton) {
await descriptor.initializer(instance);
}
return instance;
} finally {
this.resolving.delete(key);
}
}
4.2 生命周期管理
TypeScript
// 添加初始化器
withInitializer<T>(key: ServiceKey, initializer: (instance: T) => Promise<void> | void): this {
const descriptor = this.services.get(key);
if (!descriptor) {
throw new Error(`服务 ${String(key)} 未注册`);
}
descriptor.initializer = initializer;
return this;
}
// 添加销毁器
withDisposer<T>(key: ServiceKey, disposer: (instance: T) => Promise<void> | void): this {
const descriptor = this.services.get(key);
descriptor.disposer = disposer;
return this;
}
// 容器销毁时清理所有资源
async dispose(): Promise<void> {
const disposers: Array<Promise<void>> = [];
for (const descriptor of this.services.values()) {
if (descriptor.lifetime === Lifetime.Singleton &&
descriptor.instance &&
descriptor.disposer) {
disposers.push(descriptor.disposer(descriptor.instance));
}
}
await Promise.all(disposers);
this.services.clear();
}
5. 实际应用案例
5.1 复杂依赖链示例
TypeScript
// Agent 依赖多个服务
container.registerSingleton(ServiceKeys.Agent, async c => {
const bot = c.resolve<Bot>(ServiceKeys.Bot);
const executor = c.resolve(ServiceKeys.ActionExecutor);
const llmManager = await c.resolveAsync(ServiceKeys.LLMManager);
const memory = await c.resolveAsync(ServiceKeys.MemoryManager);
const planningManager = await c.resolveAsync(ServiceKeys.GoalPlanningManager);
const config = c.resolve<AppConfig>(ServiceKeys.Config);
return new Agent(bot, executor, llmManager, config, memory, planningManager);
});
5.2 条件性依赖注入
TypeScript
// MemoryManager 根据配置决定是否注入 MaiBotClient
container.registerSingleton(ServiceKeys.MemoryManager, async c => {
const memory = new MemoryManager();
const config = c.resolve<AppConfig>(ServiceKeys.Config);
// 条件性注入
if (config.maibot.enabled) {
try {
const maibotClient = await c.resolveAsync(ServiceKeys.MaiBotClient);
memory.setMaiBotClient(maibotClient);
} catch (error) {
logger.warn('MaiBot 客户端初始化失败,使用无 MaiBot 模式');
}
}
return memory;
});
5.3 延迟初始化
TypeScript
// 解决循环依赖的方法之一
container
.registerSingleton(ServiceKeys.ActionExecutor, c => {
const executor = new ActionExecutor(contextManager, logger);
// 注册动作
registerActions(executor, logger);
return executor;
})
.withInitializer(ServiceKeys.ActionExecutor, executor => {
// 延迟设置 ContextManager 的 executor 引用
contextManager.updateExecutor(executor);
});
6. 测试支持
6.1 测试容器隔离
TypeScript
// 测试时创建独立的容器
const testContainer = new Container();
// 注入 Mock 依赖
testContainer.registerInstance(ServiceKeys.Bot, mockBot);
testContainer.registerInstance(ServiceKeys.LLMManager, mockLLMManager);
// 其他服务使用真实实现
testContainer.registerSingleton(ServiceKeys.MemoryManager, c => {
return new MemoryManager();
});
// 测试目标服务
const agent = await testContainer.resolveAsync<Agent>(ServiceKeys.Agent);
6.2 集成测试
TypeScript
describe('Agent Integration Tests', () => {
let container: Container;
let agent: Agent;
beforeEach(async () => {
container = new Container();
// 设置测试环境
setupTestContainer(container);
agent = await container.resolveAsync<Agent>(ServiceKeys.Agent);
});
afterEach(async () => {
await container.dispose();
});
});