为什么 SOLID 理论仍是现代软件架构的基础


.

尽管自创建 SOLID 理论 20 年以来计算发生了很大变化,但它们仍然是设计软件时的最佳选择。

SOLID 理论是已经过时间考验用于创建高质量软件的准则。但在多范式编程和云计算的领域中,它们还能叠加吗?我将探索 SOLID 的意义(字面上和比喻上),解释它仍受欢迎的原因,并分享一些它用在现代计算的例子。

什么是SOLID?

SOLID是一组从 Robert C. Martin 在 2000 年代初期的著作中提炼出来的原则。它被提议作为一种专门面向对象 (OO) 编程质量的方式。总体而言,SOLID 理论对如何拆分代码、哪些部分应该是内部的或公开的以及应该如何使用其他代码编码提出了观点。我将深入研究下面的每个字母并解释其原始含义,以及可应用于 OO 编程之外的扩展含义。

发生了哪些变化?

在 21世纪初期,Java 和 C++ 是当之无愧的王者。当然,在我的大学课程中,我们的大部分练习和课程都使用Java 语言。Java 的流行催生了书籍、会议、课程以及其他方面,以帮助人们从学会编码到编写高质量 代码。

从那时起,软件行业发生了深刻的变化。以下几个方面的变化最明显:

  • 动态类型语言 ,如 Python、Ruby,尤其是 JavaScript,已经变得和 Java 一样流行——在某些行业和类型的公司中甚至超过了Java。

  • 非面向对象范式 ,尤其是函数式编程 (FP),在这些新语言中也更为常见。甚至 Java 本身也引入了 lambdas!元编程(添加和更改对象的方法和特征)等技术也很受欢迎。还有“更软”的面向对象风格,例如 Go,它具有静态类型但没有继承这个类型。所有这些都意味着类和继承在现代软件中不如过去重要。

  • 开源软件 已经激增。之前最常见的做法是编写供客户使用的闭源编译软件,而现在,开源的依赖项更为常见。因此,在编写库时曾经必不可少的那种逻辑和数据隐藏就变得不再那么重要。

  • 微服务软件即服务 迅速涌现。与其将应用程序部署为将其所有依赖项链接在一起的大型可执行文件,不如部署一个与其他服务(你自己的或由第三方提供支持的服务)对话的小型服务。

总的来说,SOLID 真的包含许多事情——比如类和接口、数据隐藏和多态——这些已经不再是程序员每天都要处理的事情。

没变的事物有哪些?

这个行业现在各方面都发生了很多变化,但有些事情却没有改变,也可能不会改变。具体如下:

  • 代码是由人编写和修改的。 代码编写一次,阅读多次。无论是内部的还是外部的,总是需要记录良好的代码,尤其是记录良好的 API。

  • 代码被组织成模块 。在某些语言中,这些是类。在其他情况下,它们可能是单独的源文件。在 JavaScript 中,它们可能是导出对象。无论如何,存在某种方法可以将代码分离和组织成不同的、有界的单元。因此,总是需要决定如何最好地将代码组合在一起。

  • 代码可以是内部的,也可以是外部的。 有些代码是为你自己或你的团队编写的。编写其他代码供其他团队甚至外部客户使用(通过 API)。这意味着需要某种方式来决定哪些代码是“可见的”,哪些是“隐藏的”。

“现代”SOLID

在接下来的内容中,我将用更通俗的解释把五个 SOLID 理论中的每一部分都重述一遍,它可以适用于 OO、FP 或多范式编程,并提供示例。在许多情况下,这些原则甚至可以应用于整个服务或系统!

注意,我将在以下段落中使用模块 一词来指代一组代码。这可以是一个类、一个模块、一个文件等。

单一职责理论

最初的定义: “一个类改变的理由永远不应该超过一个。”

如果你编写的类有很多关注点或“更改原因”,那么只要这些关注点中的任何 一个需要更改,你就需要更改相同的代码。这增加了对一个功能的更改会意外破坏不同功能的可能性。

例如,这是一个永远不应该投入生产的franken类:

class Frankenclass {
   public void saveUserDetails(User user) {
       //...
   }
 
   public void performOrder(Order order) {
       //...
   }
 
   public void shipItem(Item item, String address) {
       // ...
   }
}

新定义: “每个模块都应只做一件事并把它做好。”

