他可能想要自己设计机器,因为他相信自己会做的更好,于是尝试改造一些发动机。成功之后,就想要更大的成功。然而这个时候,他可能会遇到瓶颈,因为他没有理论基础。于是,他就会发现以前自己丝毫不感兴趣并觉得一无是处的理论,现在变得有了一些值得敬重之处。

罗伯特·M.波西格禅与摩托车维修艺术

原来觉得业务系统不如中间件开发来的有意思,没有太多的技术含量,全都是无聊地堆砌CRUD。但随着工作年限的增加,愈发地感受到业务系统开发的挑战,想要做出一个稳定的业务系统,实属不易。
最近参与了一个复杂业务系统的开发,生产问题频发。身心疲惫之余,分析造成软件质量低下的原因,其中很重要的一部分就是代码结构设计的不好,没有做到隔离变化,导致修改一点功能,就可能使得整个系统发生问题。
想要使系统变得稳定,那么就需要一些设计原则指导我们的系统设计。SOLID原则实际上是五个设计原则的首字母缩写:

  • 单一职责原则(Single responsibility principle,SRP)
  • 开放封闭原则(Open–closed principle,OCP)
  • Liskov 替换原则(Liskov substitution principle,LSP)
  • 接口隔离原则(Interface segregation principle,ISP)
  • 依赖倒置原则(Dependency inversion principle,DIP)

单一职责原则

首先需要明确的一个概念就是,单一职责不是关于如何组合的原则,而是关于如何分解的原则。在《架构整洁之道》中的定义:一个模块应该对一类且仅对一类行为者(actor)负责。最最重要的是将变化及变化的来源纳入考量
我们都知道,写代码与盖楼最大的区别是,软件永远在变化,但是变化就意味着新的不确定性。所以,一个模块最理想的状态是不改变,其次是少改变
一个模块之所以会频繁变化,关键点就在于能引起它改变的原因太多了。我们需要分离关注点,我们代码库里稳定的类越多越好,这应该是我们努力的一个方向。以我负责的一个业务模块为例,原有代码将流程控制和业务逻辑写在了同一个类里,这样做的结果就是,每次要新增相同流程的业务,都需要修改这个类,这个类永远都在变化。更好的设计应该是将流程控制与业务逻辑分离,这样既可以复用流程控制代码,又能更好地控制代码变化的范围。

开闭原则

日常开发工作中最常见的场景是这样的,新来了一个需求就要改一次代码。修改也很容易,只要我们按照之前的惯例如法炮制就好了。这是一种不费脑子的做法,却伴随着长期的伤害。每人每次都只改了一点点,但是,经过长期积累,再来一个新的需求,改动量就要很大了。而在这个过程中,每个人都很无辜,因为每个人都只是遵照惯例在修改。但结果是,所有人都受到了伤害,代码越来越难以维护。
开放封闭原则是这样表述的:软件实体(类、模块、函数)应该对扩展开放,对修改封闭
它给软件设计提出了一个极高的要求:不靠修改而靠扩展以满足需求的变动。简单说就是新需求应该用新代码来满足。这需要我们在软件内部埋好拓展点,而这正是我们需要去设计的地方。我想这背后需要很强的业务知识背景以及不断的重构来实现。比较常见的实现方法就是使用抽象类定义整个业务流程,开放可覆盖的方法供子类实现,使用多态代替if-else分支,实现不同业务的定制化。
实践开闭原则同样需要分离关注点,只有识别出共性的逻辑,抽象出变化点,不断地重构,才能逐渐构造出稳定的模块。

Liskov替换原则

若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。
简单说,在一段代码逻辑中,子类型(subtype)必须能够替换其父类型(base type)。

看一段很常见的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
void handle(final Handler handler) {
if (handler instanceof ReportHandler) {
// 生成报告
((ReportHandler)handler).report();
return;
}

if (handler instanceof NotificationHandler) {
// 发送通知
((NotificationHandler)handler).sendNotification();
}
...
}

这段代码很明显违反了Liskov替换原则,虽然代码定义了一个父类型 Handler,但在这段代码的处理中,是通过运行时类型识别知道子类型是什么的,然后去做相应的业务处理。ReportHandler 和 NotificationHandler 虽然都是 Handler 的子类,但它们没有统一的处理接口。整个方法都是站在子类的角度去思考的,如果我们遵循Liskov替换原则,那么各个子类应该将各自的逻辑封装在父类型定义的方法中。

1
2
3
4
void handle(final Handler handler) {
handler.process();
...
}

此时,这个方法就是站在父类的角度去是思考了,这个方法也就变的更稳定了。

接口隔离原则

