Nguyen Le Phong

软件架构基础第 3 篇,共 6 篇

Dependency Injection 与 Inversion of Control,去掉魔法讲清楚

DI 容器看起来像魔法,直到你看清楚它背后的朴素思想:不要让代码自己创建依赖,而是从外部传进来。一篇从零开始的指南,配上真实示例和测试收益。

每个开发者都会在某个时刻碰到同一堵墙。你想为一段代码写测试,却发现代码在内部偷偷调用了 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 在内部调用 new() PostgresClient Stripe SDK SendGrid SDK 依赖在类内部创建——无法测试 改造后——组合根向内注入 Composition Root 调用 new()——仅在此处 OrderService 只接受接口 Postgres Stripe 依赖向内流入——易于替换,易于测试
左侧,OrderService 向外伸手构造自己的依赖——每条箭头都是嵌入类体中的一处耦合。右侧,组合根构造具体对象并将它们向内注入。箭头方向反转了。service 只知道接口;具体类型是边缘处的细节。
问题手动 / Pure DIDI 容器(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”代表依赖倒置原则:高层模块不应该依赖低层模块;两者都应该依赖抽象。上面的接口(PaymentGatewayOrderRepositoryMailer)就是那些抽象。OrderService 是高层模块。StripePaymentGatewayPostgresOrderRepository 是低层模块。两者都不直接知道对方的存在——它们在接口处相遇,组合根把它们扣在一起。

反模式: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 名工程师) 轻量容器开始物有所值。考虑 tsyringeInversifyJS,或框架内置的容器。 类上的装饰器,每个模块配置一个容器;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(隐藏的全局注册表)、过度注入(十个参数的构造函数),以及在组合根之外访问容器。

对依赖如何流动有了扎实的理解之后,下一个自然的问题是如何组织文件本身。本系列的下一篇正是探讨这个问题:代码库结构:按功能还是按层?——以及为什么答案取决于你团队的形态,就像取决于你代码的形态一样。