0%

《架构整洁之道》笔记

程序员要为架构抗争!

随着软件周期的推移,软件修改要付出的代价会越来越大。软件架构的目标是希望以最少的人力满足构建和维护该系统的需求,延缓软件腐化的趋势。

关于对架构的误解澄清:高层的架构并不能脱离细节实现的设计。高层架构和低层设计不分你我。

从两个价值维度描述软件价值:

  • 系统的行为。体现为程序员赶功能交付。
  • 架构灵活性。体现为程序员重构或寻找优秀的设计。

对于这两个永恒的矛盾,程序员应当能够对任务做规划:重要又紧急,重要但不紧急,不重要但紧急,不重要也不紧急。架构往往就是重要但不紧急的事情,程序员如果具备足够的架构自信,应当为架构做抗争,比如说服权力争取时间做架构和重构,争取不到至少也可以尝试调整需求的上线顺序。这种抗争在公司中是永无止境的。

编程范式

结构化编程

结构化编程对程序控制权的直接转移进行了限制和规范。

Dijkstra于1968年提出结构化编程,他认为goto这种无限制跳转的语句会损害程序整体结构。推荐使用if/then/else/do/while/until语句。

为什么说goto是有害的?Dijkstra认为可以借鉴数学推导的方法去证明程序的正确性,程序员可以用一些以证明可用的程序结构串联起来,证明额外的程序是正确的,进而就可以推导出整个程序的正确性。但是goto语句的存在就会导致模块无法被拆分成为更小的、可证明的单元。

目前采用的是科学证明法。科学理论的特点就是,可以被证伪,但无法被证实。现在我们用大量的测试用例去排查bug,如果找不到bug,那么我们的程序可以看作是正确的。

面向对象编程

面向对象编程对程序控制权的间接转移进行了限制和规范。

封装,就是把数据和函数圈起来,对外暴露必要的方法,而数据不可见。

继承,把子类伪装成父类的衍生体,实际上没有做出什么程序的新改造,但是提供了数据结构的伪装性和遍历性。

多态,实际上是对函数指针的运用。多态将原来顺序执行且顺序依赖的程序做出了调整,将其依赖反转(依赖关系与控制流的反转)。程序运行时调用的某接口是虚的,真正调用的接口不受控制流影响,可以独立变化。这样程序就可以做到业务代码与数据库、用户界面的解耦。非常有帮助!

函数式编程

函数式编程对程序中的赋值进行了限制和规范。

函数式编程语言中的变量是不可变的,变量不可变,就不会出现死锁、竞争、并发问题。更适合用于系统中的不可变组件。

设计原则

SRP 单一职责原则

一般会被大家简单理解为:一个函数只完成一个功能。

实际上,单一职责原则是:任何一个软件模块都应该只对某一类行为者负责。

我比较喜欢记住这个解释:任何一个软件模块都应该有且只有一个被修改的原因。当一组人对一些数据有共同的责任时,那这些数据的处理适合放在同一个地方管理,如果放在不同的地方,那么模块就会相互依赖。

OCP 开闭原则

软件要易于扩展,抗拒修改(限制每次被修改所影响的范围)。

常见的实施过程是:先划分按照需求进行功能分组(SRP),然后调整这些分组之间的依赖关系。即将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高层组件不会因为底层组件被修改而受到影响。

在控制组件之间的影响时要注意,组件不应该依赖其不直接使用的组件。如果不直接使用,应当给组件设计一个外观,阻隔外来的调用。

LSP 里氏替换原则

对一个类进行的操作来说,当这个类替换成其衍生类时,操作仍然保持合理。

比如,正方形为矩形的子类,那么修改矩形边长的setter操作就违法了LSP原则,因为正方形子类的长宽不可任意修改。

以我的理解,LSP限制说明的观点是,如果父类的操作不能适用于子类,那么这个方法或者这个继承关系本身就不是合理的。当外界想要对子类操作时,还要专门加以区分,这样就徒增工作量了。

ISP 接口隔离原则

如果多个用户的调用的操作都放在了一起,那么这一个大组件的修改就牵一发而动全身,是不好的。ISP要做的就是给操作增加一个专门针对每个用户的操作接口,这样每个用户只依赖自己所需的那个接口,而不是把别人的操作也依赖了。

DIP 依赖反转原则

依赖抽象而不是依赖具体实现。

如果有一些具体实现的代码总是变动,那么依赖这些代码的模块会非常辛苦,他们最好依赖抽象。比如硬件时常更新、电脑连接的外设总是变化,那么操作系统或者软件依赖的最好是一个虚拟的外设,这样就可以阻隔外界的变化,等真正用到的时候,再给这些虚拟的外设指定好具体是哪个外设。

例如工厂模式。

组件构建原则

设计原则指导我们如何用砖块砌成房间,组件构建原则指导我们如何将房间组合成房子。

组件

组件是软件的部署单元,是完成部署的最小实体。

我本来以为这一章讲的是划分软件设计层面的抽象组件,结果真的是编译器层面的部署单元。

组件是一组二进制文件的集合,多个组件可以链接成一个独立可执行文件。可以独立部署,也可以独立开发。

最开始的时候,编译器效率比较低下,存取缓慢。那么代码越多,占用的内存越多,编译过程就越长。那么程序员就将库函数的源代码单独编译,指定一个内存中的加载地址,编译好就放在这里。之后编译应用程序文件的时候,可以先加载二进制形式的库文件过来,再加载应用程序。

后来,函数库的函数不断增加的时候,就要重新划分加载的上限了。随着内存扩大,程序的碎片化程度会随之不断增加。这就出现了重定位技术:可以指定编译出的二进制格式文件加载到任意地址。