这个原则与高内聚 的话题密切相关。本质上,你的代码不应将多个角色或用途混合在一起。

这是使用 JavaScript 的同一示例的 FP 版本:

const saveUserDetails = (user) => { ... }
const performOrder = (order) => { ...}
const shipItem = (item, address) => { ... }
 
export { saveUserDetails, performOrder, shipItem };
 
// calling code
import { saveUserDetails, performOrder, shipItem } from "allActions";

这同样适用于微服务设计;如果你有一个服务来处理所有这三个功能,那么它就能做更多事情。

开闭原则

原始定义: “软件实体应该对扩展开放,对修改关闭。”

这是 Java 等语言设计的一部分——你可以创建类并扩展它们(通过创建子类),但你不能修改原始类。

使事情“对扩展开放”的一个原因是限制对类作者的依赖——如果你需要对类进行更改,你需要不断地要求原作者为你更改,或者你需要深入研究才能自己更改。更重要的是,该类将开始包含许多不同的关注点,这打破了单一职责原则。

关闭类进行修改的原因是我们可能不相信所有下游消费者都能理解我们的“私有”代码,这些代码带有我们个人工作特色,所以我们希望使它免受非技术人员的影响。

class Notifier {
   public void notify(String message) {
       // send an e-mail
   }
}
 
class LoggingNotifier extends Notifier {
   public void notify(String message) {
       super.notify(message); // keep parent behavior
       // also log the message
   }
}

新定义: “你应该能够在不重写模块的情况下使用并添加模块。”

这在面向对象的领域是免费的。在 FP 世界中,你的代码必须确定清晰的“挂钩点”以允许修改。以下的示例不仅允许前后挂钩,甚至可以通过将其函数传递到你的函数来覆盖基本行为:

// library code
 
const saveRecord = (record, save, beforeSave, afterSave) => {
  const defaultSave = (record) => {
   // default save functionality
  }
 
  if (beforeSave) beforeSave(record);
  if (save) {
    save(record);
  }
  else {
    defaultSave(record);
  }
  if (afterSave) afterSave(record);
}
 
// calling code
 
const customSave = (record) => { ... }
saveRecord(myRecord, customSave);

里氏替换原则

原始定义: “如果 S 是T的子类型,那么类型 T 的对象可以替换为类型 S 的对象,而不会改变程序的任何理想属性。”

这是面向对象语言的基本属性。这意味着你应该能够使用任何子类来代替它们的父类。这让你对合约 充满信心——你可以安全地依赖任何“是”类型的对象T 来继续扮演T 的角色. 以下是具体实践:

class Vehicle {
   public int getNumberOfWheels() {
       return 4;
   }
}
 
class Bicycle extends Vehicle {
   public int getNumberOfWheels() {
       return 2;
   }
}
 
// calling code
public static int COST_PER_TIRE = 50;
public int tireCost(Vehicle vehicle) {
    return COST_PER_TIRE * vehicle.getNumberOfWheels();
}   
  
Bicycle bicycle = new Bicycle();
System.out.println(tireCost(bicycle)); // 100

新定义: 如果声明这些事物的行为方式相同,那你就能用一种事物替换另一种事物。

在动态语言中,需要注意的是,如果你的程序“承诺”做某事(例如实现一个接口或一个函数),你就需要遵守承诺,不要让你的客户失望。

许多动态语言使用鸭子类型来实现这一点。本质上,你的函数以正式或非正式的方式来声明它的输入以特定方式运行并根据该假设进行。

下面是使用 Ruby 的例子:

# @param input [#to_s]
def split_lines(input)
 input.to_s.split("\n")
end

在这种情况下,函数并不关心input 是什么类型——而是关心它有一个to_s 函数,它的行为方式与所有to_s 函数的行为方式相同,即将输入转换为字符串。许多动态语言没有办法强制 这种行为,所以这更像是一个纪律问题,而不是一种形式化的技术。

以下是一个使用 TypeScript 的 FP 示例。在这种情况下,高阶函数接受一个过滤器函数,它需要输入一个数字并返回一个布尔值:

const isEven = (x: number) : boolean => x % 2 == 0;
const isOdd = (x: number) : boolean => x % 2 == 1;
 
