软件架构之SOLID原则

7,891 阅读10分钟

关注公众号,提前Get更多技术好文

当开发大型软件时,编写易于维护和扩展的代码变得至关重要。SOLID原则是一组指导原则,可帮助我们实现高质量、易于维护的代码。这些原则旨在使软件架构更加健壮、灵活和可扩展,从而减少后期维护成本。

SOLID原则由Robert C. Martin在他的书籍《Agile Software Development, Principles, Patterns, and Practices》中提出。它们代表了一组面向对象编程(OOP)的最佳实践。下面将介绍SOLID原则的五个组成部分。

  • 单一职责原则(Single Responsibility Principle, SRP): 一个类应该只有一个引起它变化的原因。这意味着一个类应该只有一项职责,因此它应该只有一个原因需要被修改。通过将代码分解成更小、更简单的部分,我们可以轻松地对其进行修改和维护,从而提高代码的可读性和可维护性。

  • 开放封闭原则(Open-Closed Principle, OCP): 软件实体(类、模块、函数等)应该是对扩展开放的,但对修改关闭的。这意味着当我们需要增加新功能时,我们应该尽可能地利用现有的代码,而不是修改它们。这可以通过使用接口和抽象类等OOP技术来实现。

  • 里氏替换原则(Liskov Substitution Principle, LSP): 所有引用基类的地方必须能够透明地使用其子类的对象。这意味着子类应该能够替换其基类,并且程序的行为不会受到影响。遵循LSP原则可以确保代码的正确性和稳定性。

  • 接口隔离原则(Interface Segregation Principle, ISP): 客户端不应该依赖于它不需要的接口。这意味着我们应该将接口拆分成更小、更特定的部分,以避免客户端代码依赖于不必要的接口。这将提高代码的可维护性和可扩展性。

  • 依赖反转原则(Dependency Inversion Principle, DIP): 高层模块不应该依赖于低层模块,它们应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。这可以通过依赖注入(DI)等技术实现。遵循DIP原则可以降低模块之间的耦合度,从而提高代码的灵活性和可扩展性。

单一职责原则

SRP 是 SOLID 五大设计原则中最容易被误解的一个,也许是名字的原因,很多程序员认为这个原则就是指:每个模块都应该只做一件事。然而,这并不是 SRP 的全部。在现实环境中,软件系统为了满足用户需求,必然要做出这样那样的修改,所以 SRP 的最终描述就变成了:

任何一个软件模块都应该只对某一类行为者负责。

“软件模块”指的是一组紧密相关的函数和数据结构。相关这个词实际上就隐含了 SRP 这一原则。代码和数据就是靠着与某一类行为者的相关性被组合在一起的。

下面看一个违反 SRP 原则的例子:

工资管理程序中的 Employee 类有三个函数,calculatePay()、reportHours()和save()。这三个函数分别对应三个不同的行为者。

截屏2023-04-09 20.59.28.png

这三个函数被放在同一个类中,这样做实际上是将三类行为者的行为耦合在了一起,这有可能导致CFO团队的命令影响到了COO团队所依赖的功能。

有很多不同的方法可以解决上面的问题,每一种方法都需要将相关函数划分成不同的类,即使每个类都只对应一类行为者。

小结: 单一职责原则主要讨论的是函数与类之间的关系。

开闭原则

开闭原则是由 Bertrand Meyer 在1988年提出的,该设计原则认为:

设计良好的计算机软件应该易于扩展,同时抗拒修改。

一个良好的软件架构师会努力将旧代码的修改需求量降至最小,甚至为0。

下面通过一个例子来了解开闭原则:

假设我们需要设计一个在Web页面上展示财务数据的系统,页面上的数据需要滚动展示,其中负值显示为红色。接下来,该系统的所有者又要求用同样的数据生成一份报表,该报表可以用黑白打印机打印,同时报表格式要得到合理分页等。

  • 首先,我们可以将不同需求的代码分组(SRP)

截屏2023-04-09 20.58.31.png

  • 然后再调整这些分组之间的依赖关系(DIP)

截屏2023-04-09 20.57.42.png

这里很重要的一点是这些单线框的边界都是单向跨越的。也是说,上面的所有组件之间的关系都是单向依赖的。

如果组件A不像因为组件B的修改而受到影响,那么就该让组件B依赖于组件A。

小结: OCP 是我们进行架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

里氏替换原则

里氏替换原则是由美国计算机科学家Barbara Liskov 在1987年提出的。她在一篇名为《数据抽象和层次》的论文中首次提出了该原则,该原则强调了在面向对象编程中继承的使用,即子类对象应该能够替换其父类对象并且仍然能够保持原有的行为表现。