再后来,程序员将加载过程和链接过程分离。链接器输出一个完成了外部链接、可以重定位的二进制文件。

程序规模的墨菲定律:程序规模会一直增长下去,直到将有限的编译和链接时间填满为止。

组件聚合

三个描述组件之间关系的原则。

REP:复用/发布等同原则

:软件复用的最小粒度应等同于其发布的最小粒度。

就是说,组件中的类与模块必须是紧密相关的,要有一个共同的主题或大方向。

而且,一个组件中包含的类和模块应该可以一起发布,用一条版本线跟踪。

CCP:共同闭包原则

:要把可能会同时修改、为相同目的修改的类放在一个组件中。

即,SRP原则的组件层面版本。

CRP:共同复用原则

不要强迫一个组件的用户依赖他们不需要的东西。

和ISP原则类似,就是不要依赖不需要的东西。

三个原则综合考虑:组件聚合张力

graph LR
    rep[复用发布等同原则]
    ccp[共同闭包原则]
    crp[共同复用原则]
    rep---|太多不必要的发布|ccp
    rep---|太多组件变更|crp
    ccp---|复用困难|crp

REP、CCP原则是黏合性原则(组件变大),CRP是排除性原则(组件变小)。

一个软件的开发过程中,它的组件结构会在这个三角区域中不断变动。

组件耦合

无依赖环原则

:组件依赖关系中不应该出现环。

组件依赖关系应该可以转换成一个有向无环图DAG。

消除循环依赖

将依赖图转化为DAG。应用以下两种机制:

  1. 应用依赖反转原则DIP。将一个依赖改为依赖接口,通过实现接口来倒置依赖。
  2. 创建一个新组件。

自上而下的设计

组件结构图不可能从上而下地设计出来。这里的上是指抽象,下是指具体。

组件依赖结构图主要是针对构建性、维护性方面的设计图,用来指导如何隔离软件频繁的变更。所以在项目开始不会被设计出来,是因为开始的时候不需要被构建和维护。

SDP:稳定依赖原则

:依赖关系要指向更稳定的方向。

按照我的想法,稳定是相对的、是有“历史局限性”的。我们要做的是尽可能识别到自己所见范围内最稳定的地方,作为我们依赖的对象。

看看另一个好落实的稳定依赖方案:

量化一个组件的稳定性:稳定性指标(I)。I=0表示最稳定。
$$
I={Fanout}\div{(Fanin + Fanout)},I\in[0,1]\
Fanout=出度依赖,Fanin=入度依赖
$$
稳定依赖原则要求让每个组件的I指标都大于其所依赖的组件的I指标。

SAP:稳定抽象原则

:一个组件的抽象化程度应该与其稳定性保持一致。

意思就是说,在这个原则当中,组件的稳定性与抽象化程度被关联起来了——一个稳定的组件同时也应该是抽象的,这样这个组件就可以被灵活地扩展且不需修改。

所以SAP让SDP有了落实的方法。两个结合起来就是:依赖关系应该指向更抽象的方向。也就是组件版的DIP原则。

量化抽象程度:A。A=0表示最具体。
$$
A=Na\div{Nc}, A\in[0,1]\
Na=组件中类的数量,Nc=组件中抽象类和接口的数量。
$$

稳定与抽象的讨论

I与A在实现软件的时候应该成反比,就是说稳定的应当写为抽象(所以方便扩展)、不稳定的写的是具体的实现(它依赖的是抽象)。软件越靠近这个反比的线条,在稳定和抽象的程度上就把握得越合理。

如果一个组件处于又稳定又具体的位置,那么写代码就很痛苦,因为他不好修改、不好扩展。如果组件处于不稳定又抽象的地方,那他就没有什么好扩展的,没用。

书里的这部分,感觉作者本人觉得自己发明了两个概念很了不起,叭叭地延展了一堆,等我耐心看完他那一堆概念和公式,觉得自己好像被耍了,因为他也没讲出什么有趣的道理。

软件架构

架构师的定位

工作实质:规划如何将系统切分为组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。

目的:更好地对组件开发、部署、运行、维护。

如果想设计一个更方便推进各项工作的系统,策略就是在设计中尽可能长时间地保留尽可能多的可选项。

开发的角度

难以开发的系统也不会健康长久。

架构设计要适应开发团队的结构,或者说架构设计最后也会慢慢适应团队结构。

对于多个小组的开发团队,系统要有划分清晰的组件和可靠稳定的接口。

部署的角度

一般来说,一个系统的部署成本越高,可用性就越低。软件架构的目标是要实现一键式的轻松部署。

运行的角度

软件架构对系统运行的影响没有前面的几个大。优化重心应该放在开发、部署和维护。

良好的软件架构应该能明确地反映该系统在运行时的需求。就是说开发人员可以通过架构可以了解运行过程,简化对系统的理解。

维护的角度

维护成本是最高的。成本一,我们在现有系统中寻找在哪里新增功能、修改问题的时间和经历。成本二,修改代码时可能的衍生问题。

保持可选项

保留可选项就是说让软件维持其灵活和便捷的“软”性。

保留什么可选项?就是那些无关紧要的细节设计。

这里的细节设计是相对于软件的核心策略所说的。软件可以分为策略和细节,策略包括软件中所有业务规则和操作过程,就是系统的价值所在。细节是人与系统交互中不影响策略本身的一些行为,比如数据库、I/O设备等。

架构让这些细节与策略脱离开,推迟这些具体的决策过程。

一个优秀的架构师应该致力于最大化可选项数量。