1 分钟

依赖注入如何提高可测试性与模块化

了解依赖注入如何让代码更易测试、重构和扩展。探索实用模式、示例以及常见陷阱和规避方法。

依赖注入如何提高可测试性与模块化

依赖注入是什么意思(不讲行话)

依赖注入(DI)是一个简单的想法:不要让一段代码去创建它需要的东西,而是从外面把这些东西给它

这些“它需要的东西”就是它的依赖——例如数据库连接、支付服务、时钟、日志器或邮件发送器。如果你的代码自己去构建这些依赖,就会悄悄锁定这些依赖的实现方式

一个现实世界的类比

想象办公室里的咖啡机。它依赖水、咖啡豆和电力。

  • 如果这台机器只接受一种专有的水盒且自行购买,你就被供应商绑定了。
  • 如果机器接受任何标准的水源,其他人可以选择自来水、过滤水或瓶装水,而不必改机器。

DI 就是第二种方式:“咖啡机”(你的类/函数)只专注于做咖啡(它的职责),而“耗材”(依赖)由设置它的人来提供。

DI 不是什么

DI 不是必须使用某个特定框架,也不是等同于 DI 容器。你可以手动通过参数(或构造函数)传入依赖来实现 DI。

DI 也不是“mock(模拟)”。模拟只是测试中利用 DI 的一种方式,但 DI 本身只是关于在哪里创建依赖的设计选择。

为什么可测试性和模块化会一起提升

当依赖从外部提供时,你的代码在不同上下文下运行变得容易:生产环境、单元测试、演示或未来功能。

这种灵活性也让模块更干净:可以在不重写整个系统的情况下替换某个部分。结果是测试更快、更清晰(因为可以替换为简单的替身),代码库也更容易更改(因为各部分耦合更少)。

核心问题:紧耦合让变更变难

紧耦合发生在代码的一部分直接决定它必须使用哪些其他部分时。最常见的形式很简单:在业务逻辑里直接调用 new

直接实例化如何造成隐藏耦合

想象一个结账函数内部做了 new StripeClient()new SmtpEmailSender()。一开始看起来方便——所需的一切都在这里。但这也把结账流程锁定到这些具体实现、它们的配置细节,甚至创建规则(API 密钥、超时、网络行为)。

这种耦合是“隐藏的”,因为它不在方法签名里显现。函数看起来只是处理订单,但它实际上依赖支付网关、邮件提供商,可能还有数据库连接。

难以替换的依赖为何放慢变更速度

当依赖被硬编码时,小改动也会引起连锁反应:

  • 更换供应商(Stripe → Adyen)需要修改业务逻辑而不是替换组件。
  • 添加缓存、重试或日志会迫使你在许多调用点传递这些关注点。
  • 升级库可能变成大规模重构,因为创建逻辑散落在各处。

紧耦合会表现为测试慢或不稳定

硬编码的依赖会让单元测试执行真实工作:网络调用、文件 I/O、时钟、随机 ID 或共享资源。测试变慢因为不隔离,变得不稳定因为结果依赖时序、外部服务或执行顺序。

值得注意的痛点信号

如果你看到这些模式,紧耦合可能已经在消耗你的时间:

  • 使用全局状态作为隐式依赖
  • 难以在测试间重置的单例
  • 在核心逻辑里到处 new
  • 无法在没有数据库、Web 服务器或真实 API 密钥的情况下测试的代码

依赖注入通过让依赖显式且可替换来解决这些问题——不需要每次世界变化都重写业务规则。

控制反转:把“做什么”和“如何得到”分离

控制反转(IoC)是一个职责的简单转移:类应该关注它需要做什么,而不是如何获得它需要的东西

当一个类自行创建依赖(例如 new EmailService() 或直接打开数据库连接)时,它无形中承担了两项任务:业务逻辑和构建/设置。这让类更难更改、更难重用、更难测试。

