读Uncle Bob新书-Clean Archtiecture
不经意发现Bob大叔出版了新书,延续之前的命名风格定名为《Clean Architecture》,英文版已经于2017年底正式上市; 刚好公司的账户可以访问Orielly公司的在线书城,于是就断断续续地读了起来。 Bob大叔讲故事的能力即使在久负盛名的技术作家圈里面也是闪耀出众的,可以算作是高手中的高手,行文由浅入深层层递进,可读性一向很好,这本书读起来也不例外。
历史的回顾和架构师的主要职责
作者在一开头就用讲故事的手法描述了一个要求保持匿名的市场领先的软件公司的例子来讲述软件架构失败可能带来的巨大危害 - 一个庞大的具有超过六百万行代码的项目, 因为明显的架构问题导致8年的时间,项目组的人手越来越多,开发和添加新的功能的成功却又直线上升导致虽然增加了很多的人手却不能得到相对应的产出; 这样的问题也就是很多书里所讲的可扩展性不足导致的修改困难。
架构师的工作重心
回头说起来软件项目和硬件项目的不同,Bob大叔认为这里主要的不同就来自于软件是可以被修改的,而硬件项目则是一旦完工就无法很容易地扩展和添加新的功能。 软件架构师的主要核心职责不在于功能性的需求如何被实现出来,而是如何监控和设计软件系统的结构使得添加新的功能保持简单、经济和高效。
虽然作者没有明确地提到,但是我们还是需要注意功能需求也是很重要的,因为很多架构的约束反而是来自于系统的功能性需求; 只是现实生活中,大部分的项目经理和管理人员更容易看到功能需求的问题,而不会特意关注系统结构上的约束,那么我猜这里作者有意不提这方面也算合理, 读者却不可不自己心神领会;如果生搬硬套就会走向歧路了。
计算机行业的历史
讲起历史来,Bob大叔绝对算是行家中的行家;当初他的《Clen Code》系列视频中每一节都会加入一小部分关于天文学和物理学的历史知识作为铺垫; 甚至于我一度以为大叔是学理论物理学或者科学史出身的(后来在其它的演进里面才发现不是)。为了讲述和探讨软件行业的本质工作性质,作者又不吝文墨地回顾了整个计算机程序设计的历史; 作者给出的结论是,真正本质的计算机技术和程序设计思想在过去的几十年中并没有发生太大的变化。
这可以说是一个既乐观又悲观的消息。好的方面是,行业经过几十年的摸索和积淀终于积累下来了很多供后辈好好重用的宝贵经验; 可悲的地方却是商业上的超级多的浅层次的成功反而使得真正的技术被大部分人忽视了。 工业领域的应用招致了很多不懂计算机核心技术的软件工程师,用不太严谨的做法堆砌出大量的代码,而真正的生产模块化的代码的技术和方法都是在想方设法限制程序员的能力。
编程范式的历史
虽然编程的本质是一样的,业界却先后流行起来三种变成编程范式,对这些范式的支持情况的不同导致了各种形形色色的编程语言的繁荣; 但是仔细琢磨的话,我们会发现这些编程语言虽然外表看起来千差万别,分解之后里面的核心特性却没有多少。
面向过程的编程范式是很多人非常熟悉的范式,早期的先驱们(尤其是荷兰的牛人Dijkstra从理论物理专业转行来演进计算机理论)总结了几种基本的结构, 并不惜通过在一片争论声中将Goto关入牢笼来强迫大家采用事先定义好的顺序、分支和循环三种控制流转换,编写出良好的软件。 因为硬件执行过程的贴切的模拟对应,面向过程的编程范式一直是程序员的基石。
面向对象的编程范式其实并没有干太多新的事情,它里面总结的各种套路其实面向过程的方法也可以做到。 有人总结了面性对象的本质特性是继承、封装和多态,然而本质上面向过程的语言用结构和函数指针的方法一样可以达到, 甚至早期兴盛的C++语言就是通过虚函数表这一机制,在底下生成额外的控制带来控制程序逻辑跳转。 从这个意义上来看,面向对象的编程范式其实是限制了程序员必须用间接控制流转移的方法来提高可维护性。
函数式编程范式则是很早的一个范式,众所周知它的历史可以追溯到和图灵同时代的逻辑学家邱奇的lambda算子推演。 这一思路的主要特征就是数据的不可变性,同样的数据经过同样的处理必然产生同样的输出,同时数据一旦产生就不能被修改了,否则就会有副作用。 这种范式之所以能够再度流行起来,恰好又是因为更为复杂的运算需求需要增加数据之间的隔离。 前两种范式里面都有很明显的变量和赋值、操作的基本机制,而这一机制在函数式范式面前毫无用武之力。
软件架构师的任务就是需要清楚地认识到这些编程范式只是在不同的维度上对程序员加了各种各样的约束,以解决它所适应的场景的问题而已。 理顺问题的场景然后清楚地了解编程实现和交付中的各种困难,减小软件扩展和维护的成本,是架构师所面临的最根本的挑战。
计算机为什么是一门科学
作者不惜花费大量的笔墨来跟踪Dijkstra大师的人生轨迹和研究成功,其实是想为他最后的结论做铺垫,搞明白计算机能否成为严谨的数学。 毕竟早期的计算机先驱们都是数学家乃至逻辑学家;他们都期望找到一种严格的方式来证明计算机程序的绝对正确性。 现在大家都已经明白,这种努力无论从理论上还是时间上都已经破产了。
计算机软件和编程工作只能被认为是一种科学而不是一种数学。科学的主要特征是,你永远无法证明某个事情是真的, 但是却可以用固定的重复手段去验证该事情,并有可能有一天发现这个事情不成立而推翻这个结论。 这个证伪的方式其实就是我们所做的软件测试;我们有各种方法做形形色色的测试,而测试不通过的时候,我们可以通过修改程序来变更程序的行为,使得它贴合我们的期望。
想通过分析和推倒来写出严格的没有问题的程序虽然没有指望了,我们仍然可以通过降低测试的成本,改进测试的手段和方法, 通过持续、自动化的测试和即时反馈的方法提高工作效率。这方面的思考对于架构师而言是不可或缺的。
模块设计的原则和SOLID
架构师所关心的应该是更高层次的抽象实体而不再是一样一行具体的实现代码,因为唯有关注于这些做过良好分离的抽象,才能做到纵览全局心中有数。 这些抽象实体之间的关系是架构师需要重点考量的;这个过程中需要用到的设计原则并不拘泥于某种编程语言或者编程范式,尽管这些设计原则是在长期的面向对象思想中被提炼总结出来的
- 单一职责原则
- 开发闭合原则
- 李氏替换原则
- 接口隔离原则
- 依赖导致原则
这些设计原则以前一直被成为是面向对象的设计原则,一个很重要的原因应该是面向对象设计曾经风靡二十年之久;可惜的是大部分死背概念的架构师并没有真正的理解这些设计原则。 Bob大叔则旗帜鲜明地说这些原则其实都需要仔细地来揣摩和理解,单单是粗浅的重复字面意思而不能灵活地应用,无异于是买椟还珠,空入宝山徒手而还了。
设计原则的目标和场景
这些设计原则的目的是帮助我们实现简单而又可靠的软件模块乃至子系统,使得
- 变更它们的成本很低,因为软件系统总是要添加新功能才更有生命力,修改困难的软件就会被慢慢放弃了
- 理解起来容易,因为维护软件总是需要人来做,而参与其中的人总是在流动的
- 可以被轻易地重用在合适的地方,因为永远也没有人可以预测未来的软件项目需求;重头再写类似的代码的代价又过于高昂
上述的五大设计原则往往被认为是处理复杂软件系统内处于中等抽象的实体结构和相互关系的指导性方法论。 这些中等粒度的抽象就是一般意义上所说的模块或者子模块;不同的模块或者子模块之间会按照这些原则来聚合而形成基本的可执行单元。 在不太复杂的软件系统中,做到这一层就可以满足所有需要了;更复杂的大型的软件系统可能需要更高层次的组合,即组件。
关于设计原则的简单概括和去谬
简称为SOLID的五大设计原则对于现金的架构师而言都不是什么秘密了,然而对他们的认识上的谬误还是随处可见。作者又不惜花费一个大的章节来详细阐述其中关键的思想。
单一职责
最简单却又被误解最深的一个原则非单一职责原则莫属了;很多人的第一印象解读就是,一个模块应该只做一件事儿并讲这件事儿做好。 粗看起来这个解释很切合题中之义,也能在Unix编程哲学中找到KISS原则的呼应。只是这一原则的提出其实有更深刻的考虑因为怎么去界定前面所说的一件事儿会非常困难。 单一职责的真正含义是:
一个模块被改变的原因应该有并且只有一个;或者说一个模块的利益相关者应该有并且只有一个。
不幸的是,这里面的原因也好,利益相关者也好,都还是有含糊而解释不清的地方,因此作者毋宁说: 一个模块和他相关联交互的Actor只有一个,这样用UML描述模块关系的时候就比较清楚的看出这一原则有没有呗违反了。
开闭原则
开放闭合原则倒是没有那么多的误解,因为它本身就是事关怎样划分子系统、子模块乃至基本单元的。它所要求的系统应该对修改闭合对扩展开放某种程度上可以看做是对模块高内聚的要求。 要做到这一点,需要讲系统划分为不同的抽象层次的模块、子模块,并且实现良好的约束使得高层的模块不要以来于低层的实现,而扩展系统的方式总是维持核心模块的稳定性再增加新的低层模块来完成。
李氏替换原则
李氏替换原则原本是来源于类型系统的定义;一般也不太会被理解错误,只是在软件设计和架构中,这里的类型被替换成了设计中的抽象并加以深入考量。 对于有类型的静态语言来说,这意味着接口或者基类的抽象需要有足够好的普适性,扩展出来的子类必须完全遵守事前定义好的契约,因为这里的耦合是非常强的。 对于弱类型的动态语言而言,这些约束可能是隐式的,虽然因为不必有源代码依赖上的麻烦而变得更加灵活,但是同样因为没有这些检查而使得所有违反约束的错误代码都只能在运行时才能发现。
某种程度上说,缺少了编译器的检查,动态语言易于修改代码的优势其实带来了维护设计一致性的额外麻烦;这一点不可不察。
接口隔离原则
接口隔离原则强调的是模块化软件关于耦合方面的分离;它的最基本的思想可以认为是:
除了你必须要以来来完成模块定义的功能之外的东西,额外的依赖都不要引入进来。
这里的要求是两个方面的
- 提供功能的模块在对外提供的接口上必须保证是高度内聚的,在对应的抽象等级、粒度上给出的接口需要职责明确
- 使用接口的模块需要保证只引入自己使用的功能,而不能引入不必要的依赖
依赖倒置原则
这个原则可以看作是传统的面向对象设计里面最为烧脑的一个原则,正确理解起来一直有些不容易,因为必须同时考虑到代码结构的组织和实际运行时的依赖来看才能体会其中的精妙。 它的核心是将程序组织的依赖和运行时的依赖跟分离开来看,构造代码的时候,从抽象角度来说需要保证
- 抽象层次高的代码必须依赖于抽象而不是具体实现;
- 实际的具体实现的代码就只能看到这些抽象而看不到抽象背后所隐藏的实现;
- 核心模块的业务规则只能看到其它外围模块的概念抽象,而看不到具体实现这些抽象的实体;这样当需要扩展新的实现方式的时候,只需要添加新的实现代码,而核心的业务规则可以保持稳定。
可以看出依赖导致原则其实潜在地暗含了其它几个原则。实际实现中还需要考虑下面一些因素
- 操作系统和平台软件的一来是不可避免的;但是这里的关键是期望减小系统的维护成本, 而这些底层的稳定的部分很少变化,所以直接依赖他们的具体实现倒是也问题不大; 如果是自己内部搭建的通用平台软件,那么则需要另外分析和斟酌;这方面的讨论也有很多。
- 核心业务逻辑层的稳定提炼需要依赖于领域知识,因为什么是稳定的什么是可变的跟具体的目标业务场景息息相关,没有放之四海而皆准的模板可以套用。
组件设计原则
组件化技术已经成为搭建大型复杂软件系统的基础技术,如何组织系统的组件子系统,安排好这些子系统之间的接口、依赖和交互关系是软件系统架构师的主要工作职责。 需要留意的是,这些技术是构建在基本的模块化设计开发方法之上的,因此很多模块设计的原则也被自然而然地升华和适配;如果不能深刻地理解基础的模块化设计方法和里面的痛点, 生搬硬套组件设计的方法弄不好就会弄巧成拙,变成浮沙之上筑高台的游戏就很容易招徕失败。
组件化技术的探索和演化历史
组件作为复杂的软件系统发布和部署的基本单元,其形式随编程语言和平台的研究和发展而呈现不同的具体形式,可能是Java环境中的Jar打包文件, 或者是.NET环境中的动态库文件,也可能是Ruby中的Gem,Rust中的模块,NPM中的模块等。 它们的共同特征就是可以独立打包、发布,并且和其它组件有形形色色的依赖共生关系。
最早期的家算计程序不支持这些概念,所有人开发的源代码必须安置好在内存中被加载的位置,多人协作的场景下, 大家协商好自己负责的部分的加载地址,但是这种很快就变得难以为继; 于是新的可以支持重定位加载地址的技术就被发明出来,使得不同的程序可以在加载的时候,加载器通过计算给它安差一个位置,防止冲突。 这种情况下,编译出来的程序的实际加载位置是未知的,而加载器需要根据它的算法为一个可执行文件中的所有的动态加载地址计算一个没有冲突的方案来。 链接器则被用来对编译的程序安插一些可重定位的段和符号表,以便加载器在程序实际载入内存的时候安插实际的内存地址。
摩尔定律和墨菲定律的交替作用始终影响着计算机软件行业的发展和演进,程序的规模变得越来越大,业务系统越来越复杂,而新技术的出现总是被现实中遇到的限制所驱动。 传统的静态语言中,用连接器和加载器技术组织的组件化技术依然有各种各样的优势和不足,间接催生了采用不同技术决策的技术的繁荣共存; 于此同时,希望绕开这一限制的先驱们则通过创建新的编程语言(如动态语言)或者平台技术(跨越多种语言的中间语言来促进互操作)来简化组件化开发的种种问题。
现在我们可以支持在程序执行的过程中,不重启主要系统,而将新开发的功能插入运行的系统中的办法来实现功能的实时更新和扩展, 这种插件化技术甚至在很多开发环境中成为默认的扩展方式;这一技术能够工作的前提是系统的架构必须有良好的考虑和组织。
组件内聚原则
Bob大叔讲使用用组件划分的原则总结为基本的三条;这三条原则也是前面所说的模块化设计原则的延伸
- 重用和发布等同原则 (Common Resue/Release Equivalence Principle)
- 通用闭合原则 (Common Closure Principle)
- 通用重用原则 (Common Reuse Principle)
第一条原则是关于模块的聚合和发布,现代的软件工具已经隐含地要求我们放在一起的类或者模块必须同时发布出去,所以这一个原则看起来是不言自明的。 只是这里面有个不太明显的部分在于,如果设计者将没有关联的东西随意放置在一起发布,那么不必要的偶然依赖就会出现使得系统变得难以维护。 这些被打包在一起发布,使用同样的测试、发布流程的组件内部模块必须共同服务于同一个设计目标,并且遵循前面单一职责原则的思想,应该有且只有一个原因而变更。
后边两个原则则是从其它方面深入阐述了第一条原则的重要性和潜在假设。
通用闭合原则描述的是单一职责和开放闭合原则在组件化设计上的应用;它建议我们讲同样职责并且提供单一变更或者有单一的Actor的类或者模块放置在一个组件中。 响应地,具有不同的逻辑职责或者可能有多个原因引起重新发布控制的模块或者类应该被归类放置在不同的组件中。 不同的是这里讨论的变更所在的抽象层次更高一些。
最后一个原则注重于组件的重用,要求我们仅仅使用本组件需要用到的其它功能组件,不要引入不必要的组件之间的依赖。 这个考虑之所以重要,是因为依赖关系总是有传递性的,引入不必要的依赖会使软件配置管理变得困难,测试和验证复杂度急剧上升,从而增加软件的维护成本。 毕竟软件架构的目标就是尽力提高软件系统的可扩展性和降低维护变更的成本。
三个原则之间的关系
软件架构工作可以看做是充满了妥协和折中的艺术性活动,尽管它所依赖的基本工具是各种各样的技术和业务领域知识。 这三个原则之间其实是相互掣肘,相互依赖的关系,实际应用的时候需要根据场景和需求分析和折中,而不能走向极端。
如果过分考虑重用性和发布重用等价两个原则,则会引入很多不必要的软件组件,带来极高的维护成本; 过分考虑通用闭合原则和发布重用等价原则,则会违反重用原则,导致升级和发布困难。 显然闭合原则强调聚合而重用强调拆分,两者是互斥的两面,需要综合分析和考虑。
三个原则的使用和侧重也可能因为开发所处的阶段不同而变换。往往在项目的早期阶段,组件重用方面的考虑并不会太多,否则就会因为选择困难而迟迟无法取得进展。 等到越来越多的功能被加入进来,则需要适时地拆分组件,提高可重用性。
组件耦合处理原则
模块化的软件设计方法要求我们尽力减少组件之间的耦合,而正确地处理组件的耦合从来不是一个简单的工作,需要考虑技术、政治和环境变化等方面的因素。
无依赖环原则
第一个重要的原则是,组件之间的依赖关系中不能出现环。
这一原则的基础想法是考虑组件之间的依赖关系,并用一幅有向图来描述各个组件之间的依赖关系,并检查最终的组件依赖关系图中是否存在有环。 显然出现了依赖环的组件之间有很强的耦合,会带来代码改动和发布上的困难,测试和集成工作也会寸步难行,必须依赖于高频度的同步开发,而过多的同步则必然带来并行开发效率的降低。 有时候复杂的组件依赖环还会带来组件构建和发不顺序选择上的困境,很多时候发布团队不得不小心选择构建的顺序和工作流以免带来不一致。
打破依赖环的常见办法有两种
- 采纳依赖导致原则,抽象出来接口组件,然后让组件依赖于接口,而实现组件实现给定的接口组件;这样强依赖关系便被打破。
- 创建一个新的组件,然后把产生环形依赖的组件糅合放置在新的组件中,这样原来的环就自然消失了。
这样的变更意味着传统的自顶向下方法的失效,但是如果我们考虑演进式架构的方法学,总是用发展的思维看到软件架构的工作, 矛盾就自然迎刃而解了,因为复杂的组件依赖不是一夜之间突然冒出来的, 只要在刚刚出现问题的时候,采用务实的思路分析业务问题和组件设计约束,尽快扑灭架构腐化的苗头即可。 这样的方法其实也是现在敏捷环境下软件设计的主要思路 (之前的思考见这里)。
稳定依赖原则
软件架构从来不是一个静态的概念,构成系统的组件总是会因为各种各样的原因而不时发生变化。 这条原则讨论的是组件之间相互依赖方向的选择,即考虑哪个组件应该依赖于哪个组件的问题。 作者的经验是,变化相对困难的组件应该依赖于稳定而易于修改的组件,而不是相反方向;否则就会引起架构的腐化和演进困难。
这里的关键是,需要将组件的稳定性用变更的难易程度来衡量,而不是实际变更的多少来衡量,因为软件组件从被设计出来的那一天开始, 就不能避免发生变化,除非它因为无法重用而慢慢消亡失去生命力。影响稳定性的因素有很多,比如代码的行数,编程语言或者领域复杂性等, 但是从架构设计的角度看,一个明显的因素是它所依赖的其它模块的数量和稳定性本身。 如果一个模块有很多的前向依赖,那么这些依赖模块的变更都会使该模块本身变得不稳定。
稳定性的度量可以用依赖关系图上面的扇入、扇出关系来简单计算,只需要简单数一下依赖于该组件的其它组件的数量(扇出值), 并用这个数字除以所有扇入扇出的所有组件的数量,就可以得到一个简单的稳定性度量。 当这个度量为1的时候,表明它没有使用其它组件,那么修改起来非常容易,不会有其它的影响。
如果用上面的依赖度量值来描述稳定性依赖原则,那么我们需要保证,在组件依赖关系图上,沿着依赖方向组件的稳定性值需要保持递减。 对于违反了这一原则的组件,一个简单的处理方法依然是使用依赖倒置原则创建新的抽象组件,从而避免稳定的组件依赖于不稳定的组件。
这样会产生一个明显的结果是有一些纯粹抽象的组件被创建出来,不过无需惊讶的是,成熟的软件系统中,纯抽象的接口组件随处可见。 当然这个现象是静态语言编程环境中才会观察到的现象,因为静态类型系统总是需要检查声明和实现的一致性,以期及早发现编码中的问题。 动态语言编程环境由于放松了对程序员代码一致性的检查,所有的依赖都是隐性的,所以没有必要创建抽象的接口组件。
稳定抽象原则
这一原则可以用一句简单的表述概况,即
一个组件的稳定程度应该和它的抽象程度想匹配。
它要求我们将业务逻辑相关的核心策略或者非常稳定的业务规则用高度抽象的组件接口描述出来。而一些不是特别重要的或者易于变化的部分可以用具体的组件模块来实现。 在面性对象系统中,这些高度稳定的部分有时候有一些默认的实现,这些实现可以用抽象类的办法来实现之。
组件的抽象程度可以用一个简单的公式来度量:用组件中的抽象接口和抽象类的总和除以组件中所有的接口、类的数量就可以得到其度量值。 这种度量方法比较适合纯粹的如Java这样的面向对象语言。
Bob大叔还基于上述的两个度量方法,提出了一套定量分析组件设计的稳定性和抽象程度匹配度的模型。 可以用常规的统计学方法来判断组件系统的设计是否处于良好的状态。 在这一的关联图中,设计良好的软件组件总是处于对角线的位置,而偏离对角线的地方则可能意味着有连个极端
- 过度抽象却又鲜有变化的代码表明存在着过度设计
- 缺乏良好抽象却又不稳定的组件会带来维护泥潭
整洁之架构
什么是架构和架构师,以及架构师应该需要做什么向来是充满争议的话题。 写了很多经典著作的Martin Fowler写过著名的Who Needs an Architect 指出架构师应该是做出关键而重要的技术决策的资深开发者。 Bob大叔则进一步从软件开发人员的角阐述了架构师应该是什么而不是什么
- 架构师是最好的程序员,并且继续作为一个程序员而存在;不应该相信那些架构师可以从代码中抽身而出的只关注于高级问题的砖家的说法。
- 架构师需要关注于如何提高程序员团队的生产率,这一要求使得架构师不能脱离程序员队伍,否则他们就无法理解实际实现中的痛点和问题。
- 软件架构需要关注于软件系统如何被开发、部署、运行、维护;其背后需要尽可能的少封闭一些具体实现相关的决策。
- 软件架构的终极目标是用尽可能小的成本来支持系统的生存周期,最大化地提高程序员的生产效率。这就要求我们尽可能多地留出更多的扩展接口,减少系统层面的写死设计。
保留尽可能多的选项
软件总是凸显两个方面的价值:
- 行为模式的价值,只有实现正确的行为模式,软件才能变得有用
- 结构模式的价值,唯有具备了合适的结构,软件才能成为软件,而合理的结构应该是软件架构本身的重点所在;因为没有良好的架构的软件系统也能完成需要的行为
软件之所以能被称之为软件,最大的原因在于变更它的结构以便适应变化的需求的成本很小。如果想做到这一点, 我们需要把软件系统分为两个部分:策略和细节。策略一旦选定变化起来就比较困难,而细节实现的变化往往容易。 策略的部分包括这些业务规则和过程,而细节则是具体的技术实现、数据库、通信协议、框架、库等。 由于商业业务规则比较稳定而技术的变化比较快,我们需要保持这些稳定的部分,而尽可能的不写死具体实现这些业务规则的技术细节。
举个例子来说,在系统开发的早期阶段,选择某个具体的数据库系统实现往往不是最重要的决策,因为 关键的业务规则和逻辑并不需要依赖于具体的数据存储技术和细节。那么架构师可以将这些决定留到以后需要的时候再做;系统中需要用到数据存储系统的时候,使用抽象的接口隔离它就可以了。 类似地,也不需要尽早地在系统开发的早期阶段部署REST接口,因为系统的核心业务规则并不依赖于具体的接口实现技术。是否需要使用某些SOA架构框架或者微服务框架。
如果这些细节已经被别人事先选择好了,那么保持业务规则灵活的另外一个办法是隔离这些细节,然后在核心业务规则的部分假设这些细节不存在; 这样做的好处是以后万一需要修改采用另外一种方式,只需要增加一种新的实现就可以了。 好的架构师总是想方设法地最大化未固化的技术决策。
单体还是微服务?
微服务架构目前已经成为一门显学,而架构师则需要拨开这些喧嚣专注于自己的商业系统的核心业务规则。如果系统需要强烈的分布式架构, 那么软件系统应该很容易地扩展为分布式系统,采用SOA或者微服务系统;良好的软件架构需要保留这些选项, 使得扩展和变更轻而易举。如果系统的所有部分都假设自己知道这些细节信息,实现这样的改变就会难于登天。
关键还是要做好适当的隔离和解耦,不做过多的假设使得核心的业务逻辑仅仅依赖于系统业务逻辑本身。 所有这些应用服务器、依赖注入框架、微服务系统、容器技术本身是为了简化具体细节的实现,而不是来替代软件架构本身。 他们不应该是软件架构的中心;重心是如何做才能使易于变化的部分怎样以更低的成本实现以满足业务需要;其手段就是预留出足够多的扩展空间。
系统边界划分
为了解耦系统架构设计,我们需要设计子系统或者组件,然后分割组件的边界。如何划分系统边界以便更好地实现系统的用例模式,同时减小耦合提高组件复用度不是一件轻而易举的事儿。 好的边界划分方法是尽可能降低一些不成熟的决策产生的影响,降低对这些容易变化的决策的依赖。 我们需要在在重要的模块或者子系统和不太重要的子系统之间划分出清晰的边界; 比如数据库系统和界面显示系统往往和核心的商业业务逻辑之间有一条清晰的边界,因为数据库实现和用户界面相对于业务逻辑而言更不成熟和易于变化。 实现边界隔离的常用办法是抽象出一个接口,然后让具体的实现来提供实现这些接口,而业务规则仅仅依赖于该抽象接口,这其实就是上面的依赖导致原则。
这样的架构设计方法催生的正是非常著名的允许第三方扩展的插件式架构;核心的系统业务逻辑规则被和其它的可以用各种方式实现的外围模块所分离开, 这样外围的实现模块就成为核心系统的插件。这些插件部分是易于扩展和替换的,从而整个软件系统都变得易于修改和变更以实现新的外围需求。 具体的外围插件系统的例子包括图形界面子系统、数据库子系统等。并且大部分情况下的输入输出系统其实也应该是属于可替换的系统插件而被合适的接口所隔离。
策略和分层
核心的业务规则往往本身又可以被划分为很多子系统,这些子系统都是高度抽象的业务规则, 却可以按照它们和外围的输入输出的远近而被分割为不同的层次。 那些和输入输出系统关系比较疏远的子系统处于比较高的层次,而较近的则处于比较低一些的层次。 同时会因为同样的原因变更的部分需要被组合在一个层次中。高层次的策略应该是更稳定的部分, 并且应该代表了更高层次的抽象,更不易发生变化。
业务规则识别
既然业务规则是架构中最核心的部分,如何识别它们有时候不是一个显而易见的问题。 简单地来看,业务规则就是这些商业系统赖于赚钱盈利的过程或者业务流程,这些业务流程很多时候即使没有计算机软件也可以手工来达成。
这些部分是商业软件系统的核心,它们是软件系统之所以存在的核心价值所在,组成了系统的核心功能。 理想情况下,实现核心业务规则的子系统应该处于软件架构的最核心处,应该是最容易被重用的稳定的代码系统。
架构意图呈现
良好的软件系统应该能够第一眼望去就自动呈现系统实现的核心业务,而不是具体的框架、库和基础技术。 新加入的程序员可以很容易地学习到系统的基本用力系统,即使他们没有办法很快熟悉系统的具体交付和支撑运行系统。
这样做的一个额外的好处是,软件系统本身就成为更容易被隔离测试的健壮的系统,因为影响可测试性的一个很大的因素就是这些外围的框架、通信协议等繁杂的细节。 反之以框架和复杂协议为中心组织的软件系统则很难被剥离出来做方便的自动化测试,系统维护的成本也会随之变高。
整洁架构的特征和分层
过去几十年来,先驱们发展了各种各样的想法来构建软件架构,这些方法论都致力于实现具有如下特征的软件系统
- 框架独立性:不依赖于任何库或者软件架构
- 可测试性,各个部分可以独立测试
- 界面隔离和独立,不管是命令行界面系统还是桌面客户端或者是浏览器等技术
- 数据库独立性,可以替换各种SQL数据库,或者是文档数据库等,核心的业务逻辑不依赖于这些具体技术
- 外部代理独立性,核心的业务系统并不知道外围的接口和协议
Bob大叔据此把整洁的软件架构分离为四个环,从内层到外依次是
- 业务规则实体
- 应用业务规则包括核心的业务用例
- 接口适配器,包括控制器,展示层,网关等
- 外围设备,Web,界面,数据库,具体设备等
这种结构可以用UML图描述如下
根据上面讨论的架构规则,处于内侧的部分是核心的业务规则,外层的部分则是实现的细节和具体机制。 依赖规则要求我们务必保证,源代码层次上的依赖顺序是,外层的代码需要依赖于内层的策略;相应地内层的部分需要对外层的实现一无所知。所有外层的部分的变化都不应该影响到内层的实现。 图中没有直接描述的还有实际的控制流,依据依赖导致原则,实际运行的控制流和依赖关系是反转的, 存在从Present和Controller到核心业务规则UseCaseInteractor的控制流。
Humble Object模式
上图中的展现层可以细分为Presenter和View两个层次,将距离用户更近的这一层挪到View中,同时放置在View中的部分需要尽量保持简单,以为界面相关的代码是出名的难以测试。 这种做法其实是Humble Object模式的一种应用; 该模式可以帮助我们识别和保护架构的边界。它通过将系统的行为拆分为容易测试的部分和不易测试的部分,极大地简化了隔离测试。 这种做法的关键是将难以测试的部分代码加以分解,只留下最基本的简单代码放在外部耦合比较大的模块中,而将比较纯粹的逻辑代码分离出来以便测试。 被隔离出来的代码其实包含了更少的外围细节,在方便测试的同时,模块的边界也变得更加清晰起来。
这种做法很好地满足了好的软件架构总是易于测试的需要,换句话说具备可测性是良好的软件架构的一个重要属性。
类似的做法也适用于以下一些场景
- 数据库隔离层网关,我们不希望我们的业务层代码知晓数据库操作的细节,也希望把SQL操作的细节隔离在外围,那么负责数据库访问的ORM就是同样类似的Humble Object。
- 跨服务传递通信的细节同样也需要用Humble Ojbect来隔离,如何和其它的服务通信一样应该处于本服务的外围。 基本上在系统架构的边界上,Humble Object模式可以得到广泛的应用。
部分边界
构造完整的模块边界的做法可以保证架构的灵活性和高扩展性;不幸的是这样做带来的额外开销也不小,并经常被敏捷开发专家嘲笑,他们经常用的一个敏捷术语是YAGNIT即你将来也不需要它! 不得不承认很多时候他们说的也对,因为没有人可以预测到未来的需求,然而作为一个架构师却又不得不如此做! 变通的办法是简化这一边界隔离策略,采用简化版的边界来平衡潜在的扩展性需求和搭建隔离边界的开销。
一种构造部分边界的做法是,准备所有的隔离设施和抽象,但是在代码组织和部署上并不将他们设置为相互分开的组件, 而是将它们暂时糅合在一个组件中。等到时机成熟的时候,再将他们分离开,这时候分离的代价将会很小。 这样的做法只是简化了构建和运维的成本,软件内部的结构仍然保留了良好的抽象和隔离。
另外一种方法是维护单一维度的边界,即每一个服务都仅仅对服务提供者的实现做接口隔离,而在服务的调用侧, 并不对其它服务的接口做隔离和保护。这样仅仅保证了服务使用者对具体服务实现的隔离。 还有一种做法是采用门面模式讲一个服务的所有方法都放置在一个门面接口下面,这是一种更弱的隔离边界。
无论采用哪一种部分边界隔离办法,架构师都需要做到对其中的优缺点心中有数,并且在适当的来决定 是否采用部分边界的做法隔离不同的抽象模块,或者是否需要将部分边界升级为完整的边界隔离设施。 具体采用哪种策略对架构师而言是一个巨大的挑战,因为过度设计和抽象不足的代价都是极为高昂的,都会给软件的修改和维护成本带来巨大的挑战。
合格的软件架构师必须能够审时度势,根据实际情况的需要,选择合理的抽象级别,既不落入过度设计的窠臼; 也不能掉入抽象不足写的太死的尴尬境地。通过自己的智慧和经验做出明智的判断;过犹不及,恰到好处才是最好却又最难做到。 要想明智地做到这一点,很多时候甚至需要猜测和运气。幸运的是这不是一个一次性的决定,架构师可以在决定之后适时观察再谋定而后动。 发现一些地方有过度厚重的边界的时候,及时地简化以提高维护成本;发现其它一些地方的抽象不足也需要及时加重隔离的边界,为后续的扩展留上余地。
主模块
几乎所有的软件系统中,我们都可以看到一个主模块(Main),无论采用什么样的编程语言或者框架都不例外。 这一模块几乎可以看到所有其它的模块(的边界),这种情况其实是一种正常的现象,因为静态结构的解耦最终反应到代码执行层面,必须有一个地方实现运行期的绑定以便执行真正的控制流运转。 因为我们抽象设计的一个秘密武器依赖倒置实现的秘诀就是控制流反转;所以主模块必须有非常严重的耦合不可, 所以主模块必须作为一个特例并且是唯一的特例而存在。
主模块其实承担了串起所有整洁架构中的抽象模块的重任,它做的是所有的脏活累活,所以将其看做是插件式架构中的一个无关紧要的插件即可。 它完成的任务正是加载外围的给定的配置和其它输入和输出设施,启动模块的主控制逻辑,然后使得整个系统按照预设的组合运转起来。
测试边界
整洁的软件架构中,测试必须是架构的一部分,它是保证软件系统能够以低成本运作和维护的重要设施,所以测试也需要一个清晰的边界。 模块的设计必须要要保证考虑到可测性,需要避免在测试中引入一些昂贵的测试依赖和耦合,比如界面的细节, 安全设施的依赖等。本质上来说,测试都依赖于非常具体的细节,所以从架构环的角度考虑,可以认为测试位于最外层的一环。 采用的手法和上面构建边界的方法类似,可以用API接口的方式提高模块或者组件的可测试性。
测试的设计必须要作为软件架构设计的一个部分仔细考虑,没有加以详细考虑的测试组件必然因为过度脆弱、难以维护而逐渐被抛弃, 最终软件系统本身的维护成本反而会大幅增加而招致项目的失败。
嵌入式系统架构
嵌入式系统经常被当做一个非常特殊的存在,因为这一领域的软件总是显得和其它计算平台的软件差异巨大;因此是否可以套用上面的整洁架构技术是个值得思考的问题。 Bob大叔则旗帜鲜明地指出嵌入式架构本质上没有太大的特别之处,即便这个领域有它自身的特别之处,这主要是因为嵌入式软件环境必须估计到它特殊的硬件、固件环境和形形色色的定制行为。
作者认为从架构设计的角度来说,嵌入式软件的设计者们需要多多向外部的世界学习软件抽象和架构设计的智慧,做好良好的硬件和固件层封装是至关重要的, 考虑好上面的那些结构设计原则,总是将固件和硬件相关的部分隔离开,使得大部分核心的软件系统总是可测试的, 才能提高程序员的效率,降低改动和维护的成本:这在嵌入式开发中是一笔很大的开销,不能脱离具体固件环境的开发和测试的嵌入式开发成本是极度高昂的。
总结和回顾
作为一个具有几十年开发经验的领域老兵,BoB大叔的软件开发生涯比我自己的声明还要长。在这样一本以文字为主的讲方法论的书中,他穿插了很多他所见到的、听到的、成功的或者失败的故事; 每一个故事背后都有很深的教训和经验值得人们深思和学习。计算机行业本身都是一个很新的行业, 更不要提专业的大规模软件开发的历史更加短暂;计算机世界里, 解决一个复杂问题的最本质方法之一就是分而治之;很多复杂的问题的解决之道都不过是添加一些新的抽象。 这些简单的思想背后却蕴藏着巨大的威力,这一巨大的威力背后也不竟然带来巨大的风险,运用不当又很容易造成过犹不及而伤及自身。
软件开发本身又不单单是一个纯粹的技术问题,因为对绝大部分商业软件来说,我们需要考虑它的建造、维护和变更的成本。 很多时候我们面对的是一个需要维护很长时间的软件,它从被创建之日起,就肩负着可以被不断修改完善以便重用的使命。 人们很少有奢侈的机会去重头开始写一切需要的东西,因为这样做往往无法实现软件所承载的商业价值。 软件架构师之所以要努力搭建这些不时被嘲笑是“叠床架屋”的复杂设计,其实也不过是希望有朝一日某些部分需要被修改的时候,付出的代价要小一些。 忽视这些代价而只顾一时之快的经验不足的程序员们,很难避免以后自己也踏入“以使后人而复笑后人也”的境地。
Leave a Comment
Your email address will not be published. Required fields are marked *