什么是架构?什么是设计?二者究竟有什么区别?
二者没有任何区别。“架构”这个词往往使用于“高层级”的讨论中。这类讨论一般都把“底层”的实现细节排除在外。而“设计”一词,往往用来指代具体的系统底层组织结构和实现的细节。但是,从一个真正的系统架构师的日常工作来看,这样的区分是根本不成立的。
架构图里实际上包含了所有的底层设计细节,这些细节信息共同支撑了顶层的架构设计,底层设计信息和顶层架构设计共同组成了整个房屋的架构文档。
软件设计也是如此。底层设计细节和高层架构信息是不可分割的。它们组合在一起,共同定义了整个软件系统,缺一不可。所谓的底层和高层本身就是一系列决策组成的连续体,并没有清晰的分界线。
一个好的软件设计的终极目标是什么呢?
软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求。
一个软件架构的优劣,可以用它满足用户需求所需要的成本来衡量。如果该成本很低,并且在系统的整个生命周期内一直都能维持这样的低成本,那么这个系统的设计就是优良的。如果该系统的每次发布都会提升下一次变更的成本,那么这个设计就是不好的。就这么简单。
要想跑得快,先要跑得稳。
现在的软件研发工程师都有点过于自信。哦,当然,他们确实不会偷懒,一点也不。但是他们真正偷懒的地方在于——持续低估那些好的、良好设计的、整洁的代码的重要性。
工程师们普遍用一句话来欺骗自己:“我们可以未来再重构代码,产品上线最重要!”但是结果大家都知道,产品上线以后重构工作就再没人提起了。市场的压力永远也不会消退,作为首先上市的产品,后面有无数的竞争对手追赶,必须要比他们跑得更快才能保持领先。
所以,重构的时机永远不会再有了。工程师们忙于完成新功能,新功能做不完,哪有时间重构老的代码?循环往复,系统成了一团乱麻,生产效率持续直线下降,直至为零。
当然,某些软件研发工程师可能会认为挽救一个系统的唯一办法是抛弃现有系统,设计一个全新的系统来替代。但是这里仍然没有逃离过度自信。试问:如果是工程师的过度自信导致了目前的一团乱麻,那么,我们有什么理由认为让他们从头开始,结果就会更好呢?
两个价值维度
对于每个软件系统,我们都可以通过行为和架构两个维度来体现它的实际价值。软件研发人员应该确保自己的系统在这两个维度上的实际价值都能长时间维持在很高的状态。不幸的是,他们往往只关注一个维度,而忽视了另外一个维度。更不幸的是,他们常常关注的还是错误的维度,这导致了系统的价值最终趋降为零
行为价值
软件系统的行为是其最直观的价值维度。程序员的工作就是让机器按照某种指定方式运转,给系统的使用者创造或者提高利润。程序员们为了达到这个目的,往往需要帮助系统使用者编写一个对系统功能的定义,也就是需求文档。然后,程序员们再把需求文档转化为实际的代码。
当机器出现异常行为时,程序员要负责调试,解决这些问题。
大部分程序员认为这就是他们的全部工作。他们的工作是且仅是:按照需求文档编写代码,并且修复任何Bug。这真是大错特错。
架构价值
软件系统必须保持灵活。软件发明的目的,就是让我们可以以一种灵活的方式来改变机器的工作行为。对机器上那些很难改变的工作行为,我们通常称之为硬件(hardware)。
为了达到软件的本来目的,软件系统必须够“软”——也就是说,软件应该容易被修改。当需求方改变需求的时候,随之所需的软件变更必须可以简单而方便地实现。
需求变更的范畴与形状,是决定对应软件变更实施成本高低的关键。这就是为什么有的代码变更的成本与其实现的功能改变不成比例。这也是为什么第二年的研发成本比第一年的高很多,第三年又比第二年更高。
哪个价值维度更重要
如果某程序可以正常工作,但是无法修改,那么当需求变更的时候它就不再能够正常工作了,我们也无法通过修改让它能继续正常工作。因此,这个程序的价值将成为0。
如果某程序目前无法正常工作,但是我们可以很容易地修改它,那么将它改好,并且随着需求变化不停地修改它,都应该是很容易的事。因此,这个程序会持续产生价值。
为好的软件架构而持续斗争
如果你是软件架构师,那么这项工作就加倍重要了。软件架构师这一职责本身就应更关注系统的整体结构,而不是具体的功能和系统行为的实现。软件架构师必须创建出一个可以让功能实现起来更容易、修改起来更简单、扩展起来更轻松的软件架构。
请记住:如果忽视软件架构的价值,系统将会变得越来越难以维护,终会有一天,系统将会变得再也无法修改。如果系统变成了这个样子,那么说明软件开发团队没有和需求方做足够的抗争,没有完成自己应尽的职责。
SOLID原则
- SRP:单一职责原则 该设计原则是基于康威定律(Conway’s Law)的一个推论——一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。这样,每个软件模块都有且只有一个需要被改变的理由。
- OCP:开闭原则 该设计原则是由Bertrand Meyer在20世纪80年代大力推广的,其核心要素是:如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。
- LSP:里氏替换原则。 该设计原则是Barbara Liskov在1988年提出的一个著名的子类型定义。简单来说,这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。
- ISP:接口隔离原则 这项设计原则主要告诫软件设计师应该在设计中避免不必要的依赖。
- DIP:依赖反转原则。 该设计原则指出高层策略性的代码不应该依赖实现底层细节的代码,恰恰相反,那些实现底层细节的代码应该依赖高层策略性的代码。
SPR单一职责原则
任何一个软件模块都应该有且仅有一个被修改的原因。
在现实环境中,软件系统为了满足用户和所有者的要求,必然要经常做出这样那样的修改。而该系统的用户或者所有者就是该设计原则中所指的“被修改的原因”。所以,我们也可以这样描述SRP:
任何一个软件模块都应该只对一个用户(User)或系统利益相关者(Stakeholder)负责。
不过,这里的“用户”和“系统利益相关者”在用词上也并不完全准确,它们很有可能指的是一个或多个用户和利益相关者,只要这些人希望对系统进行的变更是相似的,就可以归为一类——一个或多个有共同需求的人。在这里,我们将其称为行为者(actor)。
所以,对于SRP的最终描述就变成了:
任何一个软件模块都应该只对某一类行为者负责。
SPR案例:
某个工资管理程序中的Employee类有三个函数calculatePay()、reportHours()和save()
如你所见,这个类的三个函数分别对应的是三类非常不同的行为者,违反了SRP设计原则。
calculatePay()函数是由财务部门制定的,他们负责向CFO汇报。
reportHours()函数是由人力资源部门制定并使用的,他们负责向COO汇报。
save()函数是由DBA制定的,他们负责向CTO汇报。
这三个函数被放在同一个源代码文件,即同一个Employee类中,程序员这样做实际上就等于使三类行为者的行为耦合在了一起,这有可能会导致CFO团队的命令影响到COO团队所依赖的功能。
例如,calculatePay()函数和reportHours()函数使用同样的逻辑来计算正常工作时数。程序员为了避免重复编码,通常会将该算法单独实现为一个名为regularHours()的函数
接下来,假设CFO团队需要修改正常工作时数的计算方法,而COO带领的HR团队不需要这个修改,因为他们对数据的用法是不同的。
于是,该程序员就这样按照要求进行了修改,同时CFO团队的成员验证了新算法工作正常。这项修改最终被成功部署上线了。
但是,COO 团队显然完全不知道这些事情的发生,HR 仍然在使用reportHours()产生的报表,随后就会发现他们的数据出错了!最终这个问题让COO十分愤怒,因为这些错误的数据给公司造成了几百万美元的损失。
解决方案
我们有很多不同的方法可以用来解决上面的问题,每一种方法都需要将相关的函数划分成不同的类。
其中,最简单直接的办法是将数据与函数分离,设计三个类共同使用一个不包括函数的、十分简单的EmployeeData类,每个类只包含与之相关的函数代码,互相不可见,这样就不存在互相依赖的情况
这种解决方案的坏处在于:程序员现在需要在程序里处理三个类。另一种解决办法是使用Facade设计模式
这样一来,EmployeeFacade类所需要的代码量就很少了,它仅仅包含了初始化和调用三个具体实现类的函数。
当然,也有些程序员更倾向于把最重要的业务逻辑与数据放在一起,那么我们也可以选择将最重要的函数保留在Employee类中,同时用这个类来调用其他没那么重要的函数
有的人也许会反对上面这些解决方案,因为看上去这里的每个类中都只有一个函数。事实上并非如此,因为无论是计算工资、生成报表还是保存数据都是一个很复杂的过程,每个类都可能包含了许多私有函数。
单一职责原则主要讨论的是函数和类之间的关系——但是它在两个讨论层面上会以不同的形式出现。在组件层面,我们可以将其称为共同闭包原则(Common Closure Principle),在软件架构层面,它则是用于奠定架构边界的变更轴心(Axis of Change)。
OCP开闭原则
开闭原则(OCP)原则认为:
设计良好的计算机软件应该易于扩展,同时抗拒修改。
换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。
其实这也是我们研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显然是失败的。
OCP案例:
假设我们现在要设计一个在Web页面上展示财务数据的系统,页面上的数据要可以滚动显示,其中负值应显示为红色。
接下来,该系统的所有者又要求同样的数据需要形成一个报表,该报表要能用黑白打印机打印,并且其报表格式要得到合理分页,每页都要包含页头、页尾及栏目名。同时,负值应该以括号表示。
显然,我们需要增加一些代码来完成这个要求。但在这里我们更关注的问题是,满足新的要求需要更改多少旧代码。
一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为0。
但该如何实现这一点呢?我们可以先将满足不同需求的代码分组(即SRP),然后再来调整这些分组之间的依赖关系(即DIP)。
利用SRP,我们可以按下图中所展示的方式来处理数据流。即先用一段分析程序处理原始的财务数据,以形成报表的数据结构,最后再用两个不同的报表生成器来产生报表。
这里的核心就是将应用生成报表的过程拆成两个不同的操作。即先计算出报表数据,再生成具体的展示报表(分别以网页及纸质的形式展示)
接下来,我们就该修改其源代码之间的依赖关系了。这样做的目的是保证其中一个操作被修改之后不会影响到另外一个操作。同时,我们所构建的新的组织形式应该保证该程序后续在行为上的扩展都无须修改现有代码。
OCP是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。
LSP里氏替换原则
定义:如果对于每个类型是S的对象o1都存在一个类型为T的对象o2,能使操作T类型的程序P在用o2替换o1时行为保持不变,我们就可以将S称为T的子类型。
继承的使用指导
假设我们有一个License类,其结构如下图所示。该类中有一个名为calcFee()的方法,该方法将由Billing应用程序来调用。而License类有两个“子类型”:PersonalLicense与BusinessLicense,这两个类会用不同的算法来计算授权费用。
上述设计是符合LSP原则的,因为Billing应用程序的行为并不依赖于其使用的任何一个衍生类。也就是说,这两个衍生类的对象都是可以用来替换License类对象的。
LSP可以且应该被应用于软件架构层面,因为一旦违背了可替换性,该系统架构就不得不为此增添大量复杂的应对机制
ISP:接口隔离原则
下面我们来看一张图
在上图所描绘的应用中,有多个用户需要操作OPS类。现在,我们假设这里的User1只需要使用op1,User2只需要使用op2,User3只需要使用op3。
在这种情况下,如果OPS类是用Java编程语言编写的,那么很明显,User1虽然不需要调用op2、op3,但在源代码层次上也与它们形成依赖关系。这种依赖意味着我们对OPS代码中op2所做的任何修改,即使不会影响到User1的功能,也会导致它需要被重新编译和部署。
这个问题可以通过将不同的操作隔离成接口来解决,如下图所示
回顾一下ISP最初的成因:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的。从源代码层次来说,这样的依赖关系会导致不必要的重新编译和重新部署,对更高层次的软件架构设计来说,问题也是类似的。
例如,我们假设某位软件架构师在设计系统S时,想要在该系统中引入某个框架F。这时候,假设框架F的作者又将其捆绑在一个特定的数据库D上,那么就形成了S依赖于F,F又依赖于D的关系。
在这种情况下,如果D中包含了F不需要的功能,那么这些功能同样也会是S不需要的。而我们对D中这些功能的修改将会导致F需要被重新部署,后者又会导致S的重新部署。更糟糕的是,D中一个无关功能的错误也可能会导致F和S运行出错。
该设计原则告诉我们:任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。
依赖反转原则(DIP)
依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。
稳定的抽象层
我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。但反过来,当我们修改具体实现时,却很少需要去修改相应的抽象接口。所以我们可以认为接口比实现更稳定。
的确,优秀的软件设计师和架构师会花费很大精力来设计接口,以减少未来对其进行改动。毕竟争取在不修改接口的情况下为软件增加新的功能是软件设计的基础常识。
也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。下面,我们将该设计原则归结为以下几条具体的编码守则:
应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。这条守则适用于所有编程语言,无论静态类型语言还是动态类型语言。同时,对象的创建过程也应该受到严格限制,对此,我们通常会选择用抽象工厂(abstract factory)这个设计模式。
不要在具体实现类上创建衍生类。上一条守则虽然也隐含了这层意思,但它还是值得被单独拿出来做一次详细声明。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖——这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。这基本上是DIP原则的另外一个表达方式。
组件
组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于Java来说,它的组件是jar文件。而在Ruby中,它们是gem文件。在.Net中,它们则是DLL文件。
总而言之,在编译运行语言中,组件是一组二进制文件的集合。而在解释运行语言中,组件则是一组源代码文件的集合。无论采用什么编程语言来开发软件,组件都是该软件在部署过程中的最小单元。
那么,究竟是哪些类应该被组合成一个组件呢?这是一个非常重要的设计决策,应该遵循优秀的软件工程经验来行事。
下面我们会具体讨论以下三个与构建组件相关的基本原则:
- REP:复用/发布等同原则。
- CCP:共同闭包原则。
- CRP:共同复用原则
复用/发布等同原则
软件复用的最小粒度应等同于其发布的最小粒度。
REP原则初看起来好像是不言自明的。毕竟如果想要复用某个软件组件的话,一般就必须要求该组件的开发由某种发布流程来驱动,并且有明确的发布版本号。
这其中的一个原因是,如果没有设定版本号,我们就没有办法保证所有被复用的组件之间能够彼此兼容。另外更重要的一点是,软件开发者必须要能够知道这些组件的发布时间,以及每次发布带来了哪些变更。
只有这样,软件工程师才能在收到相关组件新版本发布的通知之后,依据该发布所变更的内容来决定是继续使用旧版本还是做些相应的升级,这是很基本的要求。因此,组件的发布过程还必须要能够产生适当的通知和发布文档,以便让它的用户根据这些信息做出有效的升级决策。
共同闭包原则
我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。
这其实是SRP原则在组件层面上的再度阐述。正如SRP原则中提到的“一个类不应该同时存在着多个变更原因”一样,CCP原则也认为一个组件不应该同时存在着多个变更原因。
对大部分应用程序来说,可维护性的重要性要远远高于可复用性。如果某程序中的代码必须要进行某些变更,那么这些变更最好都体现在同一个组件中,而不是分布于很多个组件中。因为如果这些变更都集中在同一个组件中,我们就只需要重新部署该组件,其他组件则不需要被重新验证、重新部署了。
另外,CCP原则和开闭原则(OCP)也是紧密相关的。CCP讨论的就是OCP中所指的“闭包”。OCP原则认为一个类应该便于扩展,而抗拒修改。由于100%的闭包是不可能的,所以我们只能战略性地选择闭包范围。在设计类的时候,我们需要根据历史经验和预测能力,尽可能地将需要被一同变更的那些点聚合在一起。
共同复用原则
不要强迫一个组件的用户依赖他们不需要的东西。
共同复用原则(CRP)是另外一个帮助我们决策类和模块归属于哪一个组件的原则。该原则建议我们将经常共同复用的类和模块放在同一个组件中。
通常情况下,类很少会被单独复用。更常见的情况是多个类同时作为某个可复用的抽象定义被共同复用。CRP原则指导我们将这些类放在同一个组件中,而在这样的组件中,我们应该预见到会存在着许多互相依赖的类。
一个简单的例子就是容器类与其相关的遍历器类,这些类之间通常是紧密相关的,一般会被共同复用,因此应该被放置在同一个组件中。
但是CRP的作用不仅是告诉我们应该将哪些类放在一起,更重要的是要告诉我们应该将哪些类分开。因为每当一个组件引用了另一个组件时,就等于增加了一条依赖关系。虽然这个引用关系仅涉及被引用组件中的一个类,但它所带来的依赖关系丝毫没有减弱。也就是说,引用组件已然依赖于被引用组件了。
由于这种依赖关系的存在,每当被引用组件发生变更时,引用它的组件一般也需要做出相应的变更。即使它们不需要进行代码级的变更,一般也免不了需要被重新编译、验证和部署。哪怕引用组件根本不关心被引用组件中的变更,也要如此。
因此,当我们决定要依赖某个组件时,最好是实际需要依赖该组件中的每个类。换句话说,我们希望组件中的所有类是不能拆分的,即不应该出现别人只需要依赖它的某几个类而不需要其他类的情况。否则,我们后续就会浪费不少时间与精力来做不必要的组件部署。
因此在CRP原则中,关于哪些类不应该被放在一起的建议是其更为重要的内容。简而言之,CRP原则实际上是在指导我们:不是紧密相连的类不应该被放在同一个组件里。
组件聚合张力图
简而言之,只关注REP和CRP的软件架构师会发现,即使是简单的变更也会同时影响到许多组件。相反,如果软件架构师过于关注CCP和REP,则会导致很多不必要的发布。
优秀的软件架构师应该能在上述三角张力区域中定位一个最适合目前研发团队状态的位置,同时也会根据时间不停调整。例如在项目早期,CCP原则会比REP原则更重要,因为在这一阶段研发速度比复用性更重要。
一般来说,一个软件项目的重心会从该三角区域的右侧开始,先期主要牺牲的是复用性。然后,随着项目逐渐成熟,其他项目会逐渐开始对其产生依赖,项目重心就会逐渐向该三角区域的左侧滑动。换句话说,一个项目在组件结构设计上的重心是根据该项目的开发时间和成熟度不断变动的,我们对组件结构的安排主要与项目开发的进度和它被使用的方式有关,与项目本身功能的关系其实很小。
无依赖环原则
组件依赖关系图中不应该出现环。
我们一定都有过这样的经历:当你花了一整天的时间,好不容易搞定了一段代码,第二天上班时却发现这段代码莫名其妙地又不能工作了。这通常是因为有人在你走后修改了你所依赖的某个组件。我给这种情况起了个名字——“一觉醒来综合征”。
这种综合征的主要病因是多个程序员同时修改了同一个源代码文件。虽然在规模相对较小、人员较少的项目中,这种问题或许并不严重,但是随着项目的增长,研发人员的增加,这种每天早上刚上班时都要经历一遍的痛苦就会越来越多。甚至会严重到让有的团队在长达数周的时间内都不能发布一个稳定的项目版本,因为每个人都在不停地修改自己的代码,以适应其他人所提交的变更。
在过去几十年中,针对这个问题逐渐演化出了两种解决方案,它们都来自电信行业。第一种是“每周构建”,第二种是“无依赖环原则(ADP)”。
每周构建
每周构建方案是中型项目中很常见的一种管理手段。其具体做法如下:在每周的前四天中,让所有的程序员在自己的私有库上工作,忽略其他人的修改,也不考虑互相之间的集成问题;然后在每周五要求所有人将自己所做的变更提交,进行统一构建。
上述方案确实可以让程序员们每周都有四天的时间放手干活。然而一到星期五,所有人都必须要花费大量的精力来处理前四天留下来的问题。
而且更不幸的是,随着项目越来越大,每周五的集成工作会越来越难以按时完成。而随着集成任务越来越重,周六的加班也会变得越来越频繁。经历过几次这样的加班之后,就会有人提出应该将集成任务提前到星期四开始,就这样一步一步地,集成工作慢慢地就要占用掉差不多半周的时间。
事实上,这个问题最终还会造成更大的麻烦。因为如果我们想要保持高效率的开发,就不能频繁地进行构建操作,但是如果我们减少了构建次数,延长了项目被构建的时间间隔,又会影响到该项目的质量,增大它的风险。整个项目会变得越来越难以构建与测试,团队反馈周期会越来越长,研发质量自然也会越来越差。
消除循环依赖
对于上述情景,我们的解决办法是将研发项目划分为一些可单独发布的组件,这些组件可以交由单人或者某一组程序员来独立完成。当有人或团队完成某个组件的某个版本时,他们就会通过发布机制通知其他程序员,并给该组件打一个版本号,放入一个共享目录。这样一来,每个人都可以依赖于这些组件公开发布的版本来进行开发,而组件开发者则可以继续去修改自己的私有版本。
每当一个组件发布新版本时,其他依赖这个组件的团队都可以自主决定是否立即采用新版本。若不采用,该团队可以选择继续使用旧版组件,直到他们准备好采用新版本为止。
这样就不会出现团队之间相互依赖的情况了。任何一个组件上的变更都不会立刻影响到其他团队。每个团队都可以自主决定是否立即集成自己所依赖组件的新版本。更重要的是,这种方法使我们的集成工作能以一种小型渐进的方式来进行。程序员们再也不需要集中在一起,统一集成相互的变更了。
如你所见,上述整个过程既简单又很符合逻辑,因而得到了各个研发团队的广泛采用。但是,如果想要成功推广这个开发流程,就必须控制好组件之间的依赖结构,绝对不能允许该结构中存在着循环依赖关系。如果某项目结构中存在着循环依赖关系,那么“一觉醒来综合征”将是不可避免的。
自上而下的设计
组件结构图是不可能自上而下被设计出来的。它必须随着软件系统的变化而变化和扩张,而不可能在系统构建的最初就被完美设计出来。
组件结构图中的一个重要目标是指导如何隔离频繁的变更。我们不希望那些频繁变更的组件影响到其他本来应该很稳定的组件,例如,我们通常不会希望无关紧要的GUI变更影响到业务逻辑组件;我们也不希望对报表的增删操作影响到其高阶策略。出于这样的考虑,软件架构师们才有必要设计并且铸造出一套组件依赖关系图来,以便将稳定的高价值组件与常变的组件隔离开,从而起到保护作用。
另外,随着应用程序的增长,创建可重用组件的需要也会逐渐重要起来。这时CRP又会开始影响组件的组成。最后当循环依赖出现时,随着无循环依赖原则(ADP)的应用,组件依赖关系会产生相应的抖动和扩张。
如果我们在设计具体类之前就来设计组件依赖关系,那么几乎是必然要失败的。因为在当下,我们对项目中的共同闭包一无所知,也不可能知道哪些组件可以复用,这样几乎一定会创造出循环依赖的组件。因此,组件依赖关系是必须要随着项目的逻辑设计一起扩张和演进的。
稳定依赖原则
依赖关系必须要指向更稳定的方向。
设计这件事不可能是完全静止的,如果我们要让一个设计是可维护的,那么其中某些部分就必须是可变的。通过遵守共同闭包原则(CCP),我们可以创造出对某些变更敏感,对其他变更不敏感的组件。这其中的一些组件在设计上就已经是考虑了易变性,预期它们会经常发生变更的。
任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖,否则这个多变的组件也将会变得非常难以被修改。
这就是软件开发的困难之处,我们精心设计的一个容易被修改的组件很可能会由于别人的一条简单依赖而变得非常难以被修改。即使该模块中没有一行代码需要被修改,但是整个模块在被修改时所面临的挑战性也已经存在了。而通过遵守稳定依赖原则(SDP),我们就可以确保自己设计中那些容易变更的模块不会被那些难于修改的组件所依赖。
稳定性
下面来看看Webster在线字典中的描述:稳定指的是“很难移动”。所以稳定性应该与变更所需的工作量有关。例如,硬币是不稳定的,因为只需要很小的动作就可以推倒它,而桌子则是非常稳定的,因为将它掀翻需要很大的动作。
但如果将这套理论关联到软件开发的问题上呢?软件组件的变更困难度与很多因素有关,例如代码的体量大小、复杂度、清晰度等。我们在这里会忽略这些因素,只集中讨论一个特别的因素——让软件组件难于修改的一个最直接的办法就是让很多其他组件依赖于它。带有许多入向依赖关系的组件是非常稳定的,因为它的任何变更都需要应用到所有依赖它的组件上。
在下图中,X是一个稳定的组件。因为有三个组件依赖着X,所以X有三个不应该被修改的原因。这里就说X要对三个组件负责。另一方面,X不依赖于任何组件,所以不会有任何原因导致它需要被变更,我们称它为“独立”组件。
下面再来看看下图中的Y组件,这是一个非常不稳定的组件。由于没有其他的组件依赖Y,所以Y并不对任何组件负责。但因为Y同时依赖于三个组件,所以它的变更就可能由三个不同的源产生。这里就说Y是有依赖性的组件。
稳定性指标
那么,究竟该如何来量化一个组件的稳定性呢?其中一种方法是计算所有入和出的依赖关系。通过这种方法,我们就可以计算出一个组件的位置稳定性(positional stability)。
Fan-in:入向依赖,这个指标指代了组件外部类依赖于组件内部类的数量。
Fan-out:出向依赖,这个指标指代了组件内部类依赖于组件外部类的数量。
I:不稳定性,I=Fan-out/(Fan-in+Fan-out)。该指标的范围是[0,1],I=0意味着组件是最稳定的,I=1意味着组件是最不稳定的。
在这里,Fan-in和Fan-out这两个指标[6]是通过统计和组件内部类有依赖的组件外部类的数量来计算的,具体如下图所示。
在这里,我们想要计算组件Cc的稳定性指标,可以观察到有3个类在Cc外部,它们都依赖于Cc内部的类,因此Fan-in=3。此外,Cc中的一个类也依赖于组件外部的类,因此Fan-out=1,I=1/4。
当I指标等于1时,说明没有组件依赖当前组件(Fan-in=0),同时该组件却依赖于其他组件(Fan-out>0)。这是组件最不稳定的一种情况,我们认为这种组件是“不负责的(irresponsible)、对外依赖的(dependent)”。由于这个组件没有被其他组件依赖,所以自然也就没有力量会干预它的变更,同时也因为该组件依赖于其他组件,所以就必然会经常需要变更。
相反,当I=0的时候,说明当前组件是其他组件所依赖的目标(Fan-in>0),同时其自身并不依赖任何其他组件(Fan-out=0)。我们通常认为这样的组件是“负责的(responsibile)、不对外依赖的(independent)”。这是组件最具稳定性的一种情况,其他组件对它的依赖关系会导致这个组件很难被变更,同时由于它没有对外依赖关系,所以不会有来自外部的变更理由。
并不是所有组件都应该是稳定的
如果一个系统中的所有组件都处于最高稳定性状态,那么系统就一定无法再进行变更了,这显然不是我们想要的。事实上,我们设计组件架构图的目的就是要决定应该让哪些组件稳定,让哪些组件不稳定。譬如在下图中,我们所示范的就是一个具有三个组件的系统的理想配置。
在该系统组件结构图中,可变更的组件位于顶层,同时依赖于底层的稳定组件。将不稳定组件放在该结构图的顶层是很有用的,因为这样我们就可以很容易地找出箭头向上的依赖关系,而这些关系是违反SDP(以及后面将会讨论的ADP)的。
下面再通过下图来看看违反SDP的情况:
在上图中,Flexible是在设计中要确保其易于变更的组件,因此我们会希望Flexible是不稳定的。然而,Stable组件的开发人员却引入了对Flexible组件的依赖。这种情况就违反了SDP,因为Stable组件的I指标要远小于Flexible的I指标。这将导致Flexible组件的变更难度大大增加,因为对Flexible组件的任何修改都必须要考虑Stable组件及该组件自身存在的依赖关系。
如果想要修复这个问题,就必须要将Stable与Flexible这两个组件之间的依赖关系打破。为此,我们就需要了解这个依赖关系到底为什么会存在,这里假设是因为Stable组件中的某个类U需要使用Flexible组件中的一个类C,如下图所示:
我们可以利用DIP来修复这个问题。具体来说就是创造一个UServer组件,并在其中设置一个US接口类。然后,确保这个接口类中包含了所有U需要使用的函数,再让C实现这个接口,如下图所示。这样一来,我们就将从Stable到Flexible的这条依赖关系打破了,强迫这两个组件都依赖于UServer。现在,UServer组件会是非常稳定的(I=0),而Flexibile组件则会依然保持不稳定的状态(I=1),结构图中所有的依赖关系都流向I递减的方向了。
稳定抽象原则
一个组件的抽象化程度应该与其稳定性保持一致
高阶策略应该放在哪里
在一个软件系统中,总有些部分是不应该经常发生变更的。这些部分通常用于表现该系统的高阶架构设计及一些策略相关的高阶决策。我们不想让这些业务决策和架构设计经常发生变更,因此这些代表了系统高阶策略的组件应该被放到稳定组件(I=0)中,而不稳定的组件(I=1)中应该只包含那些我们想要快速和方便修改的部分。
然而,如果我们将高阶策略放入稳定组件中,那么用于描述那些策略的源代码就很难被修改了。这可能会导致整个系统的架构设计难于被修改。如何才能让一个无限稳定的组件(I=0)接受变更呢?开闭原则(OCP)为我们提供了答案。这个原则告诉我们:创造一个足够灵活、能够被扩展,而且不需要修改的类是可能的,而这正是我们所需要的。哪一种类符合这个原则呢?答案是抽象类。
稳定抽象原则简介
稳定抽象原则(SAP)为组件的稳定性与它的抽象化程度建立了一种关联。一方面,该原则要求稳定的组件同时应该是抽象的,这样它的稳定性就不会影响到扩展性。另一方面,该原则也要求一个不稳定的组件应该包含具体的实现代码,这样它的不稳定性就可以通过具体的代码被轻易修改。
因此,如果一个组件想要成为稳定组件,那么它就应该由接口和抽象类组成,以便将来做扩展。如此,这些既稳定又便于扩展的组件可以被组合成既灵活又不会受到过度限制的架构。
衡量抽象化程度
下面,假设A指标是对组件抽象化程度的一个衡量,它的值是组件中抽象类与接口所占的比例。那么:
Nc:组件中类的数量。
Na:组件中抽象类和接口的数量。
A:抽象程度,A=Na÷Nc。
A指标的取值范围是从0到1,值为0代表组件中没有任何抽象类,值为1就意味着组件中只有抽象类。
主序列
现在,我们可以来定义组件的稳定性I与其抽象化程度A之间的关系了,具体如下图所示。在该图中,纵轴为A值,横轴为I值。如果我们将两个“设计良好”的组件绘制在该图上,那么最稳定的、包含了无限抽象类的组件应该位于左上角(0,1),最不稳定的、最具体的组件应该位于右下角(1,0)。
当然,不可能所有的组件都能处于这两个位置上,因为组件通常都有各自的稳定程度和抽象化程度。例如一个抽象类有时会衍生于另一个抽象类,这种情况是很常见的,而这个衍生过程就意味着某种依赖关系的产生。因此,虽然该组件是全抽象的,但它并不是完全稳定的,上述依赖关系降低了它的稳定程度。
既然不能强制要求所有的组件都处于(0,1)和(1,0)这两个位置上,那么就必须假设A/I图上存在着一个合理组件的区间。而这个区间应该可以通过排除法推导出来,也就是说,我们可以先找出那些组件不应该处于的位置(请参考下图)
痛苦区
在上图中,假设某个组件处于(0,0)位置,那么它应该是一个非常稳定但也非常具体的组件。这样的组件在设计上是不佳的,因为它很难被修改,这意味着该组件不能被扩展。这样一来,因为这个组件不是抽象的,而且它又由于稳定性的原因变得特别难以被修改,我们并不希望一个设计良好的组件贴近这个区域,因此(0,0)周围的这个区域被我们称为痛苦区(zone of pain)。
当然,有些软件组件确实会处于这个区域中,这方面的一个典型案例就是数据库的表结构(schema)。它在可变性上可谓臭名昭著,但是它同时又非常具体,并被非常多的组件依赖。这就是面向对象应用程序与数据库之间的接口这么难以管理,以及每次更新数据库的过程都那么痛苦的原因。
另一个会处于这个区域的典型软件组件是工具型类库。虽然这种类库的I指标为1,但事实上通常是不可变的。例如String组件,虽然其中所有的类都是具体的,但由于它被使用得太过普遍,任何修改都会造成大范围的混乱,因此String组件只能是不可变的。
不可变组件落在(0,0)这一区域中是无害的,因为它们不太可能会发生变更。正因为如此,只有多变的软件组件落在痛苦区中才会造成麻烦,而且组件的多变性越强,造成的麻烦就会越大。其实,我们应该将多变性作为图14.13的第三个轴,这时图14.13所展示的便是多变性=1时的情况,也就是最痛苦的切面。
无用区
现在我们来看看靠近(1,1)这一位置点的组件。该位置上的组件不会是我们想要的,因为这些组件通常是无限抽象的,但是没有被其他组件依赖,这样的组件往往无法使用。因此我们将这个区域称为无用区。
对于这个区域中的软件组件来说,其源码或者类中的设计问题通常是由于历史原因造成的。例如我们常常会在系统的某个角落里看到某个没有人实现的抽象类,它们一直静静地躺在那里,没有人使用。
同样的,落在无用区中的组件也一定会包含大量的无用代码。很明显,这类组件也不是我们想要的。
避开这两个区域
很明显,最多变的组件应该离上述两个区域越远越好。在图14.13中,我们可以将距离两个区域最远的点连成一条线,即从(1,0)连接到(0,1)。我将这条线称为主序列线(mainsequence)。[7]
坐落于主序列线上的组件不会为了追求稳定性而被设计得“太过抽象”,也不会为了避免抽象化而被设计得“太过不稳定”。这样的组件既不会特别难以被修改,又可以实现足够的功能。对于这些组件来说,通常会有足够多的组件依赖于它们,这使得它们会具有一定程度的抽象,同时它们也依赖了足够多的其他组件,这又使得它一定会包含很多具体实现。
在整条主序列线上,组件所能处于最优的位置是线的两端。一个优秀的软件架构师应该争取将自己设计的大部分组件尽可能地推向这两个位置。然而,以我的个人经验来说,大型系统中的组件不可能做到完全抽象,也不可能做到完全稳定。所以我们只要追求让这些组件位于主序列线上,或者贴近这条线即可。
离主序列线的距离
接下来介绍最后一个指标:如果让组件位于或者靠近主序列是可取的目标,那么我们就可以创建一个指标来衡量一个组件距离最佳位置的距离。
D指标[8]:距离D=|A+I-1|,该指标的取值范围是[0,1]。值为0意味着组件是直接位于主序列线上的,值为1则意味着组件在距离主序列最远的位置。
通过计算每个组件的D指标,就可以量化一个系统设计与主序列的契合程度了。另外,我们也可以用D指标大于0多少来指导组件的重构与重新设计。
除此之外,通过计算设计中所有组件的D指标的平均值和方差,我们还可以用统计学的方法来量化分析一个系统设计。对于一个良好的系统设计来说,D指标的平均值和方差都应该接近于0。其中,方差还可以被当作组件的“达标红线”来使用,我们可以通过它找出系统设计中那些不合常规的组件。
在下图中,我们可以看到大部分的组件都位于主序列附近,但是有些组件处于平均值的标准差(Z=1)以外。这些组件值得被重点分析,它们要么过于抽象但依赖不足,要么过于具体而被依赖得太多。
D指标的另外一种用法是按时间来跟踪每个组件的值,下面用下图来做一个示范。在该图中可以看到,Payroll组件在最近几次发布中累积了一些意外的对外依赖。图中的D=0.1是组件的达标红线,R2.1这个值已经超出了红线范围,这就告诉我们现在值得花一些精力来找出这个组件偏离主序列线的原因了。
软件架构
“架构”这个词给人的直观感受就充满了权力与神秘感,因此谈论架构总让人有一种正在进行责任重大的决策或者深度技术分析的感觉。
那么,究竟什么才是“软件架构”呢?软件架构师的工作内容究竟是什么?这项工作又是什么时候进行的呢?
首先,软件架构师自身需要是程序员,并且必须一直坚持做一线程序员,绝对不要听从那些说应该让软件架构师从代码中解放出来以专心解决高阶问题的伪建议。不是这样的!软件架构师其实应该是能力最强的一群程序员,他们通常会在自身承接编程任务的同时,逐渐引导整个团队向一个能够最大化生产力的系统设计方向前进。也许软件架构师生产的代码量不是最多的,但是他们必须不停地承接编程任务。如果不亲身承受因系统设计而带来的麻烦,就体会不到设计不佳所带来的痛苦,接着就会逐渐迷失正确的设计方向。
软件系统的架构质量是由它的构建者所决定的,软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。
而设计软件架构的目的,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。
软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。
开发(Development)
一个开发起来很困难的软件系统一般不太可能会有一个长久、健康的生命周期,所以系统架构的作用就是要方便其开发团队对它的开发。
这意味着,不同的团队结构应该采用不同的架构设计。一方面,对于一个只有五个开发人员的小团队来说,他们完全可以非常高效地共同开发一个没有明确定义组件和接口的单体系统(monolithicsystem)。事实上,这样的团队可能会发现软件架构在早期开发中反而是一种障碍。这可能就是为什么许多系统都没有设计一个良好架构的原因,因为它们的开发团队起初都很小,不需要设计一些上层建筑来限制某些事情。
但另一方面,如果一个软件系统是由五个不同的团队合作开发的,而每个团队各自都有七个开发人员的话,不将系统划分成定义清晰的组件和可靠稳定的接口,开发工作就没法继续推进。通常,如果忽略其他因素,该系统的架构会逐渐演变成五个组件,一个组件对应一个团队。
运行(Operation)
软件架构对系统运行的影响远不及它对开发、部署和维护的影响。几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计。
事实上,我们长期以来就一直目睹着这种情况一再发生。对于一个因架构设计糟糕而效率低下的系统,我们通常只需要增加更多的存储器与服务器,就能够让它圆满地完成任务。另外,硬件也远比人力要便宜,这也是软件架构对系统运行的影响远没有它对开发、部署、维护的影响那么深远的一个原因。
当然,这并不是说我们不应该为了让系统能更好地运转而优化软件的架构设计,这样做是应该的,只是基于投入/产出比的考虑,我们的优化重心应该更倾向于系统的开发、部署以及维护。
维护(Maintenance)
在软件系统的所有方面中,维护所需的成本是最高的。满足永不停歇的新功能需求,以及修改层出不穷的系统缺陷这些工作将会占去绝大部分的人力资源。
系统维护的主要成本集中在“探秘”和“风险”这两件事上。其中,“探秘(spelunking)”的成本主要来自我们对于现有软件系统的挖掘,目的是确定新增功能或被修复问题的最佳位置和最佳方式。而“风险(risk)”,则是指当我们进行上述修改时,总是有可能衍生出新的问题,这种可能性就是风险成本。
我们可以通过精雕细琢的架构设计极大地降低这两项成本。通过将系统切分为组件,并使用稳定的接口将组件隔离,我们可以将未来新功能的添加方式明确出来,并大幅度地降低在修改过程中对系统其他部分造成伤害的可能性。
保持可选项
我们让软件维持“软”性的方法就是尽可能长时间地保留尽可能多的可选项。那么到底哪些选项是我们应该保留的?它们就是那些无关紧要的细节设计。
基本上,所有的软件系统都可以降解为策略与细节这两种主要元素。策略体现的是软件中所有的业务规则与操作过程,因此它是系统真正的价值所在。
而细节则是指那些让操作该系统的人、其他系统以及程序员们与策略进行交互,但是又不会影响到策略本身的行为。它们包括I/O设备、数据库、Web系统、服务器、框架、交互协议等。
软件架构师的目标是创建一种系统形态,该形态会以策略为最基本的元素,并让细节与策略脱离关系,以允许在具体决策过程中推迟或延迟与细节相关的内容。
例如,
在开发的早期阶段应该无须选择数据库系统,因为软件的高层策略不应该关心其底层到底使用哪一种数据库。
在开发的早期阶段也不应该选定使用的Web服务,因为高层策略并不应该知道自己未来要以网页形式发布。
在开发的早期阶段不应该过早地采用REST模式,因为软件的高层策略应该与外部接口无关。同样的,我们也不应该过早地考虑采用微服务框架、SOA框架等。再说一遍,软件的高层策略压根不应该跟这些有关。
如果在开发高层策略时有意地让自己摆脱具体细节的纠缠,我们就可以将与具体实现相关的细节决策推迟或延后,因为越到项目的后期,我们就拥有越多的信息来做出合理的决策。
另外,我们保留这些可选项的时间越长,实验的机会也就越多。而实验做得越多,我们做决策的时候就能拥有越充足的信息。
一个优秀的软件架构师应该致力于最大化可选项数量。
重复
架构师们经常会钻进一个牛角尖——害怕重复。
当然,重复在软件行业里一般来说都是坏事。我们不喜欢重复的代码,当代码真的出现重复时,我们经常会感到作为一个专业人士,自己是有责任减少或消除这种重复的。
但是重复也存在着很多种情况。其中有些是真正的重复,在这种情况下,每个实例上发生的每项变更都必须同时应用到其所有的副本上。重复的情况中也有一些是假的,或者说这种重复只是表面性的。如果有两段看起来重复的代码,它们走的是不同的演进路径,也就是说它们有着不同的变更速率和变更缘由,那么这两段代码就不是真正的重复。等我们几年后再回过头来看,可能就会发现这两段代码是非常不一样的了。
现在,我们假设某系统中有两个用例在屏幕展现形式上非常类似。每当这种时候,架构师们就很可能非常想复用同一段代码来处理它们的屏幕展示。那么,我们到底是否应该这样做呢?这里是真正的重复,还只是一种表面性的重复?
恐怕这里很可能只是表面性的重复。随着时间推移,这两个用例的屏幕展示功能可能会各自演变,最终很可能完全不同。正是由于这样的原因,我们必须加倍小心地避免让这两个用例复用同一段代码,否则,未来再想将它们分开会面临很大的挑战。
当我们按用例垂直切分系统时,这样的问题会经常出现。我们经常遇到一些不同的用例为了上述原因被耦合在了一起。不管是因为它们展现形式类似,还是使用了相似的语法、相似的数据库查询/表结构等,总之,我们一定要小心避免陷入对任何重复都要立即消除的应激反应模式中。一定要确保这些消除动作只针对那些真正意义上的重复。
同样的道理,当我们对系统进行水平分层时,也可能会发现某个数据库记录的结构和某个屏幕展示的数据接口非常相似。我们可能也会为了避免再创建一个看起来相同的视图模型并在两者之间复制元素,而选择直接将数据库记录传递给UI层。我们也一定要小心,这里几乎肯定只是一种表面性的重复。而且,另外创建一个视图模型并不会花费太多力气,这可以帮助我们保持系统水平分层之间的隔离。
单体结构
一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。
并且,一个设计良好的架构在上述过程中还应该能保护系统的大部分源码不受变更影响。对整个系统来说,解耦模式也应该是一个可选项。我们在进行大型部署时可以采用一种模式,而在进行小型部署时则可以采用另一种模式
划分边界
软件架构设计本身就是一门划分边界的艺术。边界的作用是将软件分割成各种元素,以便约束边界两侧之间的依赖关系。其中有一些边界是在项目初期——甚至在编写代码之前——就已经划分好,而其他的边界则是后来才划分的。在项目初期划分这些边界的目的是方便我们尽量将一些决策延后进行,并且确保未来这些决策不会对系统的核心业务逻辑产生干扰。
架构师们所追求的目标是最大限度地降低构建和维护一个系统所需的人力资源。那么我们就需要了解一个系统最消耗人力资源的是什么?答案是系统中存在的耦合——尤其是那些过早做出的、不成熟的决策所导致的耦合。
那么,怎样的决策会被认为是过早且不成熟的呢?答案是那些决策与系统的业务需求(也就是用例)无关。这部分决策包括我们要采用的框架、数据库、Web服务器、工具库、依赖注入等。在一个设计良好的系统架构中,这些细节性的决策都应该是辅助性的,可以被推迟的。一个设计良好的系统架构不应该依赖于这些细节,而应该尽可能地推迟这些细节性的决策,并致力于将这种推迟所产生的影响降到最低。
应在何时、何处画这些线
边界线应该画在那些不相关的事情中间。GUI与业务逻辑无关,所以两者之间应该有一条边界线。数据库与GUI无关,这两者之间也应该有一条边界线。数据库又与业务逻辑无关,所以两者之间也应该有一条边界线。
数据库应该是业务逻辑间接使用的一个工具。业务逻辑并不需要了解数据库的表结构、查询语言或其他任何数据库内部的实现细节。业务逻辑唯一需要知道的,就是有一组可以用来查询和保存数据的函数。这样一来,我们才可以将数据库隐藏在接口后面。
我们可以从下图中清晰地看到,BusinessRules是通过DatabaseInterface来加载和保存数据的。而DatabaseAccess则负责实现该接口,以及其与实际Database的交互。
这里的类与接口仅仅是一个例子。在一个真实的应用程序中,将会有很多业务逻辑类、很多数据库接口类以及很多数据库访问的实现。不过,所有一切所遵循的模式应该是相似的。
那么这里的边界线应该被画在哪里?边界应该穿过继承关系,在DatabaseInterface之下(见下图)
请注意,DatabaseAccess类的那两个对外的箭头。这两个箭头都指向了远离DatabaseAccess 类的方向,这意味着它们所指向的两个类都不知道DatabaseAccess类的存在。
下面让我们把抽象层次拉高一点,看一下包含多个业务逻辑类的组件与包含数据库及其访问类的组件之间是什么关系(见下图)。
请注意,上图中的箭头指向,它说明了Database组件知道BusinessRules组件的存在,而BusinessRules组件则不知道Database组件的存在。这意味着DatabaseInterface类是包含在BusinessRules组件中的,而DatabaseAccess类则被包含在Database组件中。
这个箭头的方向很重要。因为它意味着Database组件不会对BusinessRules组件形成干扰,但Database组件却不能脱离BusinessRules组件而存在。
请记住一点,Database组件中包含了将BusinessRules组件中的函数调用转化为具体数据库查询语言的代码。这些转换代码当然必须知道BusinessRules组件的存在。
通过在这两个组件之间画边界线,并且让箭头指向BusinessRules组件,我们现在可以很容易地明白为什么BusinessRules组件可以使用任何一种数据库。在这里,Database组件可以被替换为多种实现,BusinessRules组件并不需要知道这件事。
数据库可以用Oracle、MySQL、Couch或者Datomic,甚至大文件来实现。业务逻辑并不需要关心这件事。这意味着我们可以将与数据库相关的决策延后,先专注于编写业务逻辑的代码,进行测试,直到不得不选择数据库为止。
插件式架构
事实上,软件开发技术发展的历史就是一个如何想方设法方便地增加插件,从而构建一个可扩展、可维护的系统架构的故事。系统的核心业务逻辑必须和其他组件隔离,保持独立,而这些其他组件要么是可以去掉的,要么是有多种实现的(见下图)
由于用户界面在这个设计中是以插件形式存在的,所以我们可以用插拔的方式切换很多不同类型的用户界面。可以是基于Web模式的、基于客户端/服务器端模式的、基于SOA模式的、基于命令行模式的或者基于其他任何类型的用户界面技术的。
数据库也类似。因为我们现在是将数据库作为插件来对待的,所以它就可以被替换成不同类型的SQL数据库、NoSQL数据库,甚至基于文件系统的数据库,以及未来任何一种我们认为有必要发展的数据库技术。
当然,这些替换工作可能并不轻松,如果我们的系统一开始是按照Web方式部署的,那么为它写一个客户端/服务器端模型的UI插件就可能会比较困难一些。很可能业务逻辑与新UI之间的交互方式也要重新修改。但即使这样,插件式架构也至少为我们提供了这种实现的可能性。
跨边界调用
在运行时,跨边界调用指的是边界线一侧的函数调用另一侧的函数,并同时传递数据的行为。构造合理的跨边界调用需要我们对源码中的依赖关系进行合理管控。
为什么需要管控源码中的依赖关系呢?因为当一个模块的源码发生变更时,其他模块的源码也可能会随之发生变更或重新编译,并需要重新部署。所谓划分边界,就是指在这些模块之间建立这种针对变更的防火墙。
服务
系统架构中最强的边界形式就是服务。一个服务就是一个进程,它们通常由命令行环境或其他等价的系统调用来产生。服务并不依赖于具体的运行位置,两个互相通信的服务既可以处于单一物理处理器或多核系统的同一组处理器上,也可以彼此位于不同的处理器上。服务会始终假设它们之间的通信将全部通过网络进行。
服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的,其往返时间可以从几十毫秒到几秒不等。因此我们在划分架构边界时,一定要尽可能地控制通信次数。在这个层次上通信必须能够适应高延时情况。
除此之外,我们可以在服务层次上使用与本地进程相同的规则。也就是让较低层次服务成为较高层次服务的“插件”。为此,我们要确保高层服务的源码中没有包含任何与低层服务相关的物理信息。
策略与层次
本质上,所有的软件系统都是一组策略语句的集合。是的,可以说计算机程序不过就是一组仔细描述如何将输入转化为输出的策略语句的集合。
在大多数非小型系统(nontrivial system)中,整体业务策略通常都可以被拆解为多组更小的策略语句。一部分策略语句专门用于描述计算部分的业务逻辑,另一部分策略语句则负责描述计算报告的格式。除此之外,可能还会有一些用于描述如何校验输入数据的策略。
软件架构设计的工作重点之一就是,将这些策略彼此分离,然后将它们按照变更的方式进行重新分组。其中变更原因、时间和层次相同的策略应该被分到同一个组件中。反之,变更原因、时间和层次不同的策略则应该分属于不同的组件。
架构设计的工作常常需要将组件重排组合成为一个有向无环图。图中的每一个节点代表的是一个拥有相同层次策略的组件,每一条单向链接都代表了一种组件之间的依赖关系,它们将不同级别的组件链接起来。
在一个设计良好的架构中,依赖关系的方向通常取决于它们所关联的组件层次。一般来说,低层组件被设计为依赖于高层组件。
业务逻辑
业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程。更严格地讲,无论这些业务逻辑是在计算机上实现的,还是人工执行的,它们在省钱/赚钱上的作用都是一样的。
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。我们将这种对象称为“业务实体(Entity)”
业务实体
业务实体实际上就是计算机系统中的一种对象,这种对象中包含了一系列用于操作关键数据的业务逻辑。这些实体对象要么直接包含关键业务数据,要么可以很容易地访问这些数据。业务实体的接口层则是由那些实现关键业务逻辑、操作关键业务数据的函数组成的。
业务逻辑是一个软件系统存在的意义,它们属于核心功能,是系统用来赚钱或省钱的那部分代码,是整个系统中的皇冠明珠。这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。
在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。
尖叫的软件架构
假设我们现在正在查看某个建筑的设计架构图,那么在这个反映建筑设计师精心设计成果的文件中,究竟应该包括怎样的架构图呢?
如果这是一幅单户住宅的建筑架构图,那么我们很可能会先看到一个大门,然后是一条连接到起居室的通道,同时可能还会看到一个餐厅。接着,距离餐厅不远处应该会有一个厨房,可能厨房附近还会有一个非正式用餐区,或一个亲子房。当我们阅读这个架构图时,应该不会怀疑这是一个单户住宅。几乎整个建筑设计都在尖叫着告诉你:这是一个“家”。
假设我们阅读的是一幅图书馆的建筑设计图,情况也差不多。我们应该会先看到一个超大入口,然后是一个用于签到/签出的办公区,接下来是阅读区、小型会议室,以及一排排的书架区。同样,几乎整个建筑设计都在尖叫着跟你说:这是一个“图书馆”。
那么,我们的应用程序的架构设计又会“喊”些什么呢?当我们查看它的顶层结构目录,以及顶层软件包中的源代码时,它们究竟是在喊“健康管理系统”“账务系统”“库存管理系统”,还是在喊:“Rails”“Spring/Hibernate”“ASP”这样的技术名词呢?
架构设计的核心目标
一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。这就好像一个住宅建筑设计的首要目标应该是满足住宅的使用需求,而不是确保一定要用砖来构建这个房子。
架构师应该花费很多精力来确保该架构的设计在满足用例需要的情况下,尽可能地允许用户能自由地选择建筑材料(砖头、石料或者木材)。
而且,良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web服务以及其他与环境相关的工具。框架应该是一个可选项,良好的架构设计应该允许用户在项目后期再决定是否采用Rails、Spring、Hibernate、Tomcat、MySQL这些工具。
一个系统的架构应该着重于展示系统本身的设计,而并非该系统所使用的框架。如果我们要构建的是一个医疗系统,新来的程序员第一次看到其源码时就应该知道这是一个医疗系统。新来的程序员应该先了解该系统的用例,而非系统的交付方式。
整洁架构
依赖关系规则
上图中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。
业务实体
业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。无论如何,只要它能被系统中的其他不同应用复用就可以。
如果我们在写的不是一个大型系统,而是一个单一应用的话,那么我们的业务实体就是该应用的业务对象。这些对象封装了该应用中最通用、最高层的业务逻辑,它们应该属于系统中最不容易受外界影响而变动的部分。例如,一个针对页面导航方式或者安全问题的修改不应该触及这些对象,一个针对应用在运行时的行为所做的变更也不应该影响业务实体。
用例
软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
我们既不希望在这一层所发生的变更影响业务实体,同时也不希望这一层受外部因素(譬如数据库、UI、常见框架)的影响。用例层应该与它们都保持隔离。
然而,我们知道应用行为的变化会影响用例本身,因此一定会影响用例层的代码。因为如果一个用例的细节发生了变化,这一层中的某些代码自然要受到影响。
接口适配器
软件的接口适配器层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统(譬如数据库以及Web)最方便操作的格式。例如,这一层中应该包含整个GUI MVC框架。展示器、视图、控制器都应该属于接口适配器层。而模型部分则应该由控制器传递给用例,再由用例传回展示器和视图。
同样的,这一层的代码也会负责将数据从对业务实体与用例而言最方便操作的格式,转化为对所采用的持久性框架(譬如数据库)最方便的格式。总之,在从该层再往内的同心圆中,其代码就不应该依赖任何数据库了。譬如说,如果我们采用的是SQL数据库,那么所有的SQL语句都应该被限制在这一层的代码中——而且是仅限于那些需要操作数据库的代码。
当然,这一层的代码也会负责将来自外部服务的数据转换成系统内用例与业务实体所需的格式。
框架与驱动程序
上图中最外层的模型层一般是由工具、数据库、Web框架等组成的。在这一层中,我们通常只需要编写一些与内层沟通的黏合性代码。
框架与驱动程序层中包含了所有的实现细节。Web是一个实现细节,数据库也是一个实现细节。我们将这些细节放在最外层,这样它们就很难影响到其他层了。
只有四层吗
图22.1中所显示的同心圆只是为了说明架构的结构,真正的架构很可能会超过四层。并没有某个规则约定一个系统的架构有且只能有四层。然而,这其中的依赖关系原则是不变的。也就是说,源码层面的依赖关系一定要指向同心圆的内侧。层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多。最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。
不完全边界
构建完整的架构边界是一件很耗费成本的事。在这个过程中,需要为系统设计双向的多态边界接口,用于输入和输出的数据结构,以及所有相关的依赖关系管理,以便将系统分割成可独立编译与部署的组件。这里会涉及大量的前期工作,以及大量的后期维护工作。
在很多情况下,一位优秀的架构师都会认为设计架构边界的成本太高了——但为了应对将来可能的需要,通常还是希望预留一个边界。
构建不完全边界的一种方式就是在将系统分割成一系列可以独立编译、独立部署的组件之后,再把它们构建成一个组件。换句话说,在将系统中所有的接口、用于输入/输出的数据格式等每一件事都设置好之后,仍选择将它们统一编译和部署为一个组件。
显然,这种不完全边界所需要的代码量以及设计的工作量,和设计完整边界时是完全一样的。但它省去了多组件管理这部分的工作,这就等于省去了版本号管理和发布管理方面的工作——这其中的工作量其实可不小。
单向边界
在设计一套完整的系统架构边界时,往往需要用反向接口来维护边界两侧组件的隔离性。而且,维护这种双向的隔离性,通常不会是一次性的工作,它需要我们持续地长期投入资源维护下去。
在下图中,你会看到一个临时占位的,将来可被替换成完整架构边界的更简单的结构。这个结构采用了传统的策略模式(strategy pattern)。如你所见,其Client使用的是一个由ServiceImpl类实现的ServiceBoundary接口。
很明显,上述设计为未来构建完整的系统架构边界打下了坚实基础。为了未来将Client与ServiceImpl隔离,必要的依赖反转已经做完了。同时,我们也能清楚地看到,图中的虚线箭头代表了未来有可能很快就会出现的隔离问题。由于没有采用双向反向接口,这部分就只能依赖开发者和架构师的自律性来保证组件持久隔离了。
门户模式
我们再来看一个更简单的架构边界设计:采用门户模式(facade pattern),其架构如下图所示。在这种模式下,我们连依赖反转的工作都可以省了。这里的边界将只能由Facade类来定义,这个类的背后是一份包含了所有服务函数的列表,它会负责将Client的调用传递给对Client不可见的服务函数
但需要注意的是,在该设计中,Client会传递性地依赖于所有的Service类。在静态类型语言中,这就意味着对Service类的源码所做的任何修改都会导致Client的重新编译。另外,我们应该也能想象得到为这种结构建立反向通道是多容易的事。
数据库只是实现细节
从系统架构的角度来看,数据库并不重要——它只是一个实现细节,在系统架构中并不占据重要角色。如果就数据库与整个系统架构的关系打个比方,它们之间就好比是门把手和整个房屋架构的关系。
为应用程序中的数据设计结构,对于系统架构来说当然是很重要的,但是数据库并不是数据模型。数据库只是一款软件,是用来存取数据的工具。
从系统架构的角度来看,工具通常是无关紧要的——因为这只是一个底层的实现细节,一种达成目标的手段。一个优秀的架构师是不会让实现细节污染整个系统架构的。
为什么数据库系统如此流行
为什么数据库系统在软件系统和企业软件领域如此流行?Oracle、MySQL和SQL Server这些产品广泛流行的原因是什么?答案是硬盘。
在磁盘上,数据是按照环形轨道存储的。这些轨道又会进一步被划分成一系列扇区,这些扇区的大小通常是4 KB。而每个盘片上都有几百条轨道,整个硬盘可能由十几个盘片组成。如果要从硬盘上读取某一个特定字节,需要将磁头挪到正确的轨道上,等待盘片旋转到正确的位置上,再将整个扇区读入内存中,从内存中查询对应的字节。这些过程当然需要时间,所以硬盘的访问速度一般在毫秒级。
毫秒级的速度看起来好像并不是很慢,但这已经比大多数处理器的速度慢一百万倍了。如果数据不在硬盘上,访问速度通常就通常是纳秒级,而不是毫秒级了。
为了应对硬盘访问速度带来的限制,必须使用索引、缓存以及查询优化器等技术。同时,我们还需要一种数据的标准展现格式,以便让索引、缓存及查询优化器来使用。概括来说,我们需要的就是某种数据访问与管理系统。
假设磁盘不存在会怎样
虽然硬盘现在还是很常见,但其实已经在走下坡路了。很快它们就会和磁带、软盘、CD一样成为历史,RAM正在替代一切。
现在,我们要来考虑一下:如果所有的数据都存在内存中,应该如何组织它们呢?需要按表格存储并且用SQL查询吗?需要用文件形式存储,然后按目录查找吗?
当然不,我们会将数据存储为链表、树、哈希表、堆栈、队列等各种各样的数据结构,然后用指针或者引用来访问这些数据——因为这对程序员来说是最自然的方式。
事实上,如果你再仔细想想,就会发现我们已经在这样做了。即使数据保存在数据库或者文件系统中,我们最终也会将其读取到内存中,并按照最方便的形式将其组织成列表、集合、堆栈、队列、树等各种数据结构,继续按文件和表格的形式来操作数据是非常少见的。
数据的组织结构,数据的模型,都是系统架构中的重要部分,但是从磁盘上存储/读取数据的机制和手段却没那么重要。关系型数据库强制我们将数据存储成表格并且以SQL访问,主要是为了后者。总而言之,数据本身很重要,但数据库系统仅仅是一个实现细节。
应用程序框架是实现细节
大部分框架的作者愿意免费提供自己的工作成果,是因为他们想要帮助整个社群,想要回馈社会。这值得鼓励,但不管这些作者的动机有多么高尚,恐怕也并没有提供针对你个人的最佳方案。即使他们想,也做不到,因为他们并不了解你,也不了解你遇到的问题。
这些框架作者所了解的都是他们自己遇到的问题,可能还包括亲戚朋友所遇到的。他们创造框架的目的是解决这些问题——而不是解决你遇到的问题。
当然,你所遇到的问题可能和其他人遇到的大体上一致。如果不是这样,框架也就不会那么流行了。正是由于这种重合性的存在,框架才这么有用。
单向婚姻
我们与框架作者之间的关系是非常不对等的。我们要采用某个框架就意味着自己要遵守一大堆约定,但框架作者却完全不需要为我们遵守什么约定。
请仔细想想这一关系,当我们决定采用一个框架时,就需要完整地阅读框架作者提供的文档。在这个文档中,框架作者和框架其他用户对我们提出进行应用整合的一些建议。一般来说,这些建议就是在要求我们围绕着该框架来设计自己的系统架构。譬如,框架作者会建议我们基于框架中的基类来创建一些派生类,并在业务对象中引入一些框架的工具。框架作者还会不停地催促我们将应用与框架结合得越紧密越好。
风险
那么我们要承担的风险究竟有哪些呢?我们可以想到的至少有以下这几项:
框架自身的架构设计很多时候并不是特别正确的。框架本身可能经常违反依赖关系原则。譬如,框架可能会要求我们将代码引入到业务对象中——甚至是业务实体中。框架可能会想要我们将框架耦合在最内圈代码中。而我们一旦引入,就再也不会离开该框架了,这就像戴上结婚戒指一样,从此一生不离不弃了。
框架可能会帮助我们实现一些应用程序的早期功能,但随着产品的成熟,功能要求很可能超出框架所能提供的范围。而且随着时间的推移,我们也会发现在应用的开发过程中,自己与框架斗争的时间要比框架帮助我们的时间长得多。
框架本身可能朝着我们不需要的方向演进。也许我们会被迫升级到一个并不需要的新版本,甚至会发现自己之前所使用的旧功能突然消失了,或悄悄改变了行为。
未来我们可能会想要切换到一个更新、更好的框架上。
解决方案
我们可以使用框架——但要时刻警惕,别被它拖住。我们应该将框架作为架构最外圈的一个实现细节来使用,不要让它们进入内圈。
如果框架要求我们根据它们的基类来创建派生类,就请不要这样做!我们可以创造一些代理类,同时把这些代理类当作业务逻辑的插件来管理。
另外,不要让框架污染我们的核心代码,应该依据依赖关系原则,将它们当作核心代码的插件来管理。
你必须明白,如果一旦在项目中引入一个框架,很有可能在整个生命周期中都要依赖于它,不管后来情形怎么变化,这个决定都很难更改了。因此,不应该草率地做出决定。