SOLID 原则浅析

SOLID原则

相关术语

在正式进入 SOLID 的探讨之前,我们最好先了解如下几个术语:

最少知识原则强调的是每个代码单元(包括但不限于类、函数、对象、模块)都应该对其他代码单元有尽可能少的知识,这能帮助提升代码的可重用性及可维护性。

耦合也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密,其耦合性就越强,模块的独立性则越差。

内聚标志一个模块内各个元素彼此结合的紧密程度,它是信息隐蔽和局部化概念的自然扩展。内聚是从功能角度来度量模块内的联系,一个好的内聚模块应当恰好做一件事。它描述的是模块内的功能联系。

我们的软件由大量的类或对象单元组成,它们将数据及其相关的方法封装在一起。对象间应该是低耦合的,并且尽可能减少彼此间的依赖,每个对象尽可能对其他对象有更少的了解。特性(feature)与模块也应如此。S.O.L.I.D 就是用于帮助我们达成这些目的一些原则。

什么是 SOLID 原则?

SOLID 原则首先由著名的计算机科学家 Robert C·Martin (著名的Bob大叔)由 2000 年在他的论文中提出。但是 SOLID 缩略词是稍晚由 Michael Feathers 先使用的。Bob大叔也是畅销书《代码整洁之道》和《架构整洁之道》的作者,也是 "Agile Alliance" 的成员。

SOLID 指的是一组软件设计原则,可以指导我们构建我们的方法和类,以提升可靠性、可维护性和可适应性。

如果你过去编写的代码无法满足当前新的需求,更改这些代码以满足这些需求可能会很昂贵。我们需要记录下那些地方需要修改,不断地去完善,这个过程中不应该增加更多的缺陷或使其变得更难扩展。

SOLID 其实是多个英文首字母的组合,每个字母代表:

  • S: Single Responsiblity Principle(SRP,单一职责/功能原则)
  • O: Open-Closed Principle(开闭原则)
  • L: Liskov-Substitution Principle(里氏替换原则)
  • I: Interface Segregation Principle(接口隔离原则)
  • D: Dependency Inversion Principle(依赖反转原则)

单一职责原则

每个类或函数应该只有一个变化的原因,并且只做好一件事情。需要注意如下几点:

  • 不要将函数放在会因各种各样原因而改变的类中
  • 基于使用它的用户考虑其职责(变化的原因)
  • 类应该是低耦合且高内聚的

举个实际的例子,比如我们开发的企业应用需要计算技术部、财务部等不同部门的薪水及工时并将记录存储至数据库中。我们最好能将不同部门的不同计算逻辑分离(抽象)开来。

class Employee {
  public calculateSalary (): number { ...some code }
  public hoursWorked (): number { ...some code }
  public storeToDB (): any { ...some code }
} // 没有将不同部门的不同计算逻辑分离,违背了SRP原则:

上述代码中对不同部门的所有业务逻辑都写到一块了,因其中一个部门的代码改动会同时影响到其他部门(或者说我们尝试在同一个类中处理,会导致代码中出现各种嵌套的 if-else/switch 语句,不利于代码维护)。

abstract class Employee {
  abstract calculateSalary (): number;
  abstract hoursWorked (): number;
  protected storeToDB ():any { ...some code }
}

// 迫使开发者根据抽象类为不同的部门实现对应的类,便于维护并且减少了潜在的代码提交发生冲突的可能
class Technical extends Employee {
  calculateSalary (): number { ...some code }
  hoursWorked (): number {...some code }
}

class Finance extends Employee {
  calculateSalary (): number {...some code}
  hoursWorked (): number {...some code}
}

仍然对如何组织一个类有困惑?建议从类的使用者(用户/角色)的角度去考虑,并结合实际业务逻辑。

开闭原则

代码中的实体(类、模块、函数等)应该对扩展开放对修改关闭。这个原则想要表达的是:我们应该能在不动已经存在代码的前提下添加新的功能。这是因为当我们修改存在的代码时,我们就面临着创建潜在 bug 的风险。因此,如果可能,应该避免碰通过测试的(大部分时候)可靠的生产环境的代码。

违背该规则的代码的味道:代码中存在大量 if/switch 语句。为何我们要尽量避免大量的 if/switch 语句?因为多种执行流更容易导致 bug 的产生。

里氏替换原则

