软件设计

[SOLID #2] SOLID 原则实战篇(下):ISP・DIP,以及依赖注入存在的原因

上一篇介绍了 SOLID 的前三个字母 SRP・OCP・LSP,今天来整理剩下的两个字母。

3 分钟阅读
[SOLID #2] SOLID 原则实战篇(下):ISP・DIP,以及依赖注入存在的原因 封面图

上一篇介绍了 SOLID 的前三个字母 SRP・OCP・LSP,今天来整理剩下的两个字母。

它们是 ISP(接口隔离原则)和 DIP(依赖倒置原则)。

尤其是 DIP,它是使用 Swinject 或 Factory 等 Swift DI 库时必然会遇到的“依赖注入(DI)”理论基础。读完这一篇,你就会明白这些库为什么会设计成现在这样。


ISP:接口隔离原则

先从定义开始。

不应强迫客户端依赖自己不使用的方法。

这句话听起来有些抽象,但看一个实际问题就能马上理解。假设我们创建了一个多功能打印机协议。

protocol Machine {
    func print(_ doc: Document)
    func scan(_ doc: Document)
    func fax(_ doc: Document)
}

// 旧式打印机只能打印...
class OldPrinter: Machine {
    func print(_ doc: Document) { /* 正常工作 */ }
    func scan(_ doc: Document) { fatalError("无法扫描") }  // 强行实现
    func fax(_ doc: Document) { fatalError("无法传真") }   // 强行实现
}

这相当于强迫一台只能打印的打印机实现扫描和传真。最终会出现只抛出异常的伪方法,而这也违反了上一篇介绍的 LSP。臃肿的接口就这样连续破坏了两个原则。

解决方法很简单:按职责拆分接口。

protocol Printer { func print(_ doc: Document) }
protocol Scanner { func scan(_ doc: Document) }
protocol Fax { func fax(_ doc: Document) }

class OldPrinter: Printer { ... }                      // 仅打印
class MultiFunction: Printer, Scanner, Fax { ... }     // 全功能多功能打印机

各自只承诺自己能做到的事情。在实际开发中,通常会出现这样的信号:实现协议时产生了“这个方法和我们没关系……”的空实现,那就说明该拆分协议了。


DIP:依赖倒置原则

这是一个因为名称而经常被误解的原则。定义只有两行。

高层模块不应依赖低层模块。两者都应依赖抽象。

抽象不应依赖细节。细节应依赖抽象。

我们用代码来看。假设订单服务直接使用电子邮件发送库。

// 高层模块(业务逻辑)直接依赖低层模块(实现细节)
import SendGrid

class OrderService {
    private let mailer = SendGridClient(apiKey: apiKey)

    func completeOrder(_ order: Order) {
        // 订单处理...
        mailer.send(to: order.email, message: "订单完成")  // SendGrid被绑定
    }
}

这种结构有两个问题。要把 SendGrid 换成其他服务,就必须修改业务逻辑,而且每次测试都会真正发出邮件。

应用 DIP 后,箭头的方向会反转。

// 抽象由高层模块(订单领域)定义
protocol NotificationSender {
    func send(to: String, message: String) async throws
}

class OrderService {
    private let sender: NotificationSender

    init(sender: NotificationSender) {  // 只依赖抽象
        self.sender = sender
    }

    func completeOrder(_ order: Order) async throws {
        try await sender.send(to: order.email, message: "订单完成")
    }
}

// 细节(SendGrid)遵循抽象
class SendGridSender: NotificationSender { ... }
class SlackSender: NotificationSender { ... }
class FakeSender: NotificationSender { ... }  // 测试用

原本指向“订单服务 → SendGrid”的依赖关系,现在变成了“订单服务 → 协议 ← SendGrid”。由于细节向领域低头,因此称为“倒置”。

箭头方向反转的地方,就是 DIP 的全部
箭头方向反转的地方,就是 DIP 的全部
箭头方向发生变化,就是“倒置”的本质
箭头方向发生变化,就是“倒置”的本质

所以才会有 DI 框架

这里自然会产生一个问题:“那么,SendGridSender()是谁创建的?”

负责完成这次组装的,正是依赖注入(DI)容器。Swift 生态中的 Swinject、Factory 等 DI 库,准确来说做的就是这件事。让类只依赖协议,而实际实现则由库负责注入。

DIP 是原则(方向),DI 是实现它的技术(工具)。只要掌握这个区别,面试回答就能提升一个层次。

实现可以插拔,组装交给框架负责
实现可以插拔,组装交给框架负责

SOLID 全面总结

把两篇文章介绍的 SOLID 压缩成五行,就是这样。

原则 一句话总结
SRP 如果提出修改的人不同,就将代码分离
OCP 经常变化的地方,用新增而不是修改来应对
LSP 子类不能破坏父类的约定
ISP 不要强迫实现永远不会使用的方法
DIP 不要让业务逻辑依赖细节

五个原则最终都指向同一个方向:缩小变更的影响范围。

不过,如果把这些原则机械地应用到所有代码上,反而会违反 KISS 和 YAGNI。原则是应该选择性地用于实际经常发生变化的地方的工具。我认为,这种平衡感才是真正的能力。

延伸阅读