软件设计

什么是依赖注入(DI)?从把 new 移到类外开始(示例·类比总结)

代码中需要某个对象时,很多人会直接在使用处创建它。但一写测试就会碰壁,因为创建代码紧紧嵌在类里,根本无法替换。

4 分钟阅读
什么是依赖注入(DI)?从把 new 移到类外开始(示例·类比总结) 封面图

代码中需要某个对象时,很多人会直接在使用处创建它。但一写测试就会碰壁,因为创建代码紧紧嵌在类里,根本无法替换。

即使搜索依赖注入(DI),看到的也常常只是“控制反转、IoC 容器”之类的说法,反而更容易困惑。

所以今天我们尽量用简单易懂的方式来说明。先说结论。

依赖注入(DI)归根结底,就是把原本在类内部直接创建的对象,改为在外部创建后传入。核心就是这一个移动:把对象的创建位置移到类外。DI 几乎全部的内容就是如此。

读完这篇文章,你会理解为什么需要 DI、代码会如何变化,以及为什么 Swinject 这样的 DI 库能替你完成这件事。

对象在类内部创建,会有什么问题?

假设有一个处理订单的类。这个类需要支付模块来完成支付。

最常见的新手代码大概是这样。

class OrderService {
    // 在类内部直接创建支付对象
    private let pay = KakaoPay()

    func order() { pay.pay() }
}

问题在于,OrderService 被紧紧绑定到了 KakaoPay。

如果想把 KakaoPay 换成 Naver Pay,就得进入类内部修改创建部分。

测试时即使想传入假的支付对象也没办法,因为它已经在内部创建了真实的 KakaoPay。

也就是说,把对象创建放在类内部,会让这个类被某个具体实现牢牢束缚。


所以我们把对象创建移到了外部

解决方法比想象中简单。不必自己创建,只要在外部创建后接收即可。

class OrderService {
    private let pay: Pay
    // 只接收外部创建并传入的对象
    init(pay: Pay) { self.pay = pay }

    func order() { pay.pay() }
}

变化只有一个:直接创建消失了,改为通过构造器(初始化器)接收。

现在 OrderService 不需要知道支付方式是 KakaoPay 还是 Naver Pay,只需知道“接收一个叫 Pay 的东西并使用它”。

DI 的本质并不复杂。只是把对象创建移到类外,将决定传入什么对象的权限交给外部。

这种从外部传入对象的做法称为“注入(injection)”。注入依赖的对象,所以叫依赖注入。

只是名字听起来复杂,实际作用就是刚才看到的全部内容。


使用 DI 有什么好处?

光说有好处不够直观,所以我们整理一下实际发生了哪些变化。

  • 更容易替换:即使把 KakaoPay 换成 Naver Pay,OrderService 也无需修改一行代码,只要更换传入的对象即可。
  • 更方便测试:传入假的(Mock)支付对象,既不会真的扣款,又能只验证业务逻辑。
  • 职责得到分离:OrderService 只专注于“处理订单”,“使用哪种支付方式”由外部决定。

第三点最让我有感触。类只专注于自己的工作后,代码确实更容易阅读了。

总结如下。

分类 在内部创建时 将创建移到外部时(DI)
替换支付方式 需要修改类 只需替换传入的对象
测试 无法传入假对象 可以注入 Mock
关注点 创建与逻辑混杂 只专注于逻辑
把 new 移到外部后,代码就会变得如此简洁
把 new 移到外部后,代码就会变得如此简洁

那么,为什么需要 Swinject 这样的 DI 库?

读到这里自然会产生一个疑问:“我明白要从外部传入了,但这个‘外部’由谁管理呢?”

对象不多时,手动创建并传入即可。但实际项目中往往有数百个对象,而且彼此交织。

让人逐个按照顺序创建并传入,简直就是地狱。

于是,替你完成这种“从外部创建并传入对象”的工具出现了。Swift 生态中的 Swinject 和 Factory 等 DI 容器,正是承担这一职责的工具。

容器负责创建,再插入需要它的地方
容器负责创建,再插入需要它的地方
对象达到数百个后,你会庆幸有容器替你统一管理
对象达到数百个后,你会庆幸有容器替你统一管理

注册好所需的对象后,容器会自动创建它们并传入需要的位置。需要亲自创建的事情几乎消失了。

常说的“控制反转(IoC)”也由此而来。它表示创建并连接对象的控制权,已经从你的代码转移到了容器。

换句话说,DI 是一种概念,而 DI 容器则是代替你执行这一概念的工作人员。


常见问题

Q. DI 和 IoC 是同一个概念吗? 不是。IoC(控制反转)是“交出控制权”这一更宽泛的原则,而 DI 是实现它的具体方法之一。可以把 DI 看作 IoC 的一种。

Q. 除了构造器,还有其他注入方式吗? 有。包括构造器注入、属性注入和方法注入。不过在 Swift 中推荐使用构造器注入,因为依赖可以固定为 let,值不会变化,也更方便测试。

Q. 必须使用库才算 DI 吗? 不是。刚才手动传入构造器的做法也是正统的 DI。库只是帮你把它自动化了。


今天我们把依赖注入,解释成对象创建位置的一次小小移动。

如果 DI 让你觉得难,只要记住一句话:“不要在类内部创建,而要在外部创建后传入。”记住这句话,其他内容自然就能理解。

如果你曾在测试代码面前感到无从下手,建议亲自动手改一遍今天看到的示例。这样会比只看代码理解得更加扎实。

延伸阅读