常听到一句话,看一个公司的技术团队的水平,看它的API就好了。接口隔离原则就是教我们怎样设计一个好的接口。
接口隔离原则是这样表述的:不应强迫使用者依赖于它们不用的方法
很多程序员分不清使用者和设计者两个是不同的角色。因为在很多人看来,接口的设计和使用常常是由同一个人完成。这就是角色区分意识的缺失,这种缺失导致我们不能把两种不同的角色区分开来。在做软件设计的时候,我们经常考虑的是模型之间如何交互,接口只是一个方便描述的词汇,为了让我们把注意力从具体的实现细节中抽离出来。但是,如果没有设计特定的接口,你的一个个具体类就变成它的接口
以我负责的保险保全模块为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class RegistrationPosCaseRequest {
// 获取保全类型
TransactionType getPosTransType() {
...
}

// 获取保单变更请求对象
PosPolicyRequest getPosPolicyReqeust() {
...
}

// 获取被保人变更请求对象
PosPolicyInsuredListRequest getPosPolicyInsuredListRequest() {
...
}

// 获取标的变更请求对象
PosPolicyInsuredObjectRequest getPosPolicyInsuredObjectRequest() {
...
}
}

每种保全类型都对应一个业务模块,根据各自的需要去获取对应的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface PosCaseRegisterHandler {
void handle(RegistrationPosCaseRequest request)
}

class CancellationHandler implements PosCaseRegisterHandler {
void handle(final RegistrationPosCaseRequest request) {
PosPolicyRequest posPolicy = request.getPosPolicyReqeust();
...
}
}

class PIHandler implements PosCaseRegisterHandler {
void handle(final RegistrationPosCaseRequest request) {
PosPolicyInsuredListRequest insuredList = request.getPosPolicyInsuredListRequest();
...
}
}

class ObjectHandler implements PosCaseRegisterHandler {
void handle(final RegistrationPosCaseRequest request) {
PosPolicyInsuredObjectRequest insuredObject = request.getPosPolicyInsuredObjectRequest();
...
}
}

只需要做一个业务分发就可以了:

1
2
3
4
PosCaseRegisterHandler handler = handlers.get(request.getPosTransType());
if (handler != null) {
handler.handle(request);
}

参照接口隔离原则的定义,可以发现问题,RegistrationPosCaseRequest这个接口太“胖了”。如果新增一种保全类型,那么就要修改RegistrationPosCaseRequest这个接口,而所有依赖这个接口的handler,其实都在承担变化的风险。
好的做法应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

interface RegistrationPosCaseRequest {
}

interface PosPolicyPosCaseRequest extends RegistrationPosCaseRequest {
PosPolicyRequest getPosPolicyReqeust();
}

interface PosPolicyInsuredListPosCaseRequest extends RegistrationPosCaseRequest {
PosPolicyInsuredListRequest getPosPolicyInsuredListRequest();
}

interface PosPolicyInsuredObjectPosCaseRequest extends RegistrationPosCaseRequest {
PosPolicyInsuredObjectRequest getPosPolicyInsuredObjectRequest();
}

class ActualRegistrationPosCaseRequest implements RegistrationPosCaseRequest, RegistrationPosCaseRequest, RegistrationPosCaseRequest {
...
}

对应的业务处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interface PosCaseRegisterHandler<T extends RegistrationPosCaseRequest> {
void handle(T request)
}


class CancellationHandler implements PosCaseRegisterHandler<PosPolicyPosCaseRequest> {
void handle(final PosPolicyPosCaseRequest request) {
PosPolicyRequest posPolicy = request.getPosPolicyReqeust();
...
}
}


class PIHandler implements PosCaseRegisterHandler<PosPolicyInsuredListPosCaseRequest> {
void handle(final PosPolicyInsuredListPosCaseRequest request) {
PosPolicyInsuredListRequest insuredList = request.getPosPolicyInsuredListRequest();
...
}
}


class ObjectHandler implements PosCaseRegisterHandler<PosPolicyInsuredObjectPosCaseRequest> {
void handle(final PosPolicyInsuredObjectPosCaseRequest request) {
PosPolicyInsuredObjectRequest getPosPolicyInsuredObjectRequest();
...
}
}

我们可以对比一下两个设计,只有 ActualTransactionRequest 做了修改其他的部分因为不存在依赖关系,所以,并不会受到这次需求变动的影响。相对于原来的做法,新设计改动的影响面变得更小了。
这个改进还有一个有趣的地方,ActualTransactionRequest 实现了多个接口。在这个设计里面,每个接口代表着与不同使用者交互的角色,Martin Fowler 将这种接口称为角色接口(Role Interface)。

依赖倒置原则

高层模块不应依赖于低层模块,二者应依赖于抽象。
抽象不应依赖于细节,细节应依赖于抽象。

这个原则在项目中比较常见,就不细说了。