和编程范式相比,设计原则和架构的关系更加紧密,设计原则就是架构设计的指导思想,它指导我们如何将数据和函数组织成类,如何将类链接起来成为组件和程序。反向来说,架构的主要工作就是将软件拆解为组件,设计原则指导我们如何拆解、拆解的粒度、组件间依赖的方向、组件解耦的方式等。
设计原则有很多,我们进行架构设计的主导原则是 OCP(开闭原则),在类和代码的层级上有:SRP(单一职责原则)、LSP(里氏替换原则)、ISP(接口隔离原则)、DIP(依赖反转原则);在组件的层级上有:REP(复用、发布等同原则)、 CCP(共同闭包原则)、CRP(共同复用原则),处理组件依赖问题的三原则:无依赖环原则、稳定依赖原则、稳定抽象原则。
1.OCP(开闭原则)
设计良好的软件应该易于扩展,同时抗拒修改。这是我们进行架构设计的主导原则,其他的原则都为这条原则服务。
2.SRP(单一职责原则)
任何一个软件模块,都应该有且只有一个被修改的原因,“被修改的原因“指系统的用户或所有者,翻译一下就是,任何模块只对一个用户的价值负责。该原则指导我们如何拆分组件。
举个例子,CTO 和 COO 都要统计员工的工时,当前他们要求的统计方式可能是相同的,我们复用一套代码,这时 COO 说周末的工时统计要乘以二,按照这个需求修改完代码,CTO 可能就要过来骂街了。当然这是个非常浅显的例子,实际项目中也有很多代码服务于多个价值主体,这带来很大的探秘成本和修改风险。
另外当一份代码有多个所有者时,就会产生代码合并冲突的问题。
3.LSP(里氏替换原则)
当用同一接口的不同实现互相替换时,系统的行为应该保持不变。该原则指导的是接口与其实现方式。
你一定很疑惑,实现了同一个接口,他们的行为也肯定是一致的呀,还真不一定。假设认为矩形的系统行为是:面积=宽*高,让正方形实现矩形的接口,在调用 setW 和 setH 时,正方形做的其实是同一个事情,设置它的边长。这时下边的单元测试用矩形能通过,用正方形就不行,实现同样的接口,但是系统行为变了,这是违反 LSP 的经典案例。
4.ISP(接口隔离原则)
不依赖任何不需要的方法、类或组件。该原则指导我们的接口设计。
当我们依赖一个接口但只用到了其中的部分方法时,其实我们已经依赖了不需要的方法或类,当这些方法或类有变更时,会引起我们类的重新编译,或者引起我们组件的重新部署,这些都是不必要的。所以我们最好定义个小接口,把用到的方法拆出来。
5.DIP(依赖反转原则)
跨越组建边界的依赖方向永远与控制流的方向相反。该原则指导我们设计组件间依赖的方向。
依赖反转原则是个可操作性非常强的原则,当你要修改组件间的依赖方向时,将需要进行组件间通信的类抽象为接口,接口放在边界的哪边,依赖就指向哪边。
6.REP(复用、发布等同原则)
软件复用的最小粒度应等同于其发布的最小粒度。直白地说,就是要复用一段代码就把它抽成组件。该原则指导我们组件拆分的粒度。
7.CCP(共同闭包原则)
为了相同目的而同时修改的类,应该放在同一个组件中。CCP 原则是 SRP 原则在组件层面的描述。该原则指导我们组件拆分的粒度。
对大部分应用程序而言,可维护性的重要性远远大于可复用性,由同一个原因引起的代码修改,最好在同一个组件中,如果分散在多个组件中,那么开发、提交、部署的成本都会上升。
8.CRP(共同复用原则)
不要强迫一个组件依赖它不需要的东西。CRP 原则是 ISP 原则在组件层面的描述。该原则指导我们组件拆分的粒度。
相信你一定有这种经历,集成了组件A,但组件A依赖了组件B、C。即使组件B、C 你完全用不到,也不得不集成进来。这是因为你只用到了组件A的部分能力,组件A中额外的能力带来了额外的依赖。如果遵循共同复用原则,你需要把A拆分,只保留你要用的部分。
REP、CCP、CRP 三个原则之间存在彼此竞争的关系,REP 和 CCP 是黏合性原则,它们会让组件变得更大,而 CRP 原则是排除性原则,它会让组件变小。遵守REP、CCP 而忽略 CRP ,就会依赖了太多没有用到的组件和类,而这些组件或类的变动会导致你自己的组件进行太多不必要的发布;遵守 REP 、CRP 而忽略 CCP,因为组件拆分的太细了,一个需求变更可能要改n个组件,带来的成本也是巨大的。
优秀的架构师应该能在上述三角形张力区域中定位一个最适合目前研发团队状态的位置,例如在项目早期,CCP比REP更重要,随着项目的发展,这个最合适的位置也要不停调整。
9.无依赖环原则
健康的依赖应该是个有向无环图(DAG),互相依赖的组件,实际上组成了一个大组件,这些组件要一起发布、一起做单元测试。我们可以通过依赖反转原则 DIP 来解除依赖环。
10.稳定依赖原则
依赖必须要指向更稳定的方向。
这里组件的稳定性指的是它的变更成本,和它变更的频繁度没有直接的关联(变更的频繁程度与需求的稳定性更加相关)。影响组件的变更成本的因素有很多,比如组件的代码量大小、复杂度、清晰度等等,最最重要的因素是依赖它的组件数量,让组件难以修改的一个最直接的办法就是让很多其他组件依赖于它!
组件稳定性的定量化衡量指标是:不稳定性(I) = 出向依赖数量 / (入向依赖数量 + 出向依赖数量)。如果发现违反稳定依赖原则的地方,解决的办法也是通过 DIP 来反转依赖。
11.稳定抽象原则
一个组件的抽象化程度应该与其稳定性保持一致。为了防止高阶架构设计和高阶策略难以修改,通常抽象出稳定的接口、抽象类为单独的组件,让具体实现的组件依赖于接口组件,这样它的稳定性就不会影响它的扩展性。
组件抽象化程度的定量化描述是:抽象程度(A)= 组件中抽象类和接口的数量 / 组件中类的数量。
将不稳定性(I)作为横轴,抽象程度(A)作为纵轴,那么最稳定、只包含抽象类和接口的组件应该位于左上角(0,1),最不稳定、只包含具体实现类,没有任何接口的组件应该位于右下角(1,0),他们连线就是主序列线,位于线上的组件,他们的稳定性和抽象程度相匹配,是设计良好的组件。位于(0,0)周围区域的组件,它们是非常稳定(注意这里的稳定指的是变更成本)并且非常具体的组件,因为他们的抽象程度低,决定了他们经常改动的命运,但是又有许多其他组件依赖他们,改起来非常痛苦,所以这个区域叫做痛苦区。右上角区域的组件,没有其他组件依赖他们,他们自身的抽象程度又很高,很有可能是陈年的老代码,所以这个区域叫做无用区。
另外,可以用点距离主序列线的距离 Z 来表示组件是否遵循稳定抽象原则,Z 越大表示组件越违背稳定依赖原则。