依赖注入实战:写好可测试、可替换的代码
依赖注入不是框架特性,而是一个习惯:把依赖从「内部创建」改成「外部传入」。这一个改动,决定了你的代码能不能被独立测试、能不能在不改源码的前提下替换实现。
一、从一个坏习惯说起:依赖在内部创建
看一段很常见的代码:函数内部直接创建数据库连接和邮件客户端。它的三个问题也正是依赖注入要解决的三个问题:测试要连真数据库(慢且不稳)、想换邮件服务必须改源码(实现与使用耦合)、依赖是隐形的(看签名完全不知道它依赖什么)。
// ✗ 依赖在内部 new:无法替换、无法隔离测试
function processOrder(order) {
const db = new MySQLConnection(); // 硬编码
const mailer = new SmtpMailer(); // 换服务要改这里
...
}
// ✓ 依赖从外部传入:显式、可替换、可测试
function processOrder(order, deps) {
deps.db.save(order);
deps.mailer.send(order.email, "已下单");
}
改动其实很小:把「创建」从函数里挪出去,只留「使用」。创建放到哪里?放到离程序入口最近的地方(后面第四节的"组合根")。这就是依赖注入的全部核心——框架只是帮你把组装做得更省事,不是必需品。
依赖注入的本质:谁使用,谁声明;谁组装,谁创建——使用与创建分离。
二、三种注入形式:按场景选
| 形式 | 做法 | 适用 |
|---|---|---|
| 构造器注入 | 依赖在对象创建时一次性传入 | 首选:对象创建后状态完整、依赖关系清晰 |
| 方法参数注入 | 需要时才作为参数传入 | 一次性依赖,如日志器、请求上下文 |
| 属性 / 容器注入 | 框架在运行时赋值 | 框架场景,尽量少用:依赖关系不再显式 |
选择标准一句话:依赖是"这个对象活着的必需品"就用构造器注入;只影响某一次调用就用参数传入。运行时注入能不用就不用——它会让"这个类到底依赖什么"重新变得不可见。
三、什么时候需要 DI:答案比想象的少
- 需要测试替身时:数据库、HTTP 客户端、消息队列——凡是要在测试里替换的(即"接缝"),都值得注入
- 需要在多实现间切换时:多个支付渠道、多家短信服务——依赖指向接口,实现外部决定
- 不需要时:无副作用的小工具(uuid、格式化函数)直接调用即可——把它们也注入一遍,是常见的过度设计
一条简单判据:只注入"接缝"处的依赖——IO、时间、随机、第三方。纯函数和值对象不是接缝。
四、手写组合根:不引框架也能优雅装配
依赖在哪里创建?答案:在程序入口集中组装一次,然后一路传下去——这个位置叫组合根(composition root)。它让"用哪些实现"成为一个可一眼看懂的地方:
// composition root(示意):程序入口集中装配
const deps = {
db: new MySQLConnection(env.databaseUrl),
mailer: env.mailer === "ses" ? new SesMailer() : new SmtpMailer(),
clock: () => new Date(),
};
server.start((req) => processOrder(req.order, deps));
什么时候才需要依赖注入容器?当模块数量多、装配样板重复到影响效率时——而即便如此,很多团队用几行工厂函数就足够了。容器是手段不是目标:如果引入容器后"谁依赖谁"只能运行时才知道,那是理解成本的负收益。
五、DI 与测试的化学反应
- 用内存实现替换数据库:测试跑在毫秒级,不再依赖外部服务(呼应单元测试一篇的 Fake 策略)
- 用假时钟控制时间:注入一个可控的"现在",时间边界(跨天、过期)全部可测、可重复
- 按需构造依赖:每个用例只装配它关心的两个依赖,而不是启动整个世界
六、可替换性:面向"我需要的形状"
注入的时候,类型写在什么上决定了可替换性:依赖指向"我需要的形状"(接口/协议),而不是某个具体实现。两条纪律:接口最小化——只声明用到的能力(比如只要 Send,就不要把整个邮件服务的十个方法都搬进接口);第三方 SDK 挡在适配器后面——核心代码不出现厂商类型,换供应商只写一个新适配器(呼应设计模式一篇的适配器思想)。
七、五个常见坑
- 服务定位器反模式:到处 container.get("db")——依赖重新变隐形,测试仍难写;"从全局拿"与"从内部 new"的问题是同一类
- 容器滥用:注册上百个服务,运行时才拼出依赖图——静态不可读,出错才知道
- 接口膨胀:每个类都造一个接口,却没有第二个实现——多出来的间接层只增加维护面
- 构造函数依赖爆炸:一个类注入十个依赖——不是 DI 的问题,是这个类职责过重(呼应单元测试篇"mock 五个依赖 = 设计信号")
- 把 DI 当目的:连格式化函数都注入——为"架构好看"牺牲了代码直觉
速查卡
| 场景 | 做法 |
|---|---|
| 函数内部 new 了外部资源 | 改为参数传入——使用与创建分离 |
| 依赖活一整轮 | 构造器注入 |
| 依赖只用一次 | 方法参数传入(如日志器) |
| 在哪里创建实现 | 组合根:入口集中装配,一路传下去 |
| 测试要隔离外部 | 注入内存实现 / 假时钟,不 mock 业务对象 |
| 第三方 SDK 侵入核心 | 包一层适配器,接口只留需要的能力 |
写在最后
依赖注入值得记住的只有两句话:使用与创建分离、组装集中在入口。做到这两点,你的代码就能在没有数据库、没有网络、没有真实时钟的环境里被完整测试,也能在不碰业务逻辑的前提下替换任何外部实现——这正是一套代码"活得久"的底子。
评论(0)