里氏替换原则描述的是子类应该能替换为它的基类。意思是,给定 class B 是 class A 的子类,在预期传入 class A 的对象的任何方法传入 class B 的对象,方法都不应该有异常。这是一个预期的行为,因为继承假定子类继承了父类的一切。子类可以扩展行为但不会收窄

来看个例子:

Class Rectangle { // 长方形
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  area ( ) {
    return this.width * this.height;
  }
}

Class Square extends Rectangle { // 正方形
  setWidth(width) {
    this.width = width
    this.height = width;
  }

  setHeight(height) {
    this.height = height;
    this.width = height;
  }
}

const rectangle = new Rectangle(10, 2)
const square = new Square(5, 5)

function increaseRectangleWidth(rectangle) {
    rectangle.setWidth(rectangle.width +1)
}
increaseRectangleWidth(rectangle) // 新的面积为:11 * 2 = 22
increaseRectangleWidth(square) // 新的面积为:6 * 6 = 36

嘿,问题来了:上述代码中的正方形作为长方形的子类,他们符合里氏替换原则吗?

🤔......

答案是否定的!原因是正方形在设置宽度时同时设置了高度(将父类的行为收窄了)。

解决方案:可以将正方形与长方形的共同点进一步剥离出来,基于此创建一个共同的父类(如 Shape),使父类更为通用。

Class Shape {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

    area(): void {
    return this.width * this.height;
  }
}

接口隔离原则

该原则旨在通过将大型接口拆解为更小的部分以减少大型接口带来的负面影响,很多客户端特定的接口优于一个多用途接口,理念上有些类似单一职责原则。客户端不应该强制实现他们不需要的函数。

/**
 * 正面例子
 */
export class SummaryPageComponent implements OnInit {
    // 实现 OnInit 接口
}

export interface OnInit { // 小而特定的接口
    ngOnInit(): void; // 只有一个方法
}

/**
 * 反面例子
 */
export class SummaryPageComponent implements LifeCycles {
    // 尽管有些方法不需要,但仍然要实现它
}

export interface LifeCycles {
  // 太多方法
  ngOnInit(): void;
  ngOnChanges(changes: SimpleChanges): void;
  ngDoCheck(): void;
  ngOnDestroy(): void;
  ...
}

依赖反转原则

依赖倒置原则描述的是我们的 class 应该依赖接口和抽象类而不是具体的类和函数。

举个支付相关的例子:

class GooglePayService {
  constructor (googlePayInstance) {
    this.gps = googlePayInstance;
  }
  pay (to, amount ) {
    ...some code
  }
}

10个月后,你的上级告诉你需要将支付服务修改为 PhonePay 而不是 GooglePay(或两者都需要支持),你的代码需要支持这种扩展:

type PaymentTransaction = 'Success' | 'Failure' | 'Bounced'

interface IPaymentTransactionResult {
  result: PaymentTransaction;
  message?: string;
}

interface IPaymentService {
    pay(to: string, amount: number): Promise<IPaymentTransactionResult>
}

class GooglePayService implements IPaymentService {
  pay(to: string, amount: number): Promise<IPaymentTransactionResult> {
    ...some code
  }
}

class PhonePayService implements IPaymentService {
  pay(to: string, amount: number): Promise<IPaymentTransactionResult> {
    ...some code
  }
}

然后我们可以将以上两个类“依赖注入”到需要使用的类中,通过引用接口而不是具体的实现的方式。

class CreateUserController extends BaseController {
  constructor (paymentService: IPaymentService) {
    this.paymentService = paymentService;
  }

  protected proceedPayTransaction (): void {
    // api 处理逻辑...
    // 支付
    this.paymentService.pay(userId, amount);
  }
}

现在,可以使用任何需要的支付服务,或者添加其他支付服务。

const phonePayService = new PhonePayService();
const createUserController = new CreateUserController(phonePayService);
createUserController.proceedPayTransaction();

const googlePayService = new GooglePayService();
const createUserController = new CreateUserController(googlePayService);

总结

在代码中运用 SOLID 原则可以帮助我们提升代码的可复用性、可适应性、可读性、可维护性,并且更利于测试。

日常编码其实也是个不断优化代码的过程,随着业务需求的复杂度提升,代码“熵”增不可避免,我们能做的就是努力通过使用 SOLID 原则或相关设计模式让“熵”减少或减缓其增加的步伐。