依赖抽象而非具体类

有了 IoC,你的代码依赖于抽象——像接口或小的“契约”类型——而不是具体实现。

例如,CheckoutService 不需要知道支付是通过 Stripe、PayPal 还是一个测试假实现处理的。它只需要“能扣款的东西”。如果 CheckoutService 接受一个 IPaymentProcessor,只要实现遵循该契约,它就能工作。

这样即便底层工具改变,你的核心逻辑也保持稳定。

把创建移动到类的外部

IoC 的实际做法是把依赖的创建移出类并把它们传入(通常通过构造函数)。这正是依赖注入(DI)常见的实现方式。

替代:

  • 类选择并构建其协作者 → 变为 → 类从外部接收协作者

结果是灵活性:行为的替换成为配置决策,而不是重写。

“组合根”:装配发生的地方

如果类不创建依赖,必须有别处来做这件事。这个“别处”就是组合根:应用被组装的地方——通常在启动代码中。

组合根决定:“生产环境使用 RealPaymentProcessor;测试使用 FakePaymentProcessor。”把装配集中在一处可以减少意外,让代码库的其余部分专注于行为。

这对测试和重构的重要性

IoC 让单元测试更简单,因为你可以提供小而快速的测试替身,而不是调用真实网络或数据库。

它也让重构更安全:当责任分离时,只要抽象不变,改变实现往往不需要改动使用它的类。

常见的 DI 风格以及何时使用

依赖注入并不是一种技术——它是一组把依赖“喂给”类的方法(比如日志、数据库客户端或支付网关)。你选的风格影响代码的清晰度、可测试性和被滥用的风险。

构造函数注入(默认)

通过构造函数注入,依赖在创建对象时就是必须的。大优点是:你不可能忘记它们。

适用于依赖:

  • 对象工作所必需
  • 在多个方法间共享
  • 需要早期校验(例如不应为 null/undefined)

构造函数注入往往产生最清晰的代码和最直接的单元测试,因为测试可以在创建时传入假对象或 mock。

参数 / 方法注入(用于一次性工作)

有时依赖只在某个操作中被需要——例如临时格式化器、特殊策略或请求作用域的值。

这种情况下把它作为方法参数传入。这样可以让对象更小,避免把一次性需求提升为永久字段。

属性 / setter 注入(谨慎使用)

当确实无法在构造时提供依赖(某些框架或遗留代码路径)时,setter 注入很方便。但其代价是它会隐藏要求:类看起来可用,即便没有完全配置好。

这常常导致运行时惊讶(“为什么这是 undefined?”)并使测试更脆弱,因为容易漏掉设置步骤。

一个简单经验法则

  • 如果类没有它就无法工作:使用 构造函数注入
  • 如果只是单次调用或变体行为:使用 方法/参数注入
  • 如果必须支持晚期装配:使用 setter 注入,但加上保障(清晰文档、校验或快速失败检查)。

DI 如何改善单元测试(速度、隔离、清晰)

绘制你的组合根
使用规划模式在编码前勾勒模块接缝、接口和组合根。

当单元测试依赖真实数据库、网络调用、文件系统或时钟时,它们往往变慢并变得不稳定。更糟的是,失败信息不再直接:是代码坏了,还是环境出现问题?

DI 通过让代码接受外部依赖(数据库访问、HTTP 客户端、时间提供者)来解决这一点。在测试中,你可以把这些依赖替换为轻量的替身。

速度:让测试留在内存中

真实的 DB 或 API 调用会增加设置时间与延迟。有了 DI,你可以注入一个内存仓库或一个返回预设响应的假客户端。那意味着:

  • 在相同时间预算内能跑更多测试
  • 你更可能频繁运行它们
  • CI 管道保持快速

隔离:一次只测一件事

