Dependency Injection
依赖注入(Dependency Injection)设计范式总结
本文整理自一次关于"依赖注入 vs OOP 状态"的讨论,核心结论:
依赖注入、Scope、数据传递是三个互相独立的维度,用错维度才是冲突的根源。
一、核心观点:三个独立维度
| 维度 | 解决的问题 |
|---|---|
| 依赖注入(DI) | 依赖的接线(谁创建、怎么组装) |
| Scope | 实例的共享粒度(单例 / 每请求 / 每次) |
| 数据传递 | 业务数据怎么流动(参数 / 参数对象 / 上下文) |
常见误区:把"数据问题"当成"依赖问题",把"状态问题"当成"DI 的锅"。
实际上:
- DI 只负责注入依赖(Repository、Logger、HttpClient 等协作者),不负责传输数据。
- 依赖要么无状态,要么只有配置态(构造时设定、之后只读)。
- 数据(userId、订单条目等)本来就应该走参数或请求上下文。
- 把会变的业务数据塞进单例字段,才是 bug 的根源,不是范式冲突。
二、状态的两种性质
| 状态类型 | 特性 | 是否适合单例 |
|---|---|---|
| 配置态 | 构造时确定、之后只读(taxRate、连接串) | 安全 |
| 可变业务态 | 每次使用都会变(items、userId) | 不安全,需换 scope 或工厂 |
// 安全:单例 + 只读配置态
@Injectable()
class CartService {
private readonly taxRate: number
constructor(cfg: ConfigService) {
this.taxRate = cfg.get('taxRate')
}
calcTotal(items: Item[]) {
return items.reduce((s, i) => s + i.price * this.taxRate, 0)
}
}
// 危险:单例存可变请求态,并发请求互相覆盖
@Injectable() // 默认 singleton
class OrderService {
private userId: string // 请求 A 和 B 互相污染
create(userId: string) { this.userId = userId }
}
三、Scope:实例的共享粒度
DI 容器通过 scope 控制实例共享范围:
| Scope | 行为 | 适用场景 |
|---|---|---|
| singleton(默认) | 整个应用一个实例 | 无状态服务、只读配置态 |
| request | 每个请求一个实例 | 字段存请求态的对象 |
| transient | 每次注入新建 | 用完即弃的对象 |
// NestJS:每个请求一个实例,字段是本次请求的私有状态
@Injectable({ scope: Scope.REQUEST })
class ShoppingSession {
private items: Item[] = []
add(item: Item) { this.items.push(item) }
total() { /* ... */ }
}
注意:request-scoped 类内部依然可以注入别的依赖,所以有状态 ≠ 不能用 DI。
四、工厂模式:单例服务 + 每次新建的有状态对象
单例服务不保存状态,需要时让工厂"现场造"全新的带初始状态的对象:
// ① 有状态的业务对象
class Order {
private items: Item[] = []
constructor(private readonly user: User) {} // 初始状态
add(item: Item) { this.items.push(item) }
total() { /* 算钱 */ }
}
// ② 工厂:集中创建逻辑,自己也能注入依赖
@Injectable()
class OrderFactory {
constructor(private readonly pricing: PricingService) {}
createNewOrder(user: User): Order {
return new Order(user) // 每次全新实例
}
}
// ③ 单例服务:自己不存状态,用的时候向工厂要新对象
@Injectable()
class OrderService {
constructor(private readonly factory: OrderFactory) {}
create(user: User, item: Item) {
const order = this.factory.createNewOrder(user)
order.add(item)
return order.total()
}
}
和直接 new 的区别:
1. 复杂初始化逻辑收拢在工厂一处
2. 工厂能注入依赖,间接为 Order 提供依赖(不用手动 new 一堆东西)
3. 测试时方便 mock 工厂
五、跨层数据:用上下文而不是层层塞参数
Node 用 AsyncLocalStorage(Java 用 ThreadLocal):
@Injectable()
class RequestContext {
private static store = new AsyncLocalStorage<{ userId: string }>()
run<T>(userId: string, fn: () => T) { return this.store.run({ userId }, fn) }
get userId() { return this.store.getStore()?.userId }
}
// 调用链里任何地方都能拿,不需要每个方法都加参数
this.ctx.userId
六、入参过多的解法:参数对象(Parameter Object)
方法参数太多是代码味道,正确解法是分组为对象,而不是塞字段:
// 坏:10 个参数
createOrder(userId, items, addr, coupon, note, shipTime, ...)
// 好:分组为对象
createOrder(user: UserInfo, order: OrderDraft, options?: OrderOptions)
七、决策规则(速查表)
| 对象性质 | 注入方式 |
|---|---|
| 无状态 / 只有配置态 | 单例 + 数据走参数 |
| 数据随请求变化 | request scope 或工厂 |
| 横切所有层的请求数据(userId、租户) | AsyncLocalStorage / ThreadLocal 上下文 |
| 纯值对象(拼装参数、DTO) | 不注入,自己 new |
核心心法:
实例只存"不变的东西"(依赖、配置),会变的东西一律走参数或上下文。
做到这点,单例注入就是安全的,"实例数据互不影响"自动成立。
八、一句话总结
依赖注入、Scope、数据传递是三个独立维度;"单例 + 可变业务态字段"是 bug 用法,不是范式冲突。