他可能想要自己设计机器,因为他相信自己会做的更好,于是尝试改造一些发动机。成功之后,就想要更大的成功。然而这个时候,他可能会遇到瓶颈,因为他没有理论基础。于是,他就会发现以前自己丝毫不感兴趣并觉得一无是处的理论,现在变得有了一些值得敬重之处。
原来觉得业务系统不如中间件开发来的有意思,没有太多的技术含量,全都是无聊地堆砌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 | void handle(final Handler handler) { |
这段代码很明显违反了Liskov替换原则,虽然代码定义了一个父类型 Handler,但在这段代码的处理中,是通过运行时类型识别知道子类型是什么的,然后去做相应的业务处理。ReportHandler 和 NotificationHandler 虽然都是 Handler 的子类,但它们没有统一的处理接口。整个方法都是站在子类的角度去思考的,如果我们遵循Liskov替换原则,那么各个子类应该将各自的逻辑封装在父类型定义的方法中。
1 | void handle(final Handler handler) { |
此时,这个方法就是站在父类的角度去是思考了,这个方法也就变的更稳定了。
接口隔离原则
常听到一句话,看一个公司的技术团队的水平,看它的API就好了。接口隔离原则就是教我们怎样设计一个好的接口。
接口隔离原则是这样表述的:不应强迫使用者依赖于它们不用的方法。
很多程序员分不清使用者和设计者两个是不同的角色。因为在很多人看来,接口的设计和使用常常是由同一个人完成。这就是角色区分意识的缺失,这种缺失导致我们不能把两种不同的角色区分开来。在做软件设计的时候,我们经常考虑的是模型之间如何交互,接口只是一个方便描述的词汇,为了让我们把注意力从具体的实现细节中抽离出来。但是,如果没有设计特定的接口,你的一个个具体类就变成它的接口。
以我负责的保险保全模块为例:
1 | class RegistrationPosCaseRequest { |
每种保全类型都对应一个业务模块,根据各自的需要去获取对应的信息。
1 | interface PosCaseRegisterHandler { |
只需要做一个业务分发就可以了:
1 | PosCaseRegisterHandler handler = handlers.get(request.getPosTransType()); |
参照接口隔离原则的定义,可以发现问题,RegistrationPosCaseRequest这个接口太“胖了”。如果新增一种保全类型,那么就要修改RegistrationPosCaseRequest这个接口,而所有依赖这个接口的handler,其实都在承担变化的风险。
好的做法应该是这样:
1 |
|
对应的业务处理:
1 | interface PosCaseRegisterHandler<T extends RegistrationPosCaseRequest> { |
我们可以对比一下两个设计,只有 ActualTransactionRequest 做了修改其他的部分因为不存在依赖关系,所以,并不会受到这次需求变动的影响。相对于原来的做法,新设计改动的影响面变得更小了。
这个改进还有一个有趣的地方,ActualTransactionRequest 实现了多个接口。在这个设计里面,每个接口代表着与不同使用者交互的角色,Martin Fowler 将这种接口称为角色接口(Role Interface)。
依赖倒置原则
高层模块不应依赖于低层模块,二者应依赖于抽象。
抽象不应依赖于细节,细节应依赖于抽象。
这个原则在项目中比较常见,就不细说了。