程序的世界飞速发展,今天所掌握的技能可能明年就过期了,但有一些东西是历久弥新,永远不变的,掌握了这些,在程序的海洋里就不会迷路,架构思想就是这样一种东西。数据库
本文是《架构整洁之道》的读书笔记,文章从软件系统的价值出发,认识架构工做的价值和目标, 依次了解架构设计的基础、指导思想(设计原则)、组件拆分的方法和粒度、组件之间依赖设计、组件边界多种解耦方式以及取舍、下降组件之间通讯成本的方法,从而在作出正确的架构决策和架构设计方面,给出做者本身的解读。编程
阿里巴巴中间件微信公众号对话框,发送“架构”,可获取《架构整洁之道》知识脉络图。直接访问,点击这里。数组
架构是软件系统的一部分,因此要明白架构的价值,首先要明确软件系统的价值。软件系统的价值有两方面,行为价值和架构价值。安全
行为价值是软件的核心价值,包括需求的实现,以及可用性保障(功能性 bug 、性能、稳定性)。这几乎占据了咱们90%的工做内容,支撑业务先赢是咱们工程师的首要责任。若是业务是明确的、稳定的,架构的价值就能够忽略不计,但业务一般是不明确的、飞速发展的,这时架构就无比重要,由于架构的价值就是让咱们的软件(Software)更软(Soft)。能够从两方面理解:微信
当咱们只关注行为价值,不关注架构价值时,会发生什么事情?这是书中记录的一个真实案例,随着版本迭代,工程师团队的规模持续增加,但总代码行数却趋于稳定,相对应的,每行代码的变动成本升高、工程师的生产效率下降。从老板的视角,就是公司的成本增加迅猛,若是营收跟不上就要开始赔钱啦。网络
可见架构价值重要性,接下来从著名的紧急重要矩阵出发,看咱们如何处理好行为价值和架构价值的关系。闭包
重要紧急矩阵中,作事的顺序是这样的:1.重要且紧急 > 2.重要不紧急 > 3.不重要但紧急 > 4.不重要且不紧急。实现行为价值的需求一般是 PD 提出的,都比较紧急,但并不老是特别重要;架构价值的工做内容,一般是开发同窗提出的,都很重要但基本不是很紧急,短时间内不作也死不了。因此行为价值的事情落在1和3(重要且紧急、不重要但紧急),而架构价值落在2(重要不紧急)。咱们开发同窗,在低头敲代码以前,必定要把杂糅在一块儿的1和3分开,把咱们架构工做插进去。架构
前面讲解了架构价值,追求架构价值就是架构工做的目标,说白了,就是用最少的人力成本知足构建和维护该系统的需求,再细致一些,就是支撑软件系统的全生命周期,让系统便于理解、易于修改、方便维护、轻松部署。对于生命周期里的每一个环节,优秀的架构都有不一样的追求:并发
其实所谓架构就是限制,限制源码放在哪里、限制依赖、限制通讯的方式,但这些限制比较上层。编程范式是最基础的限制,它限制咱们的控制流和数据流:结构化编程限制了控制权的直接转移,面向对象编程限制了控制权的间接转移,函数式编程限制了赋值,相信你看到这里必定一脸懵逼,啥叫控制权的直接转移,啥叫控制权的间接转移,不要着急,后边详细讲解。框架
这三个编程范式最近的一个也有半个世纪的历史了,半个世纪以来没有提出新的编程范式,之后可能也不会了。由于编程范式的意义在于限制,限制了控制权转移限制了数据赋值,其余也没啥可限制的了。颇有意思的是,这三个编程范式提出的时间顺序可能与你们的直觉相反,从前到后的顺序为:函数式编程(1936年)、面向对象编程(1966年)、结构化编程(1968年)。
1.结构化编程
结构化编程证实了人们能够用顺序结构、分支结构、循环结构这三种结构构造出任何程序,并限制了 goto 的使用。遵照结构化编程,工程师就能够像数学家同样对本身的程序进行推理证实,用代码将一些已证实可用的结构串联起来,只要自行证实这些额外代码是肯定的,就能够推导出整个程序的正确性。
前面提到结构化编程对控制权的直接转移进行了限制,其实就是限制了 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(依赖反转原则);在组件的层级上有:REP(复用、发布等同原则)、 CCP(共同闭包原则)、CRP(共同复用原则),处理组件依赖问题的三原则:无依赖环原则、稳定依赖原则、稳定抽象原则。
1.OCP(开闭原则)
设计良好的软件应该易于扩展,同时抗拒修改。这是咱们进行架构设计的主导原则,其余的原则都为这条原则服务。
2.SRP(单一职责原则)
任何一个软件模块,都应该有且只有一个被修改的缘由,“被修改的缘由“指系统的用户或全部者,翻译一下就是,任何模块只对一个用户的价值负责。该原则指导咱们如何拆分组件。
举个例子,CTO 和 COO 都要统计员工的工时,当前他们要求的统计方式多是相同的,咱们复用一套代码,这时 COO 说周末的工时统计要乘以二,按照这个需求修改完代码,CTO 可能就要过来骂街了。固然这是个很是浅显的例子,实际项目中也有不少代码服务于多个价值主体,这带来很大的探秘成本和修改风险。
另外当一份代码有多个全部者时,就会产生代码合并冲突的问题。
3.LSP(里氏替换原则)
当用同一接口的不一样实现互相替换时,系统的行为应该保持不变。该原则指导的是接口与其实现方式。
你必定很疑惑,实现了同一个接口,他们的行为也确定是一致的呀,还真不必定。假设认为矩形的系统行为是:面积=宽*高,让正方形实现矩形的接口,在调用 setW 和 setH 时,正方形作的实际上是同一个事情,设置它的边长。这时下边的单元测试用矩形能经过,用正方形就不行,实现一样的接口,可是系统行为变了,这是违反 LSP 的经典案例。
Rectangle r = ... r.setW(5); r.setH(2); assert(r.area() == 10);
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 越大表示组件越违背稳定依赖原则。
了解了编程范式和设计原则,接下来咱们看看如何应用他们拆分组件、处理组件依赖和组件边界。架构工做有两个方针:
尽量长时间地保留尽量多的可选项。这里的可选项指的是可有可无的细节设计,好比具体选用哪一个存储方式、哪一种数据库,或者采用哪一种 Web 框架。业务代码要和这些可选项解耦,数据库或者框架应该作到像插件同样切换,业务层对这个切换的过程应该作到彻底无感。
低层次解耦方式能解决的,不要用高层次解耦方式。组件之间的解耦方式后边细讲,这里强调的是边界处理越完善,开发和部署成本越高。因此不彻底边界能解决的,不要用彻底边界,低层次解耦能解决的,不要用高层次解耦。
首先要给组件下个定义:组件是一组描述如何将输入转化为输出的策略语句的集合,在同一个组件中,策略的变动缘由、时间、层次相同。
从定义就能够看出,组件拆分须要在两个维度进行:按层次拆分、按变动缘由拆分。
这里的变动缘由就是业务用例,按变动缘由进行组件拆分的例子是:订单组件、聊天组件。按层次拆分,能够拆为:业务实体、用例、接口适配器、框架与驱动程序。
一条策略距离系统的输入、输出越远,它的层次越高,因此业务实体是最高的层,框架与驱动程序是最低的层。
前面拆好了组件分好了层,依赖就很好处理了:依赖关系与数据流控制流脱钩,而与组件所在层次挂钩,始终从低层次指向高层次,以下图。越具体的策略处在的层级越低,越插件化。切换数据库是框架驱动层的事情,接口适配器彻底无感知,切换展现器是接口适配器层面的事情,用例彻底无感知,而切换用例也不会影响到业务实体。
一个完整的组件边界包括哪些内容?首先跨越组件边界进行通讯的两个类都要抽象为接口,另外须要声明专用的输入数据模型、声明专用的返回数据模型,想想每次进行通讯时都要进行的数据模型转换,就能理解维护一个组件边界的成本有多高。
除非必要,咱们应该尽可能使用不彻底边界来下降维护组件边界的成本。不彻底边界有三种方式:
除了彻底边界和不彻底边界的区分,边界的解耦方式也能够分为3个层次:
从上到下,(开发、部署)成本依次升高,若是低层次的解耦已经知足须要,不要进行高层次的解耦。
本文做者:韩帅,阿里巴巴高级无线开发工程师,关注架构、IM、应用包瘦身等领域。
本文为云栖社区原创内容,未经容许不得转载。