每个开发者都会在某个时刻碰到同一堵墙。你想为一段代码写测试,却发现代码在内部偷偷调用了 new Database()。无论你怎么做,都会触发一个真实的数据库连接。你想换掉邮件服务商,但邮件 SDK 被埋在三层深处。你想让同一段逻辑既能处理 HTTP 请求又能处理定时任务,但框架被如此紧密地嵌入,几乎不可能实现。
这些都是同一个根本原因的症状:代码自己创建依赖。解决方案有一个正式的名字——Dependency Injection——但它背后的思想令人清爽地简单。你停止让代码自己寻找协作者,而是从外部把它们传进来。就这样。其他一切——IoC 容器、DI 框架、injection token——都只是建立在这一个思想之上的机械装置。
本文从零开始拆解这一切:问题、原理、模式,以及在测试中的实际收益。全程使用 TypeScript,因为它对来自任何 Web 或服务端背景的工程师来说都清晰易读。
硬连接依赖:代码自己调用 new 时
这是一个处理订单的 service。乍一看相当合理:
// order-service.ts — “改造前”版本
class OrderService {
async placeOrder(cart: Cart): Promise<Order> {
const db = new PostgresClient(process.env.DATABASE_URL!) // 硬连接数据库
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // 硬连接供应商
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!) // 硬连接供应商
const order = Order.fromCart(cart)
await stripe.charges.create({ amount: order.total, source: cart.token })
await db.query('INSERT INTO orders …', [order])
await mailer.send({ to: cart.email, template: 'order-confirmed' })
return order
}
}
一旦你尝试使用它,三个问题就会浮现:
- 无法孤立测试。你无法在没有真实凭据、真实数据库和真实 Stripe 沙箱的情况下运行
placeOrder的单元测试。每个测试默认都是缓慢、脆弱的集成测试。 - 与具体实现死死绑定。如果业务从 Stripe 迁移到 Adyen,你就要打开这个文件做手术。如果 staging 环境需要一个假邮件服务,没有干净的方式插入。
- 依赖隐藏不可见。类签名的读者看到
placeOrder(cart),完全不知道这个方法背后需要三个外部服务。依赖是不可见的,直到你读完每一行代码。
每当你在方法体或构造函数内部——而不是在组合根处——看到 new SomeExternalThing(),那就是一个硬连接依赖。该类既负责完成自己的工作,又负责寻找和构建完成这项工作所需的工具。这是两项工作,它们会朝相反的方向拉扯。
Inversion of Control:谁来负责创建?
在用代码解决问题之前,先给这个原理命个名。Inversion of Control(IoC)是这样一个思想:一段代码不应该负责获取自己的依赖。相反,某个外部的东西——框架、测试框架、组合根——负责创建并传入这些依赖。
你可能听说过所谓的好莱坞原则:“不要打电话给我们,我们会打给你。”在经典的命令式代码中,你的业务逻辑直接调用它的依赖——它主动伸手去拿数据库。有了 IoC,依赖主动来找你。那段关系的控制权被倒转了:你不再决定何时或如何获取你的协作者;外部世界负责决定并传递给你。
IoC 是一个宽泛的原理,而不是某种具体的技术。框架以多种方式实现它:
- Dependency Injection(DI)——把协作者作为构造函数参数或方法参数传入。最常见、最明确的形式。
- Service Locator——一个全局注册表,代码在运行时通过名称检索其依赖。(稍后会讲为什么这是反模式。)
- Template Method——基类调用子类覆写的钩子方法,基类控制流程,子类填写空白。
- Event / Observer——组件发布事件;框架将它们路由给订阅者。两侧互不相知。
在这些方式中,Dependency Injection 是最透明、最可测试的。它也是最常与 IoC 本身混淆的一种,这就是为什么这两个术语值得区分:IoC 是原理,DI 是该原理的一种实现。
Dependency Injection:把依赖传进来
DI 的机制一旦你看清楚就简单得令人难为情。你不在类内部构造协作者,而是把它们声明为参数,让调用者来提供。
有三种形式——但在大多数情况下只有一个明确的赢家:
构造函数注入(首选)
依赖在构造函数中声明。这是标准选择:每个依赖在类签名中可见,在构造时必须提供,并且可以标记为 readonly,防止后续被重新赋值。
// order-service.ts — 使用构造函数注入的“改造后”版本
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer { send(to: string, template: string): Promise<void> }
class OrderService {
constructor(
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
private readonly mailer: Mailer,
) {}
async placeOrder(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // 纯业务规则
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.mailer.send(cart.email, 'order-confirmed')
return order
}
}
大声读出那个构造函数签名:“要创建一个 OrderService,你需要一个支付网关、一个订单仓储和一个邮件服务。”这是一份契约,用普通代码写出,无需读一行方法体就能看到。
Setter / 方法注入(偶尔使用)
依赖在构造之后通过方法设置。对于可选协作者,或者框架需要分两个阶段构造对象时(比如某些测试框架、某些 IoC 容器)很有用。谨慎使用——它允许对象处于部分配置的状态,这是一个微妙的 bug 来源。
// setter 注入——只在构造时无法连接时才使用
class ReportGenerator {
private formatter: ReportFormatter = new DefaultFormatter()
setFormatter(f: ReportFormatter) { this.formatter = f } // 可选覆盖
generate(data: ReportData): string {
return this.formatter.format(data)
}
}
默认用构造函数注入。类始终需要的每个依赖都应该在构造函数里。只有当依赖确实是可选的,或者框架强制要求时,才使用 setter 注入。不要把 setter 注入当作逃避思考依赖图的逃生舱口。
组合根:把一切连接在一起的那一处
一旦你通过构造函数注入依赖,一个新问题随之而来:究竟谁来实际调用 new 并把各部分连接在一起?答案是组合根(composition root):应用入口处的一个单一位置,在这里所有真实对象被构造并相互注入。
把它想象成整个代码库中唯一一个允许自由出现 new ConcreteImplementation() 的地方。其他一切都用接口说话。组合根是“抽象系统”与“真实世界”之间的缝合处。
// composition-root.ts — 真实对象在这里被连接起来的唯一位置
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway } from './adapters/stripe-payment-gateway'
import { SendGridMailer } from './adapters/sendgrid-mailer'
import { OrderService } from './domain/order-service'
import { OrderController } from './http/order-controller'
export function buildApp() {
// 1. 先构建最底层的依赖(自身没有依赖)
const db = new PostgresOrderRepository(process.env.DATABASE_URL!)
const stripe = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)
// 2. 构建 service,注入其依赖
const orderSvc = new OrderService(stripe, db, mailer)
// 3. 构建交付层,注入 service
const orderCtrl = new OrderController(orderSvc)
return orderCtrl
}
注意组合根读起来几乎像一份配方:“准备这些原料,将它们组合成 service,再把 service 放入 controller。”整个代码库中只有一处,你去那里就能理解一切是如何连接的。在引导新工程师入职,或者需要替换某个原料时,这是一笔巨大的财富。
在测试中,你写一个测试专用的组合根——一个用 fake 代替真实 adapter 的微型等价物:
// 在测试文件中——同一个 service,假的协作者
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders = new InMemoryOrderRepository()
const fakeMailer = new NoOpMailer()
const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// 毫秒级运行;无网络,无数据库
手动 DI 与 DI 容器——有什么变化,何时重要
以上所有都是手动 DI,也叫Pure DI。你自己写连接代码。对于很多项目——尤其是小项目——这是正确的选择:它是普通代码,没有魔法,可以立即调试,新开发者只需读一个文件就能理解整个连接方式。
随着应用增长,手动方式开始让人不舒服。DI 容器(有时也叫 IoC 容器)自动化了依赖的注册和解析,并在此之上增加了生命周期管理。
OrderService 向外伸手构造自己的依赖——每条箭头都是嵌入类体中的一处耦合。右侧,组合根构造具体对象并将它们向内注入。箭头方向反转了。service 只知道接口;具体类型是边缘处的细节。| 问题 | 手动 / Pure DI | DI 容器(NestJS、InversifyJS、tsyringe……) |
|---|---|---|
| 类型如何注册? | 你在组合根中显式写出连接代码。 | 装饰器、token 或元数据——容器自动扫描并注册。 |
| 实例如何解析? | 按正确顺序自己调用 new。 |
向容器请求一个类型;它递归构建完整的依赖图。 |
| 生命周期 / 作用域管理 | 你来决定:每个模块一个实例、每次请求一个、或者瞬态——你持有引用。 | 声明式:@Singleton()、@RequestScoped() 等。容器处理销毁。 |
| 可调试性 | 一切都是普通代码——在任何地方设置断点,跟随调用栈。 | 解析在容器内部发生;“我为什么得到了这个实例?”需要理解容器的内部机制。 |
| 样板代码 | 大型依赖图时较多——手动连接 50 个类的应用很繁琐。 | 少——容器遍历依赖树,自动构建一切。 |
| 构建期安全性 | TypeScript 在编译期捕获缺失的构造函数参数。 | 取决于容器;基于 token 的系统可能在运行时因缺少绑定而失败。 |
| 最适合 | 中小型应用,刚接触 DI 的团队,聚焦范围的微服务。 | 有很多 service 的大型应用,想要约定优于配置的团队,以及 DI 是核心的框架(如 NestJS)。 |
容器开始物有所值的阈值因项目而异,但粗略的参考:如果你的组合根感觉本身正在成为一个维护负担——你总是在滚动它、忘记添加新的绑定,或者在与对象生命周期打架——那就是应该考虑容器的信号。在此之前,普通版本更容易理解和调试。
测试收益:注入 fake,得到快速测试
学习 DI 最值得的理由——超越任何架构上的优雅——是它对你的测试套件做的事情。当每个依赖都通过构造函数到来,写测试就意味着写一个用 fake 代替真实 adapter 的微型组合根。没有数据库,没有网络,没有供应商沙箱。测试在毫秒内运行,永远不会因为 staging Stripe 账户触发了速率限制而失败。
// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
receipts: Receipt[] = []
async charge(amount: Money, token: string): Promise<Receipt> {
const r = { id: 'fake-receipt', amount }
this.receipts.push(r)
return r
}
}
class InMemoryOrderRepository implements OrderRepository {
store: Order[] = []
async save(order: Order) { this.store.push(order) }
}
class SpyMailer implements Mailer {
sent: string[] = []
async send(to: string) { this.sent.push(to) }
}
// 测试本身是纯粹的、快速的、确定性的:
it('确认订单并通知客户', async () => {
const payments = new InMemoryPaymentGateway()
const repo = new InMemoryOrderRepository()
const mailer = new SpyMailer()
const svc = new OrderService(payments, repo, mailer)
const order = await svc.placeOrder(testCart)
expect(payments.receipts).toHaveLength(1)
expect(repo.store).toContainEqual(order)
expect(mailer.sent).toContain(testCart.email)
})
这与 Ports & Adapters 的核心洞见是同一个——内存 fake 就是测试时的 adapter,真实的 Stripe 或 Postgres 实现就是生产时的 adapter。DI 是让这种替换成为可能的机制;ports & adapters 是让边界有意图、有明确表达的架构模式。
这里也值得停下来谈谈 SOLID。“D”代表依赖倒置原则:高层模块不应该依赖低层模块;两者都应该依赖抽象。上面的接口(PaymentGateway、OrderRepository、Mailer)就是那些抽象。OrderService 是高层模块。StripePaymentGateway 和 PostgresOrderRepository 是低层模块。两者都不直接知道对方的存在——它们在接口处相遇,组合根把它们扣在一起。
反模式:DI 变坏的那些方式
思想很简单,但有几种经典的方式会让它失效。
Service Locator
Service Locator 是一个全局注册表,代码在运行时通过它检索依赖:
// 反模式:Service Locator——看起来像 DI,但不是
class OrderService {
async placeOrder(cart: Cart) {
const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
const repo = ServiceLocator.get<OrderRepository>('OrderRepository')
// … 继续
}
}
这比内联 new Stripe() 看起来更干净,但隐藏了同样的问题。依赖在签名中仍然不可见。测试必须在运行前配置一个全局注册表。而且类可以在任何时间点请求任何东西——你失去了让 DI 有价值的那种明确性。
过度注入(10 个参数的构造函数)
当一个构造函数积累了七、八或十个参数,通常是该类做了太多事情的信号——而不是 DI 用错了工具。解决方案是把类拆解成更小、更聚焦的协作者,每个都有简短的依赖列表。构造函数长度是一个有用的健康指标。
隐藏的全局容器
在 service 内部直接访问 DI 容器——而不是只在组合根处访问——是换了个名字的 Service Locator 模式。规则很严格:只有组合根与容器对话。service 只与被注入的接口对话。
无法追踪的魔法
有些框架通过装饰器和反射元数据连接依赖,方式上在出错时确实很难追踪。如果你花在阅读容器文档上的时间多于阅读自己的业务逻辑,那就是应该转向更简单方案的信号——或者至少添加一个明文说明主要绑定关系的组合根文档。
按公司规模调整 DI:从个人项目到企业级
像大多数架构思想一样,DI 随着问题的规模而扩展。目标始终相同——可测试、可变更的代码——但随着团队和代码库的增长,实现这一目标的合适机制也会变化。
| 公司阶段 | 推荐方案 | 典型形态 |
|---|---|---|
| 独立开发者 / 早期创业 | 手动 DI,加一个组合根文件。 | 一个 buildApp() 函数;也许 10–20 行。写起来快,读起来易。 |
| 小型团队(5–20 名工程师) | 仍然是手动 DI,可能拆分成若干 domain 模块,每个有自己的连接函数。 | buildOrderModule()、buildBillingModule()——各自独立,在启动时组合一次。 |
| 成长中的规模化公司(20–100 名工程师) | 轻量容器开始物有所值。考虑 tsyringe、InversifyJS,或框架内置的容器。 | 类上的装饰器,每个模块配置一个容器;Web 应用使用请求作用域的 service。 |
| 企业级 / 大型平台 | 完整的 DI 框架几乎是必须的。NestJS、Spring Boot 或 ASP.NET Core 都围绕这一思想构建。 | 约定优于配置,生命周期钩子,模块导入/导出,多租户作用域。 |
一家金融科技初创公司可能从一个 30 行的组合根开始,连接三个 service。两年后,同一代码库有了 60 个 service:手动连接本身变成了一项维护任务,团队开始使用 NestJS 或 InversifyJS 自动管理依赖图。切换很顺滑,因为底层模式——构造函数注入、接口、清晰的组合根——始终不变。容器只是一个机械助手,不是一个不同的思想。
从手动 DI 和组合根开始。任何时候当连接文件成为负担,你都可以引入容器,而且迁移是机械性的:你只是把显式的 new 调用替换为注册。你的 domain、service 或测试中没有任何东西需要改变——只有组合根。
核心要点
- 根本原因:代码刚硬、难以测试的根本原因是一个类对自己的依赖调用
new。解决方案是把依赖传进来。 - Inversion of Control 是原理(由别人负责提供你的依赖)。Dependency Injection 是实现它最明确、最可测试的方式。
- 构造函数注入是默认选择。依赖在签名中可见,在构造时必须提供,在测试中易于替换。
- 组合根是代码库中真实对象被连接在一起的唯一位置。其他一切通过接口对话。
- 手动 DI 对小型代码库更简单、更易调试。DI 容器在组合根本身成为维护负担时才物有所值。
- 测试收益立竿见影:在测试中注入 fake,跳过网络和数据库,在几秒内运行数千个用例。
- 这也是 SOLID 中依赖倒置原则(“D”)的实践:高层模块和低层模块都依赖抽象,永远不直接依赖对方。
- 避免 Service Locator(隐藏的全局注册表)、过度注入(十个参数的构造函数),以及在组合根之外访问容器。
对依赖如何流动有了扎实的理解之后,下一个自然的问题是如何组织文件本身。本系列的下一篇正是探讨这个问题:代码库结构:按功能还是按层?——以及为什么答案取决于你团队的形态,就像取决于你代码的形态一样。