没有 DI 的代码常常“new()”它自己的依赖,迫使测试去执行整个栈。有了 DI,你可以注入:

  • mocks 验证交互(例如“发了一封邮件”)
  • stubs 返回特定值(例如“用户存在”)
  • fakes 提供简单可用的行为(例如内存存储)

无需黑魔法或全局开关——只需传入不同实现。

清晰:更简单的 Arrange–Act–Assert

DI 让设置变得显式。你不必去翻配置、连接字符串或测试专用环境变量;直接读一个测试就能看到什么是真实的、什么是替换的。

一个典型的 DI 友好测试读起来像:

  1. Arrange:使用假仓库和存根时钟创建服务

  2. Act:调用方法

  3. Assert:检查返回值和/或验证 mock 的交互

这种直接性减少了噪音,让失败更容易诊断——正是单元测试所要的。

测试缝:有意让行为可替换

测试缝是你在代码中刻意保留的“开口”,用来交换行为。在生产中插入真实实现;在测试中插入更安全、更快的替代。

缝通常放在哪里

缝对难以在测试中控制的系统部分最有用:

  • 时间(当前日期/时间不断变化)
  • 文件系统(慢、权限、清理)
  • 邮件/SMS(副作用、外部服务)
  • 支付网关(真实资金、网络故障)

如果业务逻辑直接调用这些东西,测试就会脆弱:失败常常由与业务逻辑无关的原因引起(网络抖动、时区差、缺文件),并且难以快速运行。

接口(或契约)把缝变成简单的选择

缝常以接口形式存在——在动态语言中可能只是一个“契约”,比如“这个对象必须有一个 now() 方法”。关键是依赖于所需的行为,而不是它来自哪里

例如,不在订单服务中直接调用系统时钟,而是依赖一个 Clock

  • 生产:SystemClock.now()
  • 测试:FakeClock.now() 返回固定时间

同样模式也适用于文件读取(FileStore)、发送邮件(Mailer)或扣款(PaymentGateway)。核心逻辑保持不变,只有插入的实现不同。

为什么缝能带来更好的测试

当你可以有意替换行为时:

  • 测试不那么脆弱:不依赖真实时间、真实网络或共享机器状态。
  • 更容易覆盖边界情况:可以模拟“支付被拒绝”、“邮件提供商超时”或“月末日期”而无需复杂设置。
  • 失败更明确:测试失败通常意味着业务规则有问题,而不是环境问题。

放好缝后你不必到处 mock;只需少数几个干净的替换点,就能保持单元测试快速、聚焦、可预测。

DI 如何促成更模块化的代码

模块化意味着软件由独立部分(模块)组成,边界清晰:每个模块有明确职责和与外界交互的方式。

DI 通过让这些边界显式化来支持模块化。模块不去“构造”或“查找”它所需的一切,而是从外部接收依赖。这个小改变减少了模块之间的认知耦合。

设计上降低耦合度

当代码内部构造依赖(例如在服务中 new 一个数据库客户端),调用者与依赖紧密耦合。DI 鼓励你依赖接口(或简单契约),而非具体实现。