下面通过一个简单的例子来了解 LSP:

16810452239304.png

上述设计是符合 LSP 原则的,因为 Bi11ing 应用程序的行为并不依赖于其使用的任何一个衍生类。也就是说,这两个衍生类的对象都是可以用来替换 License 类对象的。

接口隔离原则

“接口隔离原则”这个名字来自下图这种软件结构。

16810453903567.png

有多个用户需要操作 OPS 类。但是 User1 只需要使用 op1, User2 只需要使用 op2,User3 只需要使用op3。

在这种情况下,User1 虽然不需要调用 op2、op3,但在源代码层次上也与它们形成依赖关系。这种依赖意 味着我们对 OPS 代码中 op2 所做的任何修改,即使不会影响到 User1 的功能,也会导致它需要被重新编译和部署。 这个问题可以通过将不同的操作隔离成接又来解決,具体如图所示:

16810457636993.png

User1 的源代码会依赖于 U1Ops 和 op1,但不会依赖于OPS。这样一来,我们之后对OPS做的修改只要不影响到 User1 的功能,就不需要重新编译和部署 User1 了。

**小结:**任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。

依赖反转原则

依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该 多引用抽象类型,而非具体实现。

我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。相反,当我们修改具体实现时,却很少需要修改相应的抽象接口。所以我们认为接口比实现更稳定。

下面通过一个例子来了解DIP

以一个图书馆管理系统为例,假设系统需要支持三种不同类型的书籍:小说、历史书籍和科学书籍。同时,系统需要能够提供书籍的分类、名称、作者等信息,并支持借出和归还书籍的功能。

按照依赖反转原则,我们可以通过抽象类或接口来定义一个书籍的通用接口,所有的具体书籍类型都实现该接口,如下所示:

protocol Book {
    var name: String { get }
    var author: String { get }
    var type: String { get }
    var isBorrowed: Bool { get set }
}

class Novel: Book {
    var name: String
    var author: String
    var type: String = "小说"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

class HistoryBook: Book {
    var name: String
    var author: String
    var type: String = "历史书籍"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

class ScienceBook: Book {
    var name: String
    var author: String
    var type: String = "科学书籍"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

此时,所有书籍类型都实现了Book接口,并且都能提供相同的属性和方法。这样一来,我们在使用这些不同类型的书籍时,就可以依赖于它们实现的相同接口,而不是依赖于具体的书籍类型。

下面是一个简单的借阅功能实现的例子:

class Library {
    var books: [Book] = []
    
    func borrow(book: Book) {
        guard let index = books.firstIndex(where: { $0.name == book.name }) else {
            print("该书不存在")
            return
        }
        
        if books[index].isBorrowed {
            print("该书已被借出")
        } else {
            books[index].isBorrowed = true
            print("借书成功")
        }
    }
    
    func returnBook(book: Book) {
        guard let index = books.firstIndex(where: { $0.name == book.name }) else {
            print("该书不存在")
            return
        }
        
        if !books[index].isBorrowed {
            print("该书未被借出")
        } else {
            books[index].isBorrowed = false
            print("还书成功")
        }
    }

在这个例子中,Library类并不依赖于具体的书籍类型,而是依赖于Book接口。这使得我们可以轻松地将来增加更多的书籍类型,而无需更改Library类的代码。

优秀的软件架构师会花费大精力来设计接口,以减少未来对其进行改动。 毕竟争取在不修改接口的情况下为软 件增加新的功能是软件设计的基础常识。

也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。下面有几条具体的编码守则:

  • 应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
  • 不要在具体实现类上创建衍生类。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
  • 不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖。这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办 法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
  • 应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名宇。这基本上是DIP原则的另外一个表达方式。

如果想遵守上述的编码守则,我们就必须对那些易变对象的创建过程做一些特殊处理,这样的谨慎是很有必要的,因为,基本在所有的编程语言中,创建对象操作都免不了在源代码层次上依赖对象的具体实现。

在大部分编程语言中,人们都会选择抽象工厂模式来解决源代码依赖问题。

16810529229120.png

这条曲线将整个系统划分为两部分组件:抽象接口层与具体实现层边界。抽象接口组件中包含了应用的所有高阶业务规则,而具体实现组件则包括了所有这些规则所需要做的具体操作及其相关的细节信息。

小结: 依赖反转原则是一条非常重要的软件设计原则,它可以帮助我们设计出高度解耦的系统,提高系统的灵活性和可扩展性。


注:本文大部分内容来自于《架构整洁之道》