《整洁架构之道》读书笔记(一)原则
《整洁架构之道》,大作,力荐。原著大概可以分为原则、策略、细节三部分,本博文总结前两部分,第三部分多为对第二部分中论点的进一步阐述,详见原著。
Part1 总览
1.现象:为什么软件开发越来效率越低?
程序员没有偷懒。真正偷懒的地方在于:持续低估好的、设计良好的、整洁的代码。
不要迷信开发完再重构:
- 烂代码随时有可能让开发团队陷入困境
- 重构往往只是美好幻想:新任务压力,回顾整个系统的额外成本等等,依靠开发者自觉性去做这件事往往不现实
因此,无论长期或短期,随心所欲、不加设计的所谓敏捷开发,其实比循规蹈矩还慢。
想要跑得快,先要跑得稳。
另:关于重写以挽救一个系统,之前是一团乱麻,没有理由相信同样的团队再来一遍就会更好。
2.目标:最小的人力成本实现开发+维护
这里的最小人力成本是指持续的低成本。
3.软件系统价值
软件系统价值包括:
- 行为价值,指需求文档中的功能,以及bug修复等
- 架构价值,指软件本身的灵活性(software),需求变更时必须容易被修改,如果系统架构设计时偏向某种特定的“形状”,那么修改就会变得困难。因此好的架构应当尽量与“形状”无关。
3.1为什么架构价值更重要
推证如下:倘若系统可以运行但无法修改,那么一旦需求变更,该程序价值开发完后是100,变化来临时瞬间从100降为0;但如果系统目前无法运行,但易于修改,那么改好它,并随需求变化随之变化,那么其价值会从0逐步开发到100,并随需求变动在100附近浮动。
业务部门往往认为行为价值更重要,也更直观:“完成现有的功能比着眼未来的灵活性更实际”,但当事后业务部门提出改动而我们估算的工作量远超其预期时,他们必定责怪我们当时放任系统如此混乱。
业务部门完全没有能力估算架构价值,因为这是开发者的任务,因此将两个价值比较并寻找平衡点也是开发者的职责之一。
3.2架构价值在紧急-重要矩阵中的位置
我们往往会犯的错误是将位序3:不重要但紧急的事情提到位序1去做。重要的事情往往总是不那么紧急,于是一再拖延,永远排不到优先级。
为此,开发者需要长期抗争,从架构价值出发,与其他部门协作寻找两种价值的平衡点,这正是开发者的职责所在,也是最容易失职之处。
Part2 范式
1.结构化范式
禁用goto,结构化编程对程序控制权的直接转移进行了限制和规范。
2.面向对象编程
用多态限制了对指针的直接使用,对程序控制权的间接转移进行了限制和规范
3.函数式编程
λ表达式使相同输入必有相同输出(入参在表达式中是不可变的),限制了赋值操作。
Part3 设计原则
1.SRP: THE SINGLE RESPONSIBILITY PRINCIPLE
单一职责原则。
并非每个模块只能做一件事,而是每个模块应当有且只有一个被修改的原因,对相同的利益相关者负责。(警惕看似可以复用,却需要向不同方向演进的实体对象)
2.OCP: THE OPEN-CLOSED PRINCIPLE
开闭原则,“设计良好的计算机软件应该易于扩展,同时抗拒修改”。
换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。用于控制依赖的方向与信息的可见性,组织模块间依赖关系,使得高阶组件不会因低阶组件被修改而受到影响。
3.LSP: THE LISKOV SUBSTITUTION PRINCIPLE
里氏替换原则。令子类共用函数签名以实现自由替换(不需要额外增加检测机制的那种)
4.ISP: THE INTERFACE SEGREGATION PRINCIPLE
接口隔离原则。通过增加一层interface,对子类的修改就无需令其调用方重新编译和部署。这可以使高层组件无需依赖其并不关心的实现细节。
5.DIP: THE DEPENDENCY INVERSION PRINCIPLE
依赖反转原则。接口要比其实现稳定,因此有如下规约:
- 应在代码中多使用抽象接口,避免使用那些多变的具体实现类
- 不要在具体实现类上创建衍生类
- 不要覆盖(override)包含具体实现的函数。
- 避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字
DIP也是划分组件边界的重要方式。
Part4 组件设计原则
1.组件
无论采用什么编程语言来开发软件,组件都是该软件在部署过程中的最小单元,例如.jar, .dll, .exe等。良好的组件应当永远能够被独立部署,单独开发。
2.组件发展历史
2.1 指定内存地址
早期存储设备十分昂贵,内存十分有限,无法将全部程序一次读取到内存中,因此会将库函数单独编译到指定位置,这样程序就可以仅保存关于库函数的符号表以引用库函数。这做法解决了初期的问题,但随着程序规模发展,库函数扩大,其内存地址也需要重新划分。
2.2 重定位
引入一个智能加载器,自动将库函数加载到适合的位置并将起始位置记录,需要修改时同步修改。这样我们就可以将其按使用顺序加载进内存,再逐个重定位,以使我们能只加载我们实际使用的程序。
另一处对编译器的修改在于,程序员将函数名作为元数据存储起来,使用时如果读取到库函数,就会将其作为外部引用与元数据连接在一起。这就是链接加载器的由来。
2.3连接器
之后,程序规模不断增长,使用链接加载器实在太慢,程序员们将加载和链接过程分离,将链接过程放到单独的程序中执行,即“链接器”。再随后,又出现了能够快速加载的可执行文件。
但这一些速度的提升也很快被程序规模的增长填满。
2.4墨菲定律与摩尔定律
程序规模的墨菲定律:程序的规模会一直不断地增长下去,直到将有限的编译和链接时间填满为止。
摩尔定律:当价格不变时,集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。
这两个定律的较量最终以摩尔定律获胜而终,我们终于不用跟程序的读取方式较劲了,甚至可以实现实时链接。
3.组件聚合
哪些应当被组织为同一个组件?该问题经常被草率地拍脑门决策。
3.1REP:The Reuse/Release Equivalence Principle
复用/发布等同原则:软件复用的最小粒度应当等同于发布的最小粒度。
这样可以方便通过版本号进行管理,使用方可以通过发布文档决定使用新版本或者旧版本。
-
同时一起发布说明该组件中的类不可毫无关联,应当有一个共同的主题或方向;
-
同一组件的类共享版本号、版本路径、发布文档,这要求同一组件的内容应当同时对作者和使用者“有意义”,究竟怎样才算有意义需要由CCP和CRP补充说明。
3.2 CCP:The Common Closure Principle
共同闭包原则:会同时修改、为相同目的修改的东西应当放到同一个组件,否则就应当放到不同组件里。
相当于SRP原则在组件层面的阐述,一个组件也不该存在多个变更原因。这样当出现需求变动时,我们就可以仅需重新部署一个组件了。
这个原则非常需要经验与预测能力以决定闭包范围。
3.3 CRP:The Common Reuse Principle
共同复用原则:不可强迫用户依赖他们所不需要的东西。
该原则告诉我们应该把哪些类分开。因为当一个类保有另一个组件的引用时,就在这两个类之间增加了一条依赖关系。被引用的组件发生变更时,引用它的组件也不得不随之一起变更。就算它代码没动过,也需要重新编译、部署等等来上一套。
因此我们不希望出现仅依赖于另一组件中个别类的情况,这会造成后续很多不必要的部署工作。换句话说,不是紧密相连的类不该放在同一组件中。
3.4 组件聚合张力图
至此可见,上述三原则实际上是竞争关系,软件架构师的工作之一就是在这三者之间权衡、取舍。REP和CCP会使组件趋向于变大,而CRP则趋向于使组件变小。
早期一般起于右上角牺牲复用(前期系统较小而且追求发布速度),而后逐渐发展,其他项目对其产生依赖,会向左侧发展。
4.组件耦合
4.1 避免环状依赖
组件依赖关系图中不可以存在环状依赖。环状依赖严重影响协同开发,同事的改动传递到自己负责的组件上而导致各种问题。这种现象代价很高,从测试到维护都会影响到,尤其发展到一定程度的项目。解决手段有:
1.借助依赖反转手段除去环状依赖
2.将依赖涉及到的东西提取成单独的组件,然后两方都去依赖这个新组件
此外需要额外加一些说明。
首先,两种手段所造成的结构变化导致我们无法在最初定死项目的组件结构。
其次,组件依赖关系图主旨并不在阐述系统功能,更贴近于一张系统构建部署维护的地图。
4.2 SDP: STABLE DEPENDENCIES PRINCIPLE
稳定依赖原则:依赖关系应当指向更稳定的方向。
稳定与否判断标准在于令其变动所需付出的努力。类比硬币,立起来的硬币虽然静止但一碰就倒,这就是不稳定。对应软件行业,我们用扇入(别组件依赖于它)&扇出(它依赖于别组件)来衡量稳定性:
I=扇出/(扇入+扇出),越接近0越稳定。
设计依赖方向图时应当使得被依赖组件I值更小。另外,并非所有组件都是I越小越好(例如Main组件)
4.3 SAP: STABLE ABSTRACTIONS PRINCIPLE
稳定抽象原则:抽象程度于稳定程度应当成正比。
依赖关系应当指向更抽象的方向,一方面将高层决策与具体实现分离,另一方面避免高层决策散落在具体实现中导致框架难以变动。
对于抽象程度的衡量,我们可以用:
A=Na/Nc 即组件中抽象class数/全部class的值判断,越接近于1则抽象程度越高。
4.4 主序列
通过I和A,我们可以衡量组件设计的优劣。
(0,0)附近表示既稳定又具体,这说明它被众多组件依赖但却难以改动,那么一旦确认需要对其进行更改,就会是一场噩梦。例如数据库,每次升级都非常麻烦;再如工具类,很难想象String发生变化会怎样,因此仅有完全确定它绝对不可能变动才会放在这里,但能在最开始就确认完全不会变动的东西少之又少。
(1,1)附近表示既不稳定又抽象,虽然抽象程度够了但却没什么组件鸟它,显然,它没什么用。
而除开这两块区域即为合理。最优的设计会将组件贴近主序列线的两端,但这仅仅是理想情况,实际操作中只要贴近这条线就ok的。