这意味着模块通常只需要知道:

  • 它需要什么(例如 PaymentGateway.charge()
  • 不需要知道如何实现(Stripe、PayPal 还是沙箱)

因此模块一起改变的频率降低,因为内部细节不再泄漏到边界之外。

在不重写调用方的情况下替换组件

模块化的代码库应该允许你替换某个组件而不重写所有使用它的代码。DI 让这变得可行:

  • 用队列发送器替换真实邮件发送器
  • 把基于文件的仓库换成数据库支持的实现
  • 在现有服务外面引入缓存装饰器

在每种情况下,调用方继续使用相同契约,只有在组合根的装配发生变化。

更容易的并行团队协作

清晰的依赖边界使团队并行工作更容易。一个团队可以在约定的接口后面构建新实现,而另一个团队继续开发依赖该接口的功能。

DI 还支持增量重构:你可以抽取模块、注入它并逐步替换——无需一次性大范围重写。

一个简单的前后对比示例

选择适合的方案
选择 Free、Pro、Business 或 Enterprise,以匹配你希望推进构建的深度。

看到代码中的依赖注入比任何定义都更直观。下面用通知功能展示一个小的“前后对比”。

之前:类自己创建依赖

当类在内部调用 new 时,它决定了要使用哪个实现以及如何构建它。

class EmailService {
  send(to, message) {
    // talks to real SMTP provider
  }
}

class WelcomeNotifier {
  notify(user) {
    const email = new EmailService();
    email.send(user.email, "Welcome!");
  }
}

测试痛点: 单元测试可能触发真实的邮件行为(或需要尴尬的全局补丁)。

test("sends welcome email", () => {
  const notifier = new WelcomeNotifier();
  notifier.notify({ email: "[email protected]" });
  // Hard to assert without patching EmailService globally
});

之后:注入依赖

现在 WelcomeNotifier 接受任何符合所需行为的对象。

class WelcomeNotifier {
  constructor(emailService) {
    this.emailService = emailService;
  }

  notify(user) {
    this.emailService.send(user.email, "Welcome!");
  }
}

测试变得小、快且显式。

test("sends welcome email", () => {
  const fakeEmail = { send: vi.fn() };
  const notifier = new WelcomeNotifier(fakeEmail);

  notifier.notify({ email: "[email protected]" });

  expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});

添加新实现更简单

想要之后支持 SMS?你不用改 WelcomeNotifier。只需传入不同的实现:

const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);

实际收益就是:测试不再与构建细节作斗争,新增行为通过替换依赖而不是重写现有代码来完成。

手动 DI 与 DI 容器:选择合适的自动化程度

依赖注入可以简单到“把你需要的东西传入使用它的东西”。那是手动 DI。DI 容器是一种自动化布线的工具。两者都有用——关键是选择与应用相匹配的自动化水平。

手动布线:显式且易于理解

使用手动 DI,你自己创建对象并通过构造函数(或参数)传递依赖。优点:

  • 可以明确看到创建发生在哪里。
  • 出错时没有隐形的魔法。
  • 适合小应用、脚本、少量组件的服务和早期重构。

手动布线还会强迫良好设计:如果一个对象需要七个依赖,你会立刻感到痛——这通常提示需要拆分责任。

DI 容器:减少样板、管理生命周期

随着组件数量增多,手动布线会变成重复的“管道”工作。DI 容器可以通过以下方式帮助:

  • 自动构建对象图。
  • 管理生命周期(单例 vs 请求级 vs 瞬态)。
  • 集中注册(例如在特定环境中将真实服务替换为测试替身)。

容器在边界和生命周期清晰的应用中表现优秀——比如 Web 应用、长运行服务或许多功能依赖共享基础设施的系统。

别让容器掩盖设计问题

容器可能让高度耦合的设计看起来很整洁,因为布线消失了,但潜在问题仍存在:

  • 每个类的依赖太多。
  • 所有权不明确(谁负责创建/释放资源?)。
  • “服务定位器”模式使依赖不可见、测试更难。

如果引入容器让代码可读性下降,或者开发者不再清楚谁依赖谁,那就可能过度使用了。

可扩展的平衡方案

刚开始用手动 DI 来保持显式性,在你划分模块时加深理解。当布线变得重复或生命周期管理变得棘手时,再引入容器。

一个实用规则:在核心/业务代码中使用手动 DI,在应用边界(组合根)可选地使用容器来组装一切。这样既保持设计清晰,又在项目增长时减少样板。

常见陷阱(以及如何避免)

让依赖可替换
创建清晰接口,在不触及业务逻辑的情况下替换实现。

DI 可以让代码更易测试和更易变更——但前提是有纪律地使用。以下是常见的失败方式和可行的习惯。

过度注入(“12 参数构造函数”问题)

