@lambeta
2021-06-17T04:03:08.000000Z
字数 22419
阅读 1033
architecture
《架构整洁之道》是Clean Architecture的中文译名。看似简单地延续了《代码整洁之道》(Clean Code)的翻译传统,但事实上,对于取中文名字这件事,我们还是花了不少气力的。拿到译文初稿时,编辑提供了几个备选的译名:《架构简洁之道》,《架构至洁》和《Clean Architecture》,这些名字各有各的考量,在没有了解这本书的核心思想之前,我也没有办法给出恰当的判断。所以在通读了原作和译作之后,我在ThoughtWorks咨询群里发起提案,讨论的过程很精彩,最终在骨灰级架构师新哥的建议下,结果大致趋向了整洁架构。
新哥说:“整本书在说依赖治理(管理),也就是如果降低依赖复杂度,和DDD中分离子域分层架构等想法是一致的;如同你整理你的房间,把东西分门别类放好,从这个角度,整齐比简单更合适,或者清晰也可。”
除此之外,对于《架构至洁》这个候选项,大魔头的态度是不要至洁,总感觉脏脏的。言下之意,自行体会。而读MBA的岳岳和XR(XR说他没读过MBA)从用户思维出发,《代码整洁之道》和《架构整洁之道》可以相互增强记忆,更容易激发用户的购买行为。
即便敲定了“整洁架构”,大家对“之道”也有不同的看法。《代码整洁之道》对应的原标题和副标题分别是Clean Code - A handbook of Agile Software Craftsmanship,而《架构整洁之道》对应的原标题和副标题分别是Clean Architecture - A Craftsman's Guide to Software Structure and Design。我们知道“道”是一种形而上的精神层面,老实讲,把Craftsman(手艺人)译做“道”是有点夸张的。
形而上是精神方面的宏观范畴,用抽象(理性)思维,形而上者道理,起于学,行于理,止于道,故有形而上者谓之道;形而下是物质方面的微观范畴,用具体(感性)思维,形而下者器物,起于教,行于法,止于术,故有形而下者谓之器。
道法术器择其一?其实凡事总有权衡,遵循前人的译法往往不会太坏。就像鲍勃大叔书中总结的稳定依赖原则,当我们依赖一种译法次数越多,它就更加稳定,这种稳定先不说能否形成品牌效应,单是SEO就能省去不少功夫,那么何乐而不为呢?
鲍勃大叔的文字平铺直叙、浅显易懂,尤其喜欢用他自己生活中的经验做例子。而且这本书是没有知识断层的,即便是初级程序员,也能在鲍勃大叔的循循善诱下,完成对软件架构认知的转变。因为他总是从最基础的知识点切入,自下而上,一步步地搭起架构的形状。
编程范式是程序员喜闻乐见的话题,就像Vim和Emacs编辑器地位的旷日之争。它们的沉浮过往俨然就是风云诡谲的江湖。结构化编程英雄迟暮逐渐淡出程序员的视野,觊觎已久的面向对象编程(OOP)以迅雷之势称霸武林,独居一隅的函数式编程(FP)隐忍多年终于等来了一次机会。2012-2014年,江湖唱衰OOP的声音不绝于耳,FP就像一名拯救程序员于水火的侠士想要撼动这片天地。硝烟过后,眼前却不是你死我亡的惨状,而是你中有我、我中有你的大团圆结局。当Java这位OOP的保守党融汇了FP的特性lambda表达式,这场范式的冲突之争也算落下了帷幕。
程序员谈编程范式,喜欢党同伐异,作为FP的拥趸,我也不例外。可是鲍勃大叔却娓娓道来,所谓编程范式不过是约束程序的执行,告诉我们什么不能做而已。
1. 结构化编程是对程序控制权的直接转移的规范和限制
2. 面向对象编程是对程序控制权的间接转移的规范和限制
3. 函数式编程是对程序赋值操作的规范和限制
学习C语言编程的第一天,老师就告诉我们不要在程序中使用goto语句,因为goto会破坏程序的结构化。Dijkstra在论文Go To Statement Considered Harmful中证明了goto语句阻止了将大程序递归分解成更小的可证明的单元,这意味着大量使用goto语句的程序是不能被证明的。这里,不能被证明的语义是不可判定,类似说谎者悖论——“我在说谎”这句话不能被证明和证伪,所以不用goto其实是在保证小的程序单元可判定。可惜的是,Dijkstra并没有证明程序单元,这项工作被科学方法——测试取代了。在保证程序单元可判定的前提下,测试是一种可以对其可证伪的科学方法。命题“天下乌鸦一般黑”就是可以证伪的,我们不可能枚举天下所有的乌鸦,等到哪天找到了一只白乌鸦,我们就可以说这个命题是错误的,这就是证伪。Dijkstra说的“测试只能说明bug存在,而不能证明不存在。”是同样的道理。
测试可以保证,在当前已知情况下,程序单元是正确的。一旦有新的测试用例导致程序单元出错,那么我们就可以修正程序,让程序更加接近真相。这或许就是TDD(测试驱动开发)的妙处所在吧。
去除了goto语句之后,我们发现具备顺序,循环和分支判断能力的计算过程还是图灵完备的,也就是说goto的有无并不会影响计算能力。那么goto的在程序中的作用便是弊大于利的。再加上goto的滥用会导致程序结构容易混乱,不利于程序员理解,这更得尽力避免。所以结构化编程限制了对程序直接转移的控制权。
人人都知道面向对象编程有三大特征:封装,继承和多态。
封装是为了构造抽象屏障(Abstract Barrier),到达隐藏信息的目的。任何编程范式都不会缺少封装,因为这是人的需求,是人类简化问题认知的方式。
继承是一种函数(过程或者API)复用的方式,以前我们想在多个结构相似的数据上使用同样的函数,需要通过强制转换到函数可接收的数据类型(结构体指针)上,这必然存在风险。面向对象的世界里,我们不再需要手动强制转换,只要通过显式地表明继承关系,编程语言就能在运行时自动做到这点。
多态(polymorphism)是一种将不同的特殊行为和单个泛化记号相关联的能力,和多态概念对应的参考实现——运行哪段代码的决策叫做分派,大部分分派基于类型,也可以基于方法参数的个数及其类型,而分派的具体执行过程则仰仗函数指针。当作为单个泛化记号的函数被声明出来,它的具体实现可以多样化。通过这样的记号,事实上,我们解耦声明和实现,而这种解耦的过程恰恰是通过函数指针间接地找到目标函数完成的。所以面向对象编程限制了对程序间接转移的控制权。
Neal Ford在《函数式编程思想》(Functional Thinking)中提到面向对象编程是通过封装可变因素控制复杂性(makes code understandable),而函数式编程是通过消除可变因素控制复杂性的。而函数式的一个显著的特点就是不可变性。不可变性意味着更多的内存消耗,更差的性能?其实不尽然。像Scala,Clojure这些基于JVM上的函数式编程语言大量使用了持久化结构(如:Persistent Vector),在不损失效率的前提下,实现了不可变的数据结构。这样的数据结构在高并发的环境下具有非常巨大的优势,尤其相对于面向对象编程中为人所诟病的临界区和竞态条件。
不可变的数据结构是无法重复赋值的,所以函数式编程限制了对程序的赋值操作。
鲍勃大叔一针见血地指出,我们过去50年学到的东西主要是——什么不应该做。这等于给全书奠定了基调,可以类比,良好的架构也在传达同样的道理。
为什么从编程范式开始谈起?在审阅完整本书之后,我慢慢发现鲍勃大叔其实在传递一种设计理念:架构设计里,自顶向下的设计往往是不靠谱的。就像本书的目录,从程序的基础构件,谈到组件,最后谈到架构,这个过程非常符合系统自组织的特征。
为什么自顶向下的设计往往不靠谱,本书的第4部分“组件构建原则”会有答案,且听下回分解。
组件是软件部署的最小单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。比如,对于Java应用程序而言,Jar包就是组件;Ruby中的组件则是Gem文件;Python中的Egg或Wheel文件以及.Net下的DLL文件。
上回我们说到,编程范式的本质是约束。子过程、类或函数是我们编程过程中的基本元素,所以说编程范式是程序的基础构件。如果将这些基本构件比作建筑里的泥沙石,那么程序中的组件就可以类比成砖头。砖头的工艺注重材料配比,组件也是如此,恰如其分的基础构件配比是组件稳定的基础。组件的内容配比较难定量,但是在实践上,仍然受到指导原则的约束。
在软件工程中,我们会看到很多约束条件都能由三角形的方式体现出来。这是因为三角形除了具有稳定的特性以外,还能体现出一种张力。
比如在敏捷项目管理中,我们常会听到时间,资源和成本的约束三角;在分布式计算中,著名的CAP(一致性,可用性和分区容错性)原理也是如此;还有区块链中的不可能三角(性能,安全和去中心化)。这些三角都在反映一种约束——不能完全同时满足,需要权衡。
组件的内容配比,最终反映在组件的实践上就是基本构件的拆与合。鲍勃大叔给出了三个拆合的指导原则:REP(复用/发布等同原则),CCP(共同闭包原则)和CRP(共同复用原则)。
[图]
1. REP(复用/发布等同原则):软件复用的最小粒度应该等同于其发布的最小粒度
2. CCP(共同闭包原则):将同时修改,目的相同的类放到同一个组件;不会同时修改,目的不同的类放到不同的组件
3. CRP(共同复用原则):不要强迫一个组件的用户依赖他们不需要的东西
这些原则乍看上去是全新的理念,细细品来又好像“新瓶装旧酒”的老把戏。CCP不就是SRP(单一职能原则)?CRP不就是ISP(接口隔离原则)?REP,等等,这是不言自明的公理呀!难怪有些架构师朋友说,鲍勃大叔老了,又拿着SOLID那一套概念出来忽悠骗钱。
不妨换个思路想想,通常当谈论SOLID、高内聚低耦合、稳定依赖、稳定抽象系列原则的时候,我们是处于软件系统生命周期的哪一环?不出意外,大家都是从编写源代码,即开发(Development)的角度出发的。但是,我们又清晰地了解,软件系统的生命周期其实还包含除开发之外的部署、发布,运行和维护环节。那么问题来了,在这些环节里,哪些指导原则是适用的呢?
在跳脱了开发的思维桎梏之后,我们通过两种手段分析下这三条原则。
REP原则阐述了一个简单的道理:软件复用是基本要求。在追求软件复用的过程中,逐步形成了标准的发布流程,如:版本号(语义化版本),发布时间,变更内容等。这要求组件中所包含的模块和类都必须同时可发布,而可发布的深层含义既是对用户的承诺,也是对作者的约束。组件是否向后兼容?是否包含破坏性的变更?升级的注意事项?
CCP原则是指尽量把变更频率相同的模块和类放到同一个组件当中。这样做的好处是,当相关功能更新时,我们可以把源代码的变更局限在某一个组件当中,而不需要横跨多个组件,从而减少了部署,验证和发布的次数。概括来说,这是局部化影响的优势。CCP和OCP(开闭原则)中强调的“闭包”也有关联,所谓封装可变因素就是形成闭包的过程,CCP要求将同一时间变更的点聚合起来,达到闭包的效果。
CRP原则是说组件和组件之间的依赖应该达成一种默契——如果不需要完全使用某个组件中所有的模块和类,那么就不要依赖它。这看上去不太可能,但是有一点意义,它指导我们:不是紧密相连的模块和类不应该被放到同一个组件里。因为我们知道一旦某个组件变更升级之后,依赖它的组件往往也会被动的变更升级,即便是和自己那些无关的变更也是如此。而每次变更都意味着重新编译,部署验证和发布。
REP原则说明软件复用是基础,复用是通过发布流程规范的。在复用和发布的上下文中,CCP原则为了便于后期维护,需要尽可能地将变更频率相同的模块和类放到相同的复用单元——组件中;CRP原则为了避免频繁发布,应该将每个组件分割的足够小,减少无关变更导致依赖链条的连锁发布反应。
如果我们只兼顾REP和CCP原则,那么就可能由于连锁发布反应,出现很多不必要的发布;如果只兼顾REP和CRP原则,那么就可能因为实现一个功能需要横跨多个组件修改,造成过多的组件变更;如果只兼顾CCP和CRP,那我们可能就忘记了复用这档子事儿,这在先前我们批判鲍勃大叔的时候已经体现出来了。
软件系统的生命周期里处处充斥着约束条件,每多一个环节往往就会多一种矛盾,进而衍生出多个方向的约束。组件聚合张力图反映的是发布和开发之间的矛盾,需要尽量遵循REP,CCP和CRP原则,满足其约束,才能减少变更成本。
组件构建过程中,除了聚合原则,还有耦合原则——描述的是组件的依赖关系。聚合原则告诉我们的是软件系统中的最小元素,耦合原则说的是元素之间的关系,当这两者和系统的功能结合到一起,就构成一个运行着的系统。系统是逐渐演化出来,即便我们熟知REP,CCP和CRP原则,也没有办法说,在系统构建之初,遵循这些原则就能画出完美的组件结构图。这便是“自顶而下”的设计不靠谱基本解释。
“自顶而下”的设计不靠谱还有更深层次的原因。本书的第14章“组件耦合”会有答案,且听下回分解。
周三的午休时间,我在北京办公室分享了一场《架构整洁之道导读》。当谈到分享组件聚合原则的时候,很多同事表示难以理解。究其缘由,是我们无法将组件违反原则的后果对应到真实项目的问题上,这就导致原则和实践之间的不一致。讨论的过程异常激烈,但是很遗憾地最终并没有得到一个服众的结论。所以为了进一步澄清这些争议点,我决定专门组织一场针对组件聚合原则张力图的讨论会。在吴大师的鼓动下,时间定在下周四晚上的8点半,与会人员大多是咨询团队的技术教练,也有我们项目上的客户。
在这场长达两个半小时的讨论会上,没想到首先出现争议的点居然是组件的定义。
组件是软件部署的最小单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。
对于这样的定义,大魔头提出了质疑:library(库)并不能独立部署。但凡出现明显的逻辑漏洞的时候,我们最好的方式是抛开译文回去看原文。
Components are the units of deployment. They are the smallest entities that can be deployed as part of a system.
阅读原文之后,我们发现“组件是软件部署的最小单元。”这句话翻译得并没有太大问题,但是第二句就有损原意了,原意是说可以作为系统的一部分被部署的最小实体,而没有强调部署过程这种动态的概念,否则就和前一句是同义反复。所以这个定义里面并没有说组件可以独立部署。后面提到组件可以被链接到一个独立可执行文件或者归档文件,又或者,可以被打包成.jar、.dll或者.exe文件,并以动态加载的插件形式实现独立部署。
来自原文:
Components can be linked together into a single executable. Or they can be aggregated together into a single archive, such as a .war file. Or they can be independently deployed as separate dynamically loaded plugins, such as.jar or .dll or .exe files.
来自讨论:
20:56:56 From tianjie : These dynamically linked files, which can be plugged together at runtime, are the software components of our architectures.
吴大师还认为组件应该是个逻辑单元,而不是物理单元。强制某个代码模块就是一个物理的部署单元是不合适的。另外,鲍勃大叔在介绍架构边界时,也表明了一样的观点:架构的边界并不是服务的边界。
联系上下文理解之后,我们知道:组件可以被设计成独立部署的,但是并不是所有的组件都是可以独立部署的。这是要澄清的,不然讨论聚合原则的时候容易出现偏差。
我按照自己的思路解释过REP、CCP和CRP原则^1之后,讨论的焦点很快聚集到REP原则的解读和实践意义上。
解读障碍
吴大师认为REP原则如果简单解读成没有发布过程就不能复用,它就和CCP、CRP原则的排斥力量不均衡,无法形成稳定的三角关系,那么这个张力图就显得有点鸡肋。
尚奇受到CAP(分布式系统基本原理,一致性,可用性和分区容错性)原则的启发提出了另一个解读方向。他说,CAP原则在分布式系统的实践里,都会先站住P原则,然后在C和A中权衡。那么在REP、CCP和CRP三角关系里,REP原则就相当于这里的P原则,必须先满足然后再去取舍CCP和CRP。
大魔头理解REP的意思是可复用性就是组件是独立可复用的。假如回到没有Maven这些工具,没有依赖管理的年代,如果我们所依赖的包还依赖其它第三方包,那么这个包就不能叫做独立可复用。
21:13:04 From YangYun : 我倒是理解REP的意思是你发布出来的一个可重用的包就是独立可重用的,你不能让我必须带着别的jar包才能用它。
21:14:04 From YangYun : The granule of reuse is the granule of release
他接着说,假如有两个提供同样功能的包,其中一个没有第三方的依赖,而另一个有,那我当然选择前者。
qianping发言说:
21:46:35 From Qian Ping : 假设项目包含sub module ABC,
- 如果ABC单纯sub module没有打成jar,又互相直接复用了,就是违反了REP
- 如果每个sub module,打成jar,互相复用的时候是通过对方特定版本的jar(如snapshot版本),就是符合REP
- 如果符合REP了,而所有sub module是跟随整个项目一起升级版本,就是符合CCP因为他们是一体一起发布的
- 这时假如A依赖B和C,我这次单纯想改C,他们一起升版本了但其实B的Jar完全没有变化,这个对B来说就是一个不必要的发布,B又貌似应该分离出去,但如果它分离出去了,就又离REP和CCP远了
MoMo提出一个观点,我们现在讨论就是可复用组件应该遵循的原则,而REP是对复用粒度的定义。至于那些那些常年采用SNAPSHOT(Java项目里Maven常用的开发版本号),没有发布概念的组件,就不该纳入复用的考虑范围内,那些也就不是REP的反模式。
王岩发现了一个翻译的失误。组件粘合张力图中REP原则的简短描述是“为复用性而组合”,而原文其实是"Group for reusers",翻译过来应该是为了复用者而组合,复用性的英文是 Reusability。所以为了复用者发布,考虑的就是对外部的承诺。
大魔头在讨论中查询了很多资料
21:45:05 From YangYun : Reuse-release Equivalence Principle (REP)
REP essentially means that the package must be created with reusable classes – “Either all of the classes inside the package are reusable, or none of them are”. The classes must also be of the same family. Classes that are unrelated to the purpose of the package should not be included. A package constructed as a family of reusable classes tends to be most useful and reusable. - wiki百科里
在wiki的定义里,可以看到REP原则还有CRP和CCP原则的成分,有点不符合MCME原则。
21:57:03 From YangYun : http://condor.depaul.edu/dmumaugh/OOT/Design-Principles/granularity.pdf
21:58:23 From YangYun : The Release Reuse Equivalency Principle (REP)1
The granule of reuse is the granule of release.
A reusable element, be it a component, a class, or a cluster of classes, cannot be reused unless it is managed by a release system of some kind. Users will be unwilling to use the element if they are forced to upgrade every time the author changes it.Thus. even though the author has released a new version of his reusable element, he must be willing to support and maintain older versions while his customers go about the slow business of getting ready to upgrade. Thus, clients will refuse to reuse an element unless the author promises to keep track of version numbers, and maintain old versions for awhile.
Therefore, one criterion for grouping classes into packages is reuse. Since packages are the unit of release, they are also the unit of reuse. Therefore architects would do well to group reusable classes together into packages.
21:58:29 From YangYun : https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf
这些观点很有代表性,值得大家反复揣摩和思考。
软件工程师一般有个“正难则反”的习惯。原则较抽象,但是模式很具体,反模式更能指导实践。接下来,大家开始讨论哪些是违反了REP原则的反模式实践。
首当其冲的就是git submodule,在某些项目中,这种通过源代码划分模块并共享的方式还是挺常见的。因为共享的是代码,所以每次共享代码更新,势必要让依赖方重新编译,发布和部署。这种做法对于复用是痛苦的。
其次是常年使用SNAPSHOT版本的某些项目。这些项目的特点一般都是某个产品团队底下,内部团队之间有复用的要求。缺点其实也很明显,常年SNAPSHOT等于没有版本和发布的流程。使用者并不知道SNAPSHOT中哪些是稳定的,哪些是修改的,拿到的版本到底是最新的还是遗留的,我需要的功能在这个功能有包含,还是你包含了太多我不需要的升级。这种也是复用痛苦的。
综合以上两个例子以及其它讨论,我们得出了一个好玩的结论:软件工程发展到现在,REP原则已经是基本的要求,它的存在有可能是鲍勃大叔年代感老了的体现。
21:18:31 From tianjie : Therefore the CRP tells us more about which classes shouldn’t be together than about which classes should be together. The CRP says that classes that are not tightly bound to each other should not be in the same component.21:30:37 From tianjie : The last decade has seen the rise of a menagerie of module management tools, such as Maven, Leiningen, and RVM. These tools have grown in importance because, during that time, a vast number of reusable components and component libraries have been created. We are now living in the age of software reuse—a fulfillment of one of the oldest promises of the object-oriented model. The Reuse/Release Equivalence Principle (REP) is a principle that seems obvious, at least in hindsight. People who want to reuse software components cannot, and will not, do so unless those components are tracked through a release process and are given release numbers. This is not simply because, without release numbers, there would be no way to ensure that all the reused components are compatible with each other. Rather, it also reflects the fact that software developers need to know when new releases are coming, and which changes those new releases will bring.Robert C. Martin. Clean Architecture: A Craftsman's Guide to Software S21:31:49 From tianjie : It is not uncommon for developers to be alerted about a new release and decide, based on the changes made in that release, to continue to use the old release instead. Therefore the release process must produce the appropriate notifications and release documentation so that users can make informed decisions about when and whether to integrate the new release. From a software design and architecture point of view, this principle means that the classes and modules that are formed into a component must belong to a cohesive group. The component cannot simply consist of a random hodgepodge of classes and modules; instead, there must be some overarching theme or purpose that those modules all share. Of course, this should be obvious. However, there is another way to look at this issue that is perhaps not quite so obvious. Classes and modules that are grouped together into a component should be releasable together. The fact that they share the same version number and the same release tracking, and are included under21:57:03 From YangYun : http://condor.depaul.edu/dmumaugh/OOT/Design-Principles/granularity.pdf21:58:23 From YangYun : The Release Reuse Equivalency Principle (REP)1The granule of reuse is the granule of release.A reusable element, be it a component, a class, or a cluster of classes, cannot bereused unless it is managed by a release system of some kind. Users will be unwillingto use the element if they are forced to upgrade every time the author changes it.Thus. even though the author has released a new version of his reusable element, hemust be willing to support and maintain older versions while his customers go aboutthe slow business of getting ready to upgrade. Thus, clients will refuse to reuse an elementunless the author promises to keep track of version numbers, and maintain oldversions for awhile.Therefore, one criterion for grouping classes into packages is reuse. Since packagesare the unit of release, they are also the unit of reuse. Therefore architects would dowell to group reusable classes together into packages.21:58:29 From YangYun : https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf
上回说到组件聚合,反映的是组件内部的“基本元素”的选择标准。第14章介绍的组件耦合则是指组件和组件之间的关系,这些依赖关系有些是好的,有些是不好的,组件耦合原则就是在澄清什么是好的依赖标准。
依赖关系其实就是一种信任关系这句话是我总结出来的。为什么大家笃信一个事实——当很多比你聪明的人都开始投身某个新领域时,没错,我说的是区块链,这个领域一定会成为未来。因为在我看来,每个投身其中的聪明人都在筑成一条新的依赖关系,依赖愈来愈多,底层建筑就会愈加稳定,稳定是信任的基础,进而会吸引更多的优秀人才投身其中,形成规模效应。就像为什么那么多人敢用支付宝和微信支付?这种信任感不仅仅来源于背后的大厂品牌,还来自于广泛的用户群体。每次用户完成的安全支付,其实都在加固信任感。区块链,所谓的降本增效,构建完善的征信体系,也是同样的道理。一次不可篡改的交易积累不出信任感,但是一千次,一百万次,以至于每个人一生中所有的交易记录都不可篡改地被记录下来,那么这个人的信誉体系就建立起来了。当一个行业,甚至国家的交易数据都被记录下来,征信体系自然而然就完善了。
这样的例子,我还可以举出很多,放到软件开发行业更加浅显易懂。我见过两个不同团队构建了具有依赖关系的两个微服务,虽然服务可以轻松通过API的方式进行通讯,但是因为双方对彼此服务可用性的不信任,他们选择在两个服务之间建立高可用的消息队列。一个服务发布数据消息,另一个订阅。这个看似良好解耦的方案其实不过是妥协的结果。因为不信任所以不去依赖,不去依赖导致根本不肯信任,就这样形成了恶性循环。还有上回在组件聚合原则中提到REP(复用发布等同原则),其实组件的发布也类似一种发布、订阅模式。这种模式将紧耦合的源代码级别的依赖,转换成了对版本号、发布文档的依赖关系。发布团队可以在自己的私有仓库里继续开发,而订阅团队则可以自行决定升级与否。这种做法本质上是把对代码的依赖反转到对版本发布的依赖上。发布的产出物是稳定的,因此值得信任,所以才可以安全地放到自己的代码中。
现在的软件开发过程,引用第三方组件已经是司空见惯的事情,而且必不可少,这也是软件复用的初衷。稍微开发大型的软件系统,就会涉及依赖多种组件,这些关系有时候会变得错综复杂,处理起来也是一件麻烦事。比如,在Java工程中,偶尔会处理某个组件的多版本冲突。这个问题是由于Maven等依赖管理工具允许传递依赖(transitive depenency)造成的,一般解决方案是将某个版本从依赖它的组件内部排除。然而,这些发生依赖的情况中,最不能接受的就是循环依赖。
组件依赖关系图中不应该出现环。
在组件之间,循环依赖导致的问题是任何组件的变更必然导致其它组件同时变更。我们试想一种组件之间没有依赖的场景,每个组件在这里都能独立的变更而不影响其它组件。再试想一种只有单向的依赖的场景,被依赖的组件发生变更势必会影响依赖它的组件,所以我们会小心翼翼,尽量减少这种组件发布的频率;而此时,依赖方的变更却是自由的。在双向(循环)依赖存在的场景中,任何一方的变更导致的影响几乎相当于粒子回旋加速器造成的动能,这样的结果是几乎无法得到任何一个组件稳定可用的版本。
我们知道,在编码时,类与类之间是不应该有互相依赖的,因为循环依赖往往会导致类加载器陷入加载的死循环,它相当于遇到了先有鸡,还是先有蛋的难题。不过,循环依赖问题是可以规避的,而这恰恰是DIP(依赖反转原则)大显身手的时候。
DIP指导我们在组件出现循环依赖的时候可以有两种规避方式。
第一种是产生循环依赖的组件内声明接口,将它依赖的组件反转成依赖自己的接口。这种方式,不仅破除了循环依赖,同时也避免生成新的组件。
第二种是生成新的组件,让互相依赖的双方都来指向它。适配器模式就是一个很好的诠释。何时生成一个新的组件?这种问题的解答需要利用上回提到的组件聚合原则,此处不再赘述。
依赖关系必须指向更稳定的方向
[图]
稳定是相对于不稳定而言,不稳定是因为组件老是要变更,如果用根因分析,频繁变更的很快就会上升到需求易变PM不是人的高度。不过,一定需要澄清的是:需求总归是要变的。不能快速变更需求的软件架构是一潭死水,也就没有实施任何设计原则的必要了。
既然“向外求玄”的路不太靠谱,那么“反求诸己”好歹也是条路。鲍勃大叔说,稳定是因为依赖的足够多。你先撇开一脸黑线和心中的碎碎念:我考察“什么是稳定”的目的是想找到稳定的方向再去依赖它,你这会儿告诉我,依赖的足够多就稳定了?那么问题来了,先有鸡,还是先有蛋?
我当时也被这种观点吓得虎躯一震,心中一阵翻滚。鲍勃大叔提出这种观点的自信到底从何而来?稍微静下心,我们捋一捋他的行文思路。在阐述上述ADP原则时,鲍勃大叔从消除循环依赖的过程中总结出一个有点遗憾结论:组件结构图不可能自上而下被设计出来。原因是它必须跟随软件系统的变化而变化和扩张。虽然人们普遍以为项目组粒度的组件分组规则所产生的就是组件的依赖结构,但事实上,组件依赖结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性和维护性方面的一张地图。结合我前面提到的依赖即信任的观点,不难发觉稳定是软件系统变化过程中逐渐沉淀出来的,组件不断被拆合,依赖不断被分解,被依赖的最多的组件才会慢慢浮现。
怎么界定组件的稳定程度呢?既然利用了依赖多寡的指标,那就可以很方便的构造出一个不稳定函数
Fan-in指的是外部指向组件的类的数量,相反,Fan-out指的是组件内部指向外部组件的类的数量。用图相关的知识解释,Fan-in是节点的入度,Fan-in是节点的出度。当I=0时,表示组件最稳定,因为只有入度没有出度;I=1时,组件最不稳定,此时只有出度而没有入度。
一个组件的抽象化程度应该与其稳定性保持一致
[图]
软件系统中,总有一部分内容不应该经常发生变化,比如:DDD方法论中领域和子域。这部分应该放到较为稳定的组件里被其它组件依赖。但是这也导致一个很棘手的问题,如果这部分发生了变化,那影响的范围将是巨大的。
怎么办?OCP原则闪亮登场,既然我们知道对修改封闭,对扩展开放,扩展也是拥抱变化的一种手段。所以稳定和抽象具有一种妙不可言的联系,这种联系要求稳定的组件也应该是抽象的,虽然不易修改,但是容易扩展。
SDP和SAP合到一起就是DIP在组件级别的诠释。SDP告诉我们要朝着稳定的方向依赖,而SAP则告诉我们稳定的方向蕴含着抽象的要求。所以依赖也应该朝着抽象的方向。
如何衡量组件的抽象化程度?抽象类和接口的占比就是很好的指标:
A=0时,表明组件抽象程度最低,没有抽象类;A=1时,表明组件抽象程度最高,因为内部全是抽象类或接口。既然依赖应该朝着稳定和抽象的方向,那么这两方面制约因素就要求组件的稳定性和抽象程度具有合力。
坐标(0, 0)表示组件无抽象但稳定。稳定意味着很难修改,再加上不够抽象,所以也无法扩展。这往往是软件系统维护难以为继的根源。
坐标(1, 1)表明组件很抽象但极不稳定,不稳定就是说它只会依赖其它组件但不被任何组件依赖,那么这种情况下的抽象通常是无用的。最典型的例子就是某些抽象类孤零零地躺在代码的角落里,无人问津,美其名曰方便以后扩展,但是我们深刻地知道,YAGNI(You Ain't Gonna Need It)。
这一章节主要在说组件之间的耦合应该遵循哪些原则,所谓原则就是优秀架构应该有的模样。同时,也最终解答了“自顶而下”的设计不靠谱的深层次原因。
其实,在我看来,组件的耦合和聚合也不是一刀切的关系,SAP原则同样指导了每个组件内部应该具备怎样的抽象程度,它也更加巩固了组件结构图一定会不断演化的观点。
我们讲了这么多回,从基础构件谈到组件,就好比有了砖头,切成了一个个独立的房间,那么怎么安排这些房间构造出一栋栋高楼大厦就是下回需要聊到的内容。想知道什么是软件架构?且听下回分解。
今天要讲的是组件发展的历史,鲍勃大叔在书中有所提及。他从古老的PDP-8程序需要指定绝对的起始地址谈到重定位技术对于程序解耦的重要性——用符号(Symbol)替换绝对地址,然后在装载(Load)的时候替换成正确的内存地址。现如今,程序员基本不用考虑程序要装载的起始内存地址。这其实是一种抽象能力,它分离了关注点,让程序员不再需要关心底层细节。
随着汇编语言的兴起,程序规模逐步变大,模块化成为控制复杂度必需的手段。而且在内存有限的情况下,编译过程需要多次从缓慢的存储设备中读取源代码,全量编译所耗费的时间逐渐变得让人难以忍受。为了缩短编译时间,程序员们就更加倾向把可复用的函数库源代码单独编译成可共享的对象(Shared Object),比如Linux下的libc.so文件。
鲍勃大叔在书中提到早期函数库的共享方法是一种静态共享库(Static Shared Library)。操作系统会在某个特定的地址划分出地址块,为那些已知的模块预留足够的空间。这种做法很简单,但是缺点也很明显,除了书中所提及的应用程序和函数库代码如果超出预留空间就会导致地址分配碎片化的问题之外,还存在多个共享库装入内存时,地址可能产生冲突,导致某个库被另一个库覆盖的问题。而且如果共享库升级,其包含的全局变量或函数的地址发生改变,那么已经装入内存的可执行文件大概率会因为依赖旧的地址而发生运行时错误。我们很容易想到,只要不让共享库装载到某块特定的内存区域,那么这些问题就能得到解决。于是,聪明的计算机先辈开始思考如何让共享对象装载到任意地址上?
这里用到了分离可变性的思路,顺道引出装载时重定位和地址无关代码(PIC)的概念。装载时重定位将重定位这件事情延迟到了装载时期,但无奈共享库的指令部分需要在多个进程间共享,这是共享的基本要求;然而重定位恰恰会因为不同的进程对虚拟地址的要求修改指令,共享指令被多个进程修改,这就不可能共享。于是,遵从共享的就是不可变的指导原则,我们需要将那部分可变的指令从指令段.text中分离出去,放入可变的数据段.data中,这也是将可变从不可变状态中分离的基本原则。分离的方法就是建立一个全局偏移表(Global Offset Table),在这个段中维护一个指向变量或函数的指针数组。
函数库也可以被编译成中间目标文件(Linux下的Relocatable的ELF[1]),简称目标文件,比如:C语言运行库Glibc中的的printf.o,这些目标文件有特定的格式(Linux上用的是ELF格式,Windows上用的是PE格式),方便以后组合使用。
这些目标文件里面记载了程序被链接时需要的信息,其中最重要的就是符号表(Symbol tables,段记录的一种)。顾名思义,符号表就是记录符号的表格,它会记载原始程序当中的全局变量、局部静态变量,函数等符号的名字、类型、绑定信息(是局部绑定还是全局绑定)以及位于哪个段记录(.text、.data、.rodata ...)中。这些符号有的是外部引用,其定义是在其它的文件里;有的是本地引用,定义和引用都在本文件中。在最终合并外部目标文件并链接成可执行文件时,所有符号的地址(此处是程序进程虚拟地址 VMA, Virtual Memory Area)都能被确定下来,所以对应的符号就可以被解析成地址,所有引用这些符号的地方也会被替换成地址,这个解析和替换的过程就是重定位。
经过链接后的可执行文件就能被装载进内存,然后和进程的VMA进行映射。准备来说,操作系统通过VMA来管理进程的地址空间,包括进程的堆、栈也是如此,而虚拟地址到物理地址的映射是通过CPU的MMU(Memory Managment Unit)部件实现的。
当然,链接加载器是在摩尔定律下的硬件飞速发展中脱颖而出的,程序规模的增长最终被硬件的革新速率打败,所以编译和链接不再是程序开发过程中的瓶颈。
回顾历史有利于帮助我们厘清概念。在谈高级程序语言是如何解耦之前,我们先回到最初只能用打孔纸带编写程序的年代,那时汇编语言还没有被发明出来,所有的指令和数据都是二进制的字节序列,就像下面这段机器代码。
地址 指令0 0001 01001 ...2 ...3 ...4 1000 0111
第0号地址上的这条指令0001 0100,它的高4位0001表示这是一条跳转指令,低4位0100表示的是跳转的目的地址,即序号4(地址),当机器执行到这个位置时,会自动跳转到第4号地址,执行1000 0111这条指令。
思考一下,这里面有两个问题。
第一是0001这条指令不容易记忆,而且一旦指令序列发生改变(有可能硬件设计时指令改变了),所有用到这条跳转指令的地方都得随之发生改变。
解决这个问题的方式就是用符号(Symbol)替换原来的绝对地址。汇编语言将一些二进制的字节序列替换成了比较容易记忆的符号,比如:jmp表示跳转,divide表示除法子程序的起始地址。这种做法不仅是为了帮助记忆,还可以帮助程序实现解耦。
第二是0100这个目标地址是硬编码的,硬编码目的地址的方式会带来很多麻烦,这就和我们在程序里硬编码魔术数是一样的道理:一旦目的地址发生改变,所有指向这个地址的指令都得随之改变。比如,我们想改动这段代码,在第0号和第4号添加或删除一条指令,那么原来的目的地址就会发生变动。这还只是一种简单的情况,更复杂地,如果这条指令被多处引用,那么操作起来就会更加繁琐。
和解决第一个问题的思路类似,我们用提取变量的重构手法,把原来第4号地址上的1000 0111指令命名为foo这个名称,那么第一条指令的汇编指令就可以表达成如下的简洁形式。
jmp foo
当foo这个标记替换掉绝对地址之后,不管这段程序被如何修改,汇编器都会在汇编的过程中,重新计算foo这个符号的地址,然后把所有引用该符号的地方都修正到这个正确的地址上。而此处的修正过程就是链接(Linking)下的重定位(Relocation)。
所以,从程序语言的发展史来看,链接过程其实是先于编译器出现,只不过那时链接重定位的工作是纯人工计算和修改的。随后,汇编语言用符号替换了指令和绝对地址,这时重定位就成了链接加载器的职责。
VMA的概念
最近收到读者反馈,《架构整洁之道》第 25 章“层次与边界”中,有一段文字的描述让人很费解。
如果我们进一步查看 GameRules 内部,就会发现 GameRules 组件的代码中使用的 Boundary 多态接口是由 Language 组件来实现的;同时还会发现 Language 组件使用的 Boundary 多态接口由 GameRules 代码实现。
读者的疑惑是 GameRules 和 Language 分别定义接口让对方实现,那么这两个组件不就形成双向依赖了吗?
我们仅凭直觉就知道双向依赖肯定是错的,那么这句话到底该作何解释?为了排除翻译出错的可能性,我仔细比照了原文,确定了译文并不损失原意。
If we were to look inside GameRules, we would find polymorphic Boundary interfaces used by the code inside GameRules and implemented by the code inside the Language component. We would also find polymorphic Boundary interfaces used by Language and implemented by code inside GameRules.
我回头翻阅了当时技术审校的文章,遗憾地发现自己确实疏漏了对此处的解释,所以我重新翻阅了书中和边界(Boundary)相关的章节,比如第 22 章的“整洁架构”和第 24 章的“不完全边界”,给出一个牵强的解释:我认为 Bob 大叔在这里提到的 Boundary 多态接口(polymorphic Boundary interfaces)指的是系统在构建完全边界时需要的 inputBoundary 和 outputBoundary 的。但是不管如何,也不可能出现他说的低级别的组件(此处的 Language)定义接口让高级别的组件(GameRules)去实现的情况。
为了解决读者心中的疑惑,我写了封邮件给 Bob 大叔。这封邮件的内容重点在询问 GameRules 为什么会有对 Language 的依赖。Bob 大叔的回复得很迅速,内容如下:
The secret here is that the polymorphic interfaces used by Language and implemented by Game Rules are contained within Game Rules. This keeps the arrows pointing in the same direction.
他强调 Language 使用的多态接口其实是定义在 GameRules 当中的,所以依赖方向依然是从 Language 指向 GameRules 的。看到这样的回复,我还是有些迷惑,不过认真思考了一段时间,我大概知道这里的误解到底是什么了。
其实答案就在第 22 章“整洁架构”里图22.2 一个基于 Web 的、使用数据库的常见 Java 程序。
结合这张图,我们其实不难总结出依赖关系,边界和高层次的接口定义,其中最重要的点就是“使用”并不意味着“定义”。为了确保我的理解正确,我画了一些图,并发送一封确认邮件。Bob 大叔很仔细地回复了我的问题。
"The diagram in Figure 25.3 has gotten a little complicated, but should contain no surprises. The dashed outlines indicate abstract components that define an API that is implemented by the components above or below them. For example, the Language API is implemented by English and Spanish."
Q1: Language components define interfaces that should be implemented by its derivates like English and Spanish that are below the Language component. So why you emphasise ABOVE them?
Answer: In Figure 25.3 you’ll see SMS and Console above Text Delivery. Text Delivery does not actually exist as an independent module. It is an abstraction, or a set of conventions, that both SMS and Console adhere to. English and Spanish appear below Language. Again, Language does not actually exist as a module. English and Spanish simply adhere to the conventions that we call Language.
"GameRules communicates with Language through an API that GameRules defines and Language implements. Language communicates with TextDelivery using an API that Language defines but TextDelivery implements. The API is defined and owned by the user, rather than by the implementer."
Q2: GameRules defines API (interface) that Language component implements, does it mean GameRules and Language are separated components?
Answer: Not quite. English and Spanish are separate from Game Rules and both implement the API defined in Game Rules. The Language abstraction is draw there to denote that English and Spanish follow the conventions that Game Rule and Text Delivery require.
"If we were to look inside GameRules, we would find polymorphic Boundary interfaces used by the code inside GameRules and implemented by the code inside the Language component. We would also find polymorphic Boundary interfaces used by Language and implemented by code inside GameRules."
Q3: In Chapter 22, there is figure 22.2 a typical scenario for a web-based Java system utilising a database.
Here, I mark connections in red to explain my understanding for "used by" and "implemented by". So "used by" is not meaning "defined by", right?
Answer: Right.
If my understanding is correct, how about the diagram I draw as follow? does it explain the secret you mentioned above?
"If we were to look inside of Language, we would find the same thing: Polymorphic Boundary interfaces implemented by the code inside TextDelivery, and polymorphic Boundary interfaces used by TextDelivery and implemented by Language."
"In each case, the API defined by those Boundary interfaces is owned by the upstream component.”
Q4: In GameRules and Language components context, upstream component means the GameRules? In Language and TextDelivery components context, upstream means the Language?
Answer: Yes. Upstream means “higher level".
当我把邮件完整地转发给读者后,他表示“我看了 Bob 大叔的回答,感觉对依赖反转的认识又深了一层。不,应该说看了你的回答(商业互吹)。抽象出的接口,我从来没想过接口的归属问题,这么看来就合理了。”
以后我们在设计组件时一定要关心边界和接口定义的归属。它代表着依赖反转原则在更大的架构层面上的运用。
读者朋友们,如果有类似的问题,作为本书中文版的技术审校者,我是非常欢迎邮件交流的 qianyan.lambda@gmail.com。