第5部分 软件架构
第15章 什么是软件架构
软件架构师自身需要是程序员,并且必须一直坚持做一线程序员,绝对不要听从那些说应该让软件架构师从代码中解放出来以专心解决高阶问题的伪建议。
也许软件架构师生产的代码量不是最多的,但是他们必须不停地承接编程任务。如果不亲身承受因系统设计而带来的麻烦,就体会不到设计不佳所带来的痛苦,接着就会逐渐迷失正确的设计方向。
设计软件架构的目的,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。
软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。
开发(Development)
一个开发起来很困难的软件系统一般不太可能会有一个长久、健康的生命周期,所以系统架构的作用就是要方便其开发团队对它的开发。这意味着,不同的团队结构应该采用不同的架构设计。对于一个只有五个开发人员的小团队来说,他们完全可以非常高效地共同开发一个没有明确定义组件和接口的单体系统(monolithic system)。如果一个软件系统是由五个不同的团队合作开发的,通常,如果忽略其他因素,该系统的架构会逐渐演变成五个组件,一个组件对应一个团队。
部署(Deployment)
一个系统的部署成本越高,可用性就越低。
运行(Operation)
软件架构对系统运行的影响远不及它对开发、部署和维护的影响。几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计。对于一个因架构设计糟糕而效率低下的系统,我们通常只需要增加更多的存储器与服务器,就能够让它圆满地完成任务。另外,硬件也远比人力要便宜,这也是软件架构对系统运行的影响远没有它对开发、部署、维护的影响那么深远的一个原因。
并不是说我们不应该为了让系统能更好地运转而优化软件的架构设计,这样做是应该的,只是基于投入/产出比的考虑,我们的优化重心应该更倾向于系统的开发、部署以及维护。
一个设计良好的软件架构应该能明确地反映该系统在运行时的需求。
是设计良好的系统架构应该可以使开发人员对系统的运行过程一目了然。架构应该起到揭示系统运行过程的作用。具体来说,就是该架构应该将系统中的用例、功能以及该系统的必备行为设置为对开发者可见的一级实体,简化它们对于系统的理解,这将为整个系统的开发与维护提供很大的帮助。
维护(Maintenance)
系统维护的主要成本集中在“探秘”和“风险”这两件事上。其中,“探秘(spelunking)”的成本主要来自我们对于现有软件系统的挖掘,目的是确定新增功能或被修复问题的最佳位置和最佳方式。而“风险(risk)”,则是指当我们进行上述修改时,总是有可能衍生出新的问题,这种可能性就是风险成本。
保持可选项
软件有行为价值与架构价值两种价值。这其中的第二种价值又比第一种更重要。
我们让软件维持“软”性的方法就是尽可能长时间地保留尽可能多的可选项。那么到底哪些选项是我们应该保留的?它们就是那些无关紧要的细节设计。
所有的软件系统都可以降解为策略与细节这两种主要元素。策略体现的是软件中所有的业务规则与操作过程,因此它是系统真正的价值所在。而细节则是指那些让操作该系统的人、其他系统以及程序员们与策略进行交互,但是又不会影响到策略本身的行为。它们包括I/O设备、数据库、Web系统、服务器、框架、交互协议等。
软件架构师的目标是创建一种系统形态,该形态会以策略为最基本的元素,并让细节与策略脱离关系,以允许在具体决策过程中推迟或延迟与细节相关的内容。
如果在开发高层策略时有意地让自己摆脱具体细节的纠缠,我们就可以将与具体实现相关的细节决策推迟或延后,因为越到项目的后期,我们就拥有越多的信息来做出合理的决策。
一个优秀的软件架构师应该致力于最大化可选项数量。
本章小结
优秀的架构师会小心地将软件的高层策略与其底层实现隔离开,让高层策略与实现细节脱钩,使其策略部分完全不需要关心底层细节,当然也不会对这些细节有任何形式的依赖。另外,优秀的架构师所设计的策略应该允许系统尽可能地推迟与实现细节相关的决策,越晚做决策越好。
第16章 独立性
一个设计良好的软件架构必须支持以下几点。
- 系统的用例与正常运行。
- 系统的维护。
- 系统的开发。
- 系统的部署。
用例
一个设计良好的架构在行为上对系统最重要的作用就是明确和显式地反映系统设计意图的行为,使其在架构层面上可见。
开发
任何一个组织在设计系统时,往往都会复制出一个与该组织内沟通结构相同的系统。
部署
一个设计良好的架构通常不会依赖于成堆的脚本与配置文件,也不需要用户手动创建一堆“有严格要求”的目录与文件。
按层解耦
从用例的角度来看,架构师的目标是让系统结构支持其所需要的所有用例。
系统可以被解耦成若干个水平分层——UI界面、应用独有的业务逻辑、领域普适的业务逻辑、数据库等。
用例的解耦
如果我们按照变更原因的不同对系统进行解耦,就可以持续地向系统内添加新的用例,而不会影响旧有的用例。
解耦的模式
基于服务来构建的架构,架构师们通常称之为面向服务的架构(service-oriented architecture)。
开发的独立性
只要系统按照其水平分层和用例进行了恰当的解耦,整个系统的架构就可以支持多团队开发
部署的独立性
按用例和水平分层的解耦也会给系统的部署带来极大的灵活性。如果解耦工作做得好,我们甚至可以在系统运行过程中热切换(hot-swap)其各个分层实现和具体用例。
重复
如果有两段看起来重复的代码,它们走的是不同的演进路径,也就是说它们有着不同的变更速率和变更缘由,那么这两段代码就不是真正的重复。等我们几年后再回过头来看,可能就会发现这两段代码是非常不一样的了。
我们一定要小心避免陷入对任何重复都要立即消除的应激反应模式中。一定要确保这些消除动作只针对那些真正意义上的重复。
再谈解耦模式
我们可以在源码层次上解耦、二进制层次上解耦(部署),也可以在执行单元层次上解耦(服务)。
源码层次:我们可以控制源代码模块之间的依赖关系,以此来实现一个模块的变更不会导致其他模块也需要变更或重新编译。
部署层次:我们可以控制部署单元(譬如jar文件、DLL、共享库等)之间的依赖关系,以此来实现一个模块的变更不会导致其他模块的重新构建和部署。
服务层次:我们可以将组件间的依赖关系降低到数据结构级别,然后仅通过网络数据包来进行通信。这样系统的每个执行单元在源码层和二进制层都会是一个独立的个体,它们的变更不会影响其他地方(例如,常见的服务或微服务就都是如此的)。
在项目早期很难知道哪种模式是最好的。事实上,随着项目的逐渐成熟,最好的模式可能会发生变化。
另一个解决方案(似乎也是目前最流行的方案)是,默认就采用服务层次的解耦。这种做法的问题主要在于它的成本很高,并且是在鼓励粗粒度的解耦。服务层次解耦的另一个问题是不仅系统资源成本高昂,而且研发成本更高。
我会倾向于将系统的解耦推行到某种一旦有需要就可以随时转变为服务的程度即可,让整个程序尽量长时间地保持单体结构,以便给未来留下可选项。
一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。
- 系统最初的组件隔离措施都是做在源码层次上的,这样的解耦可能在整个项目的生命周期里已经足够了。然而,如果部署和开发方面有更高的需求出现,那么将某些组件解耦到部署单元层次就可能够了,起码能撑上一阵。
- 随着系统在开发、部署、运行各方面所面临的问题持续增加,我们应该挑选一下可以将哪些可部署单元转化为服务,并且逐渐将系统向这个方向转变。
- 随着时间的流逝,系统的运维需求可能又会降低。之前需要进行服务层次解耦的系统可能现在只需要进行部署层次或源码层次的解耦就够了。
本章小结
一个系统所适用的解耦模式可能会随着时间而变化,优秀的架构师应该能预见这一点,并且做出相应的对策。
第17章 划分边界
软件架构设计本身就是一门划分边界的艺术。
一个系统最消耗人力资源的是什么?答案是系统中存在的耦合——尤其是那些过早做出的、不成熟的决策所导致的耦合。
怎样的决策会被认为是过早且不成熟的呢?答案是那些决策与系统的业务需求(也就是用例)无关。这部分决策包括我们要采用的框架、数据库、Web服务器、工具库、依赖注入等。在一个设计良好的系统架构中,这些细节性的决策都应该是辅助性的,可以被推迟的。
我们在业务逻辑和数据库之间画了一条边界线。这条线有效地防止了业务逻辑对数据库产生依赖,它只能访问简单的数据访问方法。这个决策使我们将与数据库选型和实现的决策推迟了超过一年。
通过划清边界,我们可以推迟和延后一些细节性的决策,这最终会为我们节省大量的时间、避免大量的问题。这就是一个设计良好的架构所应该带来的助益。
应在何时、何处画这些线
边界线应该画在那些不相关的事情中间。
。数据库应该是业务逻辑间接使用的一个工具。业务逻辑并不需要了解数据库的表结构、查询语言或其他任何数据库内部的实现细节。业务逻辑唯一需要知道的,就是有一组可以用来查询和保存数据的函数。
界应该穿过继承关系,在DatabaseInterface之下
输入和输出怎么办
一个非常重要的原则,即I/O是无关紧要的。
不重要的组件依赖于较为重要的组件。
插件式架构
软件开发技术发展的历史就是一个如何想方设法方便地增加插件,从而构建一个可扩展、可维护的系统架构的故事。
边界线也应该沿着系统的变更轴来画。也就是说,位于边界线两侧的组件应该以不同原因、不同速率变化着。
一个系统的GUI与业务逻辑的变更原因、变更速率显然是不同的,所以二者中间应该有一条边界线。这其实就是单一职责原则(SRP)的具体实现,SRP的作用就是告诉我们应该在哪里画边界线。
本章小结
为了在软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。其实,这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向。
第18章 边界剖析
跨边界调用
跨边界调用指的是边界线一侧的函数调用另一侧的函数,并同时传递数据的行为。
为什么需要管控源码中的依赖关系呢?因为当一个模块的源码发生变更时,其他模块的源码也可能会随之发生变更或重新编译,并需要重新部署。所谓划分边界,就是指在这些模块之间建立这种针对变更的防火墙。
部署层次的组件
系统架构最常见的物理边界形式:动态链接库。
与单体结构类似,按部署层次解耦的组件之间的跨边界调用也只是普通的函数调用,成本很低。
线程
线程既不属于架构边界,也不属于部署单元,它们仅仅是一种管理并调度程序执行的方式。
本地进程
系统架构还有一个更明显的物理边界形式,那就是本地进程。
本地进程之间的隔离策略也与单体结构、二进制组件基本相同,其源码中的依赖关系跨越架构边界的方向是一致的,始终指向更高层次的组件。
本地进程之间的隔离策略也与单体结构、二进制组件基本相同,其源码中的依赖关系跨越架构边界的方向是一致的,始终指向更高层次的组件。
对本地进程来说,这就意味着高层进程的源码中不应该包含低层进程的名字、物理内存地址或是注册表键名。请读者务必要记住,该系统架构的设计目标是让低层进程成为高层进程的一个插件。
本地进程之间的跨边界通信需要用到系统调用、数据的编码和解码,以及进程间的上下文切换,成本相对来说会更高一些,所以这里需要谨慎地控制通信的次数。
服务
系统架构中最强的边界形式就是服务。
服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的。因此我们在划分架构边界时,一定要尽可能地控制通信次数。
第19章 策略与层次
软件架构设计的工作重点之一就是,将这些策略彼此分离,然后将它们按照变更的方式进行重新分组。其中变更原因、时间和层次相同的策略应该被分到同一个组件中。反之,变更原因、时间和层次不同的策略则应该分属于不同的组件。
构设计的工作常常需要将组件重排组合成为一个有向无环图。图中的每一个节点代表的是一个拥有相同层次策略的组件,每一条单向链接都代表了一种组件之间的依赖关系,它们将不同级别的组件链接起来。
一般来说,低层组件被设计为依赖于高层组件。
层次
我们希望源码中的依赖关系与其数据流向脱钩,而与组件所在的层次挂钩。
通过将策略隔离,并让源码中的依赖方向都统一调整为指向高层策略,我们可以大幅度降低系统变更所带来的影响。
低层组件应该成为高层组件的插件。
第20章 业务逻辑
业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程。更严格地讲,无论这些业务逻辑是在计算机上实现的,还是人工执行的,它们在省钱/赚钱上的作用都是一样的。例如,银行要对借贷收取N%利息这个逻辑就是银行获取收入方面的一条业务逻辑。
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。我们将这种对象称为“业务实体(Entity)”。
业务实体
业务实体这个概念中应该只有业务逻辑,没有别的。
业务实体不一定非要用面向对象编程语言的类来实现。业务实体这个概念只要求我们将关键业务数据和关键业务逻辑绑定在一个独立的软件模块内。
用例
并不是所有的业务逻辑都是一个纯粹的业务实体
业务实体并不会知道是哪个业务用例在控制它们,这也是依赖反转原则(DIP)的另一个应用情景。也就是像业务实体这样的高层概念是无须了解像用例这样的低层概念的。反之,低层的业务用例却需要了解高层的业务实体。
本章小结
业务逻辑是一个软件系统存在的意义,它们属于核心功能,是系统用来赚钱或省钱的那部分代码。
业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。
第21章 尖叫的软件架构
架构设计的主题
架构设计不是(或者说不应该是)与框架相关的,这件事不应该是基于框架来完成的。框架只是一个可用的工具和手段,而不是一个架构所规范的内容。
架构设计的核心目标
一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。这就好像一个住宅建筑设计的首要目标应该是满足住宅的使用需求,而不是确保一定要用砖来构建这个房子。
良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web服务以及其他与环境相关的工具。
Web只是一种交付手段
一个系统应该尽量保持它与交付方式之间的无关性。在不更改基础架构设计的情况下,我们应该可以将一个应用程序交付成命令行程序、Web程序、富客户端程序、Web服务程序等任何一种形式的程序。
框架是工具而不是生活信条
可测试的架构设计
如果系统架构的所有设计都是围绕着用例来展开的,并且在使用框架的问题上保持谨慎的态度,那么我们就应该可以在不依赖任何框架的情况下针对这些用例进行单元测试。另外,我们在运行测试的时候不应该运行Web服务,也不应该需要连接数据库。我们测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系。
本章小结
一个系统的架构应该着重于展示系统本身的设计,而并非该系统所使用的框架。
新来的程序员应该先了解该系统的用例,而非系统的交付方式。
第22章 整洁架构
诸多架构设计方法都具有同一个设计目标:按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。 按照这些架构设计出来的系统,通常都具有以下特点。
- 独立于框架
- 可被测试
- 独立于UI
- 独立于数据库
- 独立于任何外部机构(接口)
依赖关系规则
源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
任何属于内层圆中的代码都不应该牵涉外层圆中的代码,尤其是内层圆中的代码不应该引用外层圆中代码所声明的名字,包括函数、类、变量以及一切其他有命名的软件实体。
业务实体
业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。
用例
用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
只有四层吗
并没有某个规则约定一个系统的架构有且只能有四层。然而,这其中的依赖关系原则是不变的。源码层面的依赖关系一定要指向同心圆的内侧。层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多。最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。
跨边界传输的对象应该有一个独立、简单的数据结构。总之,不要投机取巧地直接传递业务实体或数据库记录对象。同时,这些传递的数据结构中也不应该存在违反依赖规则的依赖关系。