如果一个类需要很长的依赖列表,往往说明它做了太多。这不是 DI 的失败,而是 DI 暴露了设计异味。

经验法:如果不能用一句话描述类的职责,或者构造函数不断长大,考虑拆分类、抽取更小的协作者,或将相关操作封装到一个接口后面(谨慎——别造“上帝服务”)。

服务定位器:把依赖隐藏起来的 DI

服务定位器模式看起来像在业务代码里调用 container.get(Foo)。表面上便利,但它让依赖不可见:无法通过构造函数判断类需要什么。

测试变得更难,因为你必须配置全局状态(定位器)而不是显式地传入一组替身。优先使用显式传参(构造函数注入最直接)让测试能有意地构建对象图。

隐藏的运行时失败:缺少注册和循环依赖

DI 容器在运行时可能出错:

  • 某个依赖没有注册
  • 注册在当前环境选择了错误实现
  • 两个服务互相依赖(直接或间接)造成循环

这些问题令人沮丧,因为它们只在装配时出现。

实用的缓解方法

保持构造函数短而聚焦。如果依赖列表增长,把它视为重构提示。

为装配添加集成测试。即使是一个轻量的“组合根”测试,能在早期发现缺失注册和循环依赖。

最后,把对象创建集中在一个地方(通常是应用启动/组合根),并把 DI 容器调用从业务逻辑中剥离。这样就能保留 DI 的主要收益:清晰地知道谁依赖谁。

在现有代码库中引入 DI 的实操步骤

把 DI 当成一系列小的、低风险的重构来采用。从测试慢或不稳定、变更经常波及无关代码的地方开始。

一个快速清单:哪里先用 DI 回报最高

寻找让代码难测或难以理解的依赖:

  • I/O: 文件系统访问、数据库调用、网络请求
  • 时间: “现在”时刻、时区、延时、调度器
  • 随机性: UUID、随机数、洗牌
  • 外部 API/SDK: 支付、邮件、分析、功能开关

如果一个函数不能在进程外部依赖下运行,通常就是好候选。

可重复的逐步重构

  1. 选一个缝: 选择一个当前代码里直接 new 或直接调用的外部依赖。
  2. 抽出接口(或简单契约): 定义你实际需要的行为(通常只有 1–3 个方法)。保持精简。
  3. 创建真实实现: 把现有具体依赖包装到该接口后面。
  4. 注入它: 通过构造函数参数或函数参数传入。偏好最简单合适的方式。
  5. 更新生产装配: 在应用入口(工厂或组合根)创建真实实现并传入。
  6. 更新测试: 用假实现/存根/模拟替换真实实现,返回可预测值。

该方法使每次变更都可审查,并允许你在任一步骤停止而不破坏系统。

在注入依赖时保持模块内聚

DI 可能无意中把代码变成“所有东西都相互依赖”。

一个好规则:注入能力,而不是细节。例如注入 Clock,而不是注入“SystemTime + TimeZoneResolver + NtpClient”。如果一个类需要五个彼此无关的服务,可能职责划分错了——考虑拆分。

此外,避免把依赖“传来传去”地穿透多层,只在实际使用处注入;把装配集中在一处。

关于通过生成器或脚手架生成应用时的 DI

如果使用代码生成器或“快速上手”工作流来快速生成特性,DI 会更有价值,因为它能在项目增长时保持结构。例如,当团队用 Koder.ai 从聊天驱动的规范创建 React 前端、Go 服务和 PostgreSQL 后端时,保持清晰的组合根和 DI 友好的接口有助于确保生成的代码继续易于测试、重构并能替换集成(邮件、支付、存储),而无需重写核心业务逻辑。

原则依旧:把对象创建和环境相关的装配放在边界处,让业务代码专注于行为。

变更后的衡量指标

你应该能指出具体的改善:

  • 单元测试更快(更少等待 DB/网络/时间)
  • 测试更隔离(更少全局设置和共享状态)
  • 边界更清晰(模块间契约明显)
  • 更容易变更(以最少修改替换 API 客户端或存储策略)

下一步建议:记录你的“组合根”,并保持它简单:一个文件负责编排依赖,而其余代码专注行为。

常见问题

用通俗的话说,什么是依赖注入?

依赖注入(DI)意味着你的代码从外部接收它需要的东西(数据库、日志、时钟、支付客户端),而不是在内部创建它们。

在实践中,这通常表现为把依赖通过构造函数或函数参数传入,这样它们就是显性的且可替换的。

依赖注入与控制反转(IoC)有什么区别?

控制反转(IoC)是一个更广泛的概念:类应该专注于它要做什么,而不是如何获取它的协作者

依赖注入是实现 IoC 的常用技术,通过把依赖的创建移到外部并传入来达成这一点。

为什么在业务逻辑中调用 `new` 会导致紧耦合?

如果在业务逻辑里用 new 创建依赖,就很难替换该依赖。

这会导致:

  • 被供应商绑定(例如结账流程里把 Stripe 固定进去)
  • 配置与创建规则分散在各处
  • 单元测试变慢且不稳定,因为会触发真实的 I/O(网络/数据库/文件/时间)
依赖注入如何让单元测试更快、更少抖动?

DI 让测试保持快速且确定性强,因为可以注入测试替身而不是使用真实的外部系统。

常见的替换有:

  • 用内存中的仓库代替真实数据库
  • 用固定的/可控的时钟替代系统时钟
  • 用被 mock 的邮递者代替发送真实邮件
使用 DI 必须要有 DI 容器吗?

DI 容器是可选的。开始时使用手动 DI(显式传依赖)适合以下情况:

  • 应用规模小/中等
  • 对象图可以手工组装
  • 希望最大程度的清晰度

当布线变得重复或需要生命周期管理(单例/每次请求/瞬态)时,可以考虑引入容器。

什么时候该用构造函数注入、方法注入或 setter 注入?

当依赖是对象能正常工作的必须条件且跨多个方法使用时,优先使用构造函数注入

当依赖只用于一次调用或请求域值时,使用方法/参数注入

除非确实需要晚期装配,否则尽量避免setter/属性注入;如果必须使用,记得加上快速失败的校验。

什么是“组合根”,应该放在哪?

组合根是你组装应用的地方:在这里创建具体实现并把它们传给需要的服务。

把它放在应用启动点附近(入口文件),这样其余代码只关注行为,不关心装配细节。

什么是测试缝,应该在哪里创建?

测试缝(test seam)是一个刻意设计的点,用来交换行为。

适合放缝的地方通常是难以在测试中控制的部分:

  • 时间(Clock.now()
  • I/O(文件存储、HTTP 客户端)
  • 外部服务(支付、邮件)

DI 通过允许在测试中注入替代实现来创建这些缝。

常见的 DI 错误有哪些,如何避免?

常见错误包括:

  • 过度注入:构造函数参数太多通常意味着类职责过多——拆分或抽取协作者。
  • 服务定位器(Service Locator):在业务代码里调用 container.get() 会隐藏真实依赖,优先使用显式参数。
  • 运行时装配失败:缺少注册或循环依赖,建议增加一次能构建整个应用图的集成/装配测试来提前发现问题。
如何在现有代码库中安全地引入 DI?

可以按小步骤安全引入 DI:

  1. 选择一个难以测试的依赖(DB、时钟、HTTP 客户端)。
  2. 定义一个只包含所需方法的小接口/契约。
  3. 用当前实现包装该接口。
  4. 通过构造函数或参数注入它。
  5. 在启动处更新真实的装配(组合根)。
  6. 在测试中用假实现/存根/模拟替换。

这个过程可重复对下一个缝点做同样改造,中途暂停不会破坏系统。

Related posts