const printFiltered = (arr: number[], filterFunc: (int) => boolean) => {
 arr.forEach((item) => {
   if (filterFunc(item)) {
     console.log(item);
   }
 })
}
 
const array = [1, 2, 3, 4, 5, 6];
printFiltered(array, isEven);
printFiltered(array, isOdd);

接口隔离原则

原始定义: “许多特定于客户端的接口比一个通用接口要好。”

在 OO 中,你可以将其视为为你的类提供“视图”。你不需要将完整的实施过程提供给所有客户端,而是使用与该客户端相关的方法在它们顶端创建接口,并要求你的客户端使用这些接口。

与单一职责原则一样,这减少了系统之间的耦合,并确保客户不会在不了解情况下无意使用该功能。

以下是通过 SRP 测试的示例:

class PrintRequest {
   public void createRequest() {}
   public void deleteRequest() {}
   public void workOnRequest() {}
}

这段代码通常只有一个“更改原因”——与打印请求有关,它们都是同一个域的一部分,并且所有三种方法都可能更改相同的状态,然而,这不太可能,创建 请求的客户端和解决请求的工作 请求的客户端不能使同一批人。因此,将这些接口分开更有意义:

interface PrintRequestModifier {
   public void createRequest();
   public void deleteRequest();
}
 
interface PrintRequestWorker {
   public void workOnRequest()
}
 
class PrintRequest implements PrintRequestModifier, PrintRequestWorker {
   public void createRequest() {}
   public void deleteRequest() {}
   public void workOnRequest() {}
}

新定义: “不要向客户展示过多内容”。

只需记录客户需要了解的内容。这可能意味着使用文档生成器只输出“公共”函数或路由,而不发出“私有”函数或路由。

在微服务世界中,你可以使用文档或真正的分离来增强清晰度。例如,你的外部客户可能只能以用户身份登录,但你的内部服务可能需要获取用户列表或其他属性。你可以创建一个单独的“仅限外部”用户服务来调用主服务,或者你可以只为隐藏内部路由的外部用户输出特定文档。

依赖倒置原则

原始定义: “取决于抽象,而不是具体。”

在 OO 中,这意味着客户端应该尽可能地依赖于接口而不是具体的类。这确保了代码依赖于尽可能小的表面积——事实上,它根本不依赖于代码,只是一个定义代码应该如何表现的契约 。与其他原则一样,这降低了某处破损而连带其他地方意外破损的风险。以下是一个简化的示例:

interface Logger {
   public void write(String message);
}
 
class FileLogger implements Logger {
   public void write(String message) {
       // write to file
   }
}
 
class StandardOutLogger implements Logger {
   public void write(String message) {
       // write to standard out
   }
}
 
public void doStuff(Logger logger) {
   // do stuff
   logger.write("some message")
}

如果你正在编写需要记录器的代码,不要将自己限制在写入文件中。你只需调用该write 方法并让具体类对其进行整理就可以了。
新定义: “取决于抽象,而不是具体。”

在这种情况下,我不会改变定义!在可能的情况下保持事物抽象的想法仍然很重要,即使现代代码中的抽象机制 不如严格的面向对象世界中那么强大。

实际上,这与上面讨论的 Liskov 替换原理几乎相同。主要区别在于,这里没有默认实现。因此,该部分中涉及鸭子类型和钩子函数的讨论同样适用于依赖倒置。

你还可以将抽象应用于微服务世界。例如,你可以将服务之间的直接通信替换为消息总线或队列平台,例如 Kafka 或 RabbitMQ。这样做允许服务将消息发送到单个通用位置,而无需关心某个特定服务将接收这些消息并执行其任务。

结论

再次重申“现代 SOLID”:

  • 不要让阅读你代码的人感到疑惑。
  • 不要让使用你的代码的人感到迷茫。
  • 不要难倒阅读你代码的人。
  • 适当设定代码的难度。
  • 使用正确的耦合级别——将同类事物放在一起,否则将它们分开。

好的代码就是能经得起时间的检验——这点不会改变,而 SOLID 是验证这一点的不二选择!

原文作者 Daniel Orner
原文链接 https://stackoverflow.blog/2021/11/01/why-solid-principles-are-still-the-foundation-for-modern-software-architecture/

推荐阅读
作者信息
AgoraTechnicalTeam
TA 暂未填写个人简介
文章
175
相关专栏
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。