“如何写出整洁的架构”这样的话题,的确每个人都可以侃侃而谈,但真理往往掌握在少数人的手中,他们是资深专家,他们是技术达人。其实某年之前我也范范的梳理过应用架构设计应遵循的原则,那如何才能获取少数人的架构思想呢?《架构整洁之道》是一本非常棒的书,它值得每位技术专家去参悟,看它如何秉持最简单的两个观点(分层和依赖规则)开发,就能开发出干净整洁的系统架构。
我们所说的架构是软件系统的架构,所以首先要明确软件系统的价值,价值有两方面:
1. 行为价值
它是软件的核心价值,包括按需求文档编写代码以及可用性保障(功能性bug、性能、稳定性),这几乎占据了我们90%的工作内容,快速响应业务需求也是我们工程师的首要责任。
2. 架构价值
架构价值非常明确,就是让我们的软件(Software)更软(Soft),可以从两方面理解:
当我们只关注行为价值,不关注架构价值时,会发生什么事情?
书中记录的真实案例,随着版本迭代,工程师团队的规模持续增长,但总代码行数却趋于稳定,每行代码的变更成本升高、工程师的生产效率降低,从老板的视角,就是公司的成本增长迅猛,如果营收跟不上就要开始赔钱啦。
那架构价值这么重要性,该如何处理好行为价值和架构价值的关系?
重要紧急矩阵中,做事的顺序是这样的:1.重要且紧急 > 2.重要不紧急 > 3.不重要但紧急 > 4.不重要且不紧急。
敏捷项目管理
分布式计算著名的CAP
区块链中的不可能三角
所以行为价值的事情落在1和3(重要且紧急、不重要但紧急),而架构价值落在2(重要不紧急)。我们研发同学,在低头敲代码之前,一定要把杂糅在一起的1和3分开,把我们架构工作插进去。
按笔者的经历,一般平台型公司是这样平衡二者的冲突:工作类型划分为262(20%人员处理生产级问题、60%的人员响应新需求、20%的人员响应架构类技术债务)。
追求架构价值就是架构工作的目标,说白了,就是用最少的人力成本支撑软件系统的全生命周期,让系统便于修改、方便维护、轻松部署。优秀的架构需要关注生命周期里的每个环节:
其实所谓架构就是限制,限制源码放在哪里、限制依赖、限制通信的方式,但这些限制比较上层。
编程范式是最基础的限制,它限制我们的控制流和数据流:结构化编程限制了控制权的直接转移,面向对象编程限制了控制权的间接转移,函数式编程限制了赋值。这三个编程范式最近提出的一个也有半个世纪的历史了,半个世纪以来没有提出新的编程范式,以后可能也不会了。因为编程范式的意义在于限制,限制了控制权转移限制了数据赋值,其他也没啥可限制的了。很有意思的是,这三个编程范式提出的时间顺序可能与大家的直觉相反,从前到后的顺序为:函数式编程(1936年)、面向对象编程(1966年)、结构化编程(1968年)。
1. 结构化编程
结构化编程证明了人们可以用顺序结构、分支结构、循环结构这三种结构构造出任何程序,并限制了 goto 的使用(就是前面提到结构化编程限制了控制权的直接转移)。遵守结构化编程,工程师就可以像数学家一样对自己的程序进行推理证明,用代码将一些已证明可用的结构串联起来,只要自行证明这些额外代码是确定的,就可以推导出整个程序的正确性。
什么叫做控制权的直接转移?就是函数调用或者 goto 语句,代码在原来的流程里不继续执行了,转而去执行别的代码,并且你指明了执行什么代码。为什么要限制 goto 语句?因为 goto 语句的一些用法会导致某个模块无法被递归拆分成更小的、可证明的单元。而采用分解法将大型问题拆分正是结构化编程的核心价值。
其实遵守结构化编程,工程师们也无法像数学家那样证明自己的程序是正确的,只能像物理学家一样,说自己的程序暂时没有被证伪(没被找到bug)。数学公式和物理公式的最大区别,就是数学公式可被证明,而物理公式无法被证明,只要目前的实验数据没有把它证伪,我们就认为它是正确的。程序也是一样,所有的 test case 都通过了,没发现问题,我们就认为这段程序暂时是正确的。
2. 面向对象编程
面向对象编程包括封装、继承和多态,从架构的角度,这里只关注多态。多态让我们更方便、安全地通过函数调用的方式进行组件间通信,它也是依赖反转(让依赖与控制流方向相反)的基础。
在非面向对象的编程语言中,我们如何在互相解耦的组件间实现函数调用?答案是函数指针。比如采用C语言编写的操作系统中,定义了如下的结构体来解耦具体的IO设备,具体 IO 设备的驱动程序只需要把函数指针指到自己的函数就可以了。
struct FILE {
void (*open)(char* name, int mode);
void (*close)();
int (*read)();
void (*write)(char);
void (*seek)(long index, int mode);
}
这种通过函数指针进行组件间通信的方式非常脆弱,工程师必须严格按照固定约定来初始化函数指针,并严格地按照约定来调用这些指针,只要一个人没有遵守约定,整个程序都会产生极其难以跟踪和消除的 Bug。所以面向对象编程限制了函数指针的使用(就是前面提到面向对象编程对控制权的间接转移进行了限制),通过接口-实现、抽象类-继承的方式来替代。
什么叫做控制权的间接转移?就是代码在原来的流程里不继续执行了,转而去执行别的代码,但具体执行了啥代码你也不知道,你只调了个函数指针或者接口。
3. 函数式编程
函数式编程有很多种定义很多种特性,这里从架构的角度,只关注它的没有副作用和不修改状态。
什么叫限制了赋值?函数式编程中,函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。
在架构领域所有的竞争问题、死锁问题、并发问题都是由可变变量导致的。如果有足够大的存储量和计算量,应用程序可以用事件溯源的方式,用完全不可变的函数式编程,只通过事务记录从头计算状态,就避免了前面提到的几个问题。目前要让一个软件系统完全没有可变变量是不现实的,但是我们可以通过将状态修改的部分和不需要修改的部分分隔成单独的组件,提高系统的稳定性和效率。
小结
没有结构化编程,程序就无法从一块块可证伪的逻辑搭建,没有面向对象编程,跨越组件边界会是一个非常麻烦而危险的过程,而函数式编程,让组件更加高效而稳定。没有编程范式,架构设计将无从谈起。
和编程范式相比,设计原则和架构的关系更加紧密,设计原则就是架构设计的指导思想,它指导我们如何将数据和函数组织成类,如何将类链接起来成为组件和程序。反向来说,架构的主要工作就是将软件拆解为组件,设计原则指导我们如何拆解、拆解的粒度、组件间依赖的方向、组件解耦的方式等。
设计原则有很多,
架构设计的主导原则是OCP(开闭原则)
设计良好的软件应该易于扩展,同时抗拒修改。这是我们进行架构设计的主导原则,其他的原则都为这条原则服务
在类和代码的层级上有:SRP(单一职责原则)、LSP(里氏替换原则)、ISP(接口隔离原则)、DIP(依赖反转原则)
1. SRP(单一职责原则)
任何一个软件模块,都应该有且只有一个被修改的原因,”被修改的原因“指系统的用户或所有者。该原则指导我们如何拆分组件。
举个例子,CTO和COO都要统计员工的工时,当前他们要求的统计方式可能是相同的,我们复用一套代码,这时COO说周末的工时统计要乘以二,按照这个需求修改完代码,CTO可能就要过来骂街了。当然这是个非常浅显的例子,实际项目中也有很多代码服务于多个价值主体,这带来很大的探秘成本和修改风险。另外当一份代码有多个所有者时,就会产生代码合并冲突的问题。
2. LSP(里氏替换原则)
当用同一接口的不同实现互相替换时,系统的行为应该保持不变。
你一定很疑惑,实现了同一个接口,他们的行为也肯定是一致的呀,还真不一定。比如说让正方形继承矩形,在调用setW和setH时,正方形做的其实是同一个事情,设置它的边长。这时单元测试用矩形能通过,用矩形的子类正方形就不能通过,实现同样的接口,但是系统行为变了,这是违反 LSP 的经典案例。
3. ISP(接口隔离原则)
不依赖任何不需要的方法、类或组件。该原则指导我们的接口设计。
当我们依赖一个接口但只用到了其中的部分方法时,其实我们已经依赖了不需要的方法或类,当这些方法或类有变更时,会引起我们类的重新编译,或者引起我们组建的重新发布,这些都是不必要的。所以我们最好定义个小接口,把用到的方法拆出来。
4. DIP(依赖反转原则)
跨越组建边界的依赖方向永远与控制流的方向相反。该原则指导我们设计组件间依赖的方向。
依赖反转原则是个可操作性非常强的原则,当你要修改组件间的依赖方向时,将需要进行组件间通信的类抽象为接口,接口放在边界的哪边,依赖就指向哪边。
在组件的层级上有:REP(复用、发布等同原则)、CCP(共同闭包原则)、CRP(共同复用原则)
REP、CCP、CRP 三个原则之间存在彼此竞争的关系,REP 和 CCP 是黏合性原则,它们会让组件变得更大,而 CRP 原则是排除性原则,它会让组件变小。遵守REP、CCP而忽略CRP,就会依赖了太多没有用到的组件和类,而这些组件或类的变动会导致你自己的组件进行太多不必要的发布;遵守REP、CRP而忽略CCP,因为组件拆分的太细了,一个需求变更可能要改n个组件,带来的成本也是巨大的。
1. REP(复用、发布等同原则)
软件复用的最小粒度应等同于其发布的最小粒度。直白地说,就是要复用一段代码就把它抽成组件。该原则指导我们组件拆分的粒度。
2. CCP(共同闭包原则)
将为了相同目的而同时修改的类应该放在同一个组件中。CCP原则是SRP原则在组件层面的描述。该原则指导我们组件拆分的粒度。
对大部分应用程序而言,可维护性的重要性远远大于可复用性,由同一个原因引起的代码修改,最好在同一个组件中,如果分散在多个组件中,那么开发、提交、部署的成本都会上升。
3. CRP(共同复用原则)
不要强迫一个组件依赖它不需要的东西。CRP原则是ISP原则在组件层面的描述。该原则指导我们组件拆分的粒度。
我们项目中的一个真实反例就是谷歌的 Firebase,依赖了10个左右的其他组件,整个就是谷歌全家桶,基本全都用不上,感觉自己被强奸了。
优秀的架构师应该能在上述三角形张力区域中定位一个最适合目前研发团队状态的位置,例如在项目早起,CCP比REP更重要,随着项目的发展,这个最合适的位置也要不停调整。
在组件依赖处理的原则:无依赖环原则、稳定依赖原则、稳定抽象原则
1. 无依赖环原则
健康的依赖应该是个有向无环图(DAG),互相依赖的组件,实际上组成了一个大组件,这些组件要一起发布、一起做单元测试。我们可以通过依赖反转原则 DIP 来解除依赖环。
2. 稳定依赖原则
依赖必须要指向更稳定的方向。
这里组件的稳定性指的是它的变更成本,和它变更的频繁度没有直接的关联(变更的频繁程度与需求的稳定性更加相关)。影响组件的变更成本的因素有很多,比如组件的代码量大小、复杂度、清晰度等等,最最重要的因素是
依赖它的组件数量
,让组件难于修改的一个最直接的办法就是让很多其他组件依赖于它!组件稳定性的定量化衡量指标是:不稳定性(I) = 出向依赖数量 / (入向依赖数量 + 出向依赖数量)。如果发现违反稳定依赖原则的地方,解决的办法也是通过 DIP 来反转依赖。
3. 稳定抽象原则
一个组件的抽象化程度应该与其稳定性保持一致。为了防止高阶架构设计和高阶策略难以修改,通常抽象出稳定的接口、抽象类为单独的组件,让具体实现的组件依赖于接口组件,这样它的稳定性就不会影响它的扩展性。
组件抽象化程度的定量化描述是:抽象程度(A)= 组件中抽象类和接口的数量 / 组件中类的数量。
组件是一组描述如何将输入转化为输出的策略语句的集合,在同一个组件中,策略的变更原因、时间、层次相同。从定义就可以看出,组件拆分需要在两个维度进行:
业务实体:关键业务数据和业务逻辑的集合,与界面无关、与存储无关、与框架无关,只有业务逻辑
用例:特定场景下的业务逻辑,可以理解为 输入 + 业务实体 + 输出 = 用例
接口适配器:包含整个整个MVC,以及对存储、设备、界面等的接口声明和使用
一条策略距离系统的输入、输出越远,它的层次越高,所以业务实体是最高的层,框架与驱动程序是最低的层。
前面拆好了组件分好了层,依赖就很好处理了:依赖关系与数据流控制流脱钩,而与组件所在层次挂钩,始终从低层次指向高层次,如下图。越具体的策略处在的层级越低,越插件化。切换数据库是框架驱动层的事情,接口适配器完全无感知,切换展示器是接口适配器层面的事情,用例完全无感知,而切换用例也不会影响到业务实体。
一个完整的组件边界包括哪些内容?首先跨越组件边界进行通信的两个类都要抽象为接口,另外需要声明专用的输入数据模型、声明专用的返回数据模型,想一想每次进行通信时都要进行的数据模型转换,就能理解维护一个组件边界的成本有多高。
除非必要,我们应该尽量使用不完全边界来降低维护组件边界的成本。不完全边界有三种方式:
除了完全边界和不完全边界的区分,边界的解耦方式也可以分为3个层次:
省略最后一步
一样。从上到下,(开发、部署)成本依次升高,如果低层次的解耦已经满足需要,不要进行高层次的解耦。