【读书笔记】架构整洁之道 P5-1 软件架构
第5部分 软件架构
第15章 什么是软件架构
也许软件架构师生产的代码量不是最多的,但是他们必须不停地承接编程任务。如果不亲身承受因系统设计而带来的麻烦,就体会不到设计不佳所带来的痛苦,接着就会逐渐迷失正确的设计方向。
软件系统的架构质量是由它的构建者所决定的,软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。
而设计软件架构的目的,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。
当然,这并不意味着好的软件架构对系统的行为就没有影响了,事实上架构在其中的角色还是很重要的。然而在这个方面,架构能起到的作用更多的时候是被动的,修饰性的,并不是主动的,更不是必不可少的。在系统的架构设计中,能影响系统行为的可选项少之又少。
软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。
开发(Development)
系统架构的作用就是要方便其开发团队对它的开发。
如果一个软件系统是由五个不同的团队合作开发的,而每个团队各自都有七个开发人员的话,不将系统划分成定义清晰的组件和可靠稳定的接口,开发工作就没法继续推进。通常,如果忽略其他因素,该系统的架构会逐渐演变成五个组件,一个组件对应一个团队。
部署(Deployment)
在通常情况下,一个系统的部署成本越高,可用性就越低。因此,实现一键式的轻松部署应该是我们设计软件架构的一个目标。
运行(Operation)
软件架构对系统运行的影响远不及它对开发、部署和维护的影响。几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计。
即使这样,软件架构在整个系统运行的过程中还发挥着另外一个重要作用,那就是一个设计良好的软件架构应该能明确地反映该系统在运行时的需求。
也许我们可以换一个更好的说法,那就是设计良好的系统架构应该可以使开发人员对系统的运行过程一目了然。架构应该起到揭示系统运行过程的作用。具体来说,就是该架构应该将系统中的用例、功能以及该系统的必备行为设置为对开发者可见的一级实体,简化它们对于系统的理解,这将为整个系统的开发与维护提供很大的帮助。
维护(Maintenance)
系统维护的主要成本集中在“探秘”和“风险”这两件事上。其中,“探秘(spelunking)”的成本主要来自我们对于现有软件系统的挖掘,目的是确定新增功能或被修复问题的最佳位置和最佳方式。而“风险(risk)”,则是指当我们进行上述修改时,总是有可能衍生出新的问题,这种可能性就是风险成本。
我们可以通过精雕细琢的架构设计极大地降低这两项成本。通过将系统切分为组件,并使用稳定的接口将组件隔离,我们可以将未来新功能的添加方式明确出来,并大幅度地降低在修改过程中对系统其他部分造成伤害的可能性。
保持可选项
正如我们在之前章节中所说的,软件有行为价值与架构价值两种价值。这其中的第二种价值又比第一种更重要,因为它正是软件之所以“软”的原因。
软件被发明出来就是因为我们需要一种灵活和便捷的方式来改变机器的行为。而软件的灵活性则取决于系统的整体状况、组件的布置以及组件之间的连接方式。
我们让软件维持“软”性的方法就是尽可能长时间地保留尽可能多的可选项。那么到底哪些选项是我们应该保留的?它们就是那些无关紧要的细节设计。
基本上,所有的软件系统都可以降解为策略与细节这两种主要元素。
- 策略体现的是软件中所有的业务规则与操作过程,因此它是系统真正的价值所在。
- 而细节则是指那些让操作该系统的人、其他系统以及程序员们与策略进行交互,但是又不会影响到策略本身的行为。它们包括I/O设备、数据库、Web系统、服务器、框架、交互协议等。
软件架构师的目标是创建一种系统形态,该形态会以策略为最基本的元素,并让细节与策略脱离关系,以允许在具体决策过程中推迟或延迟与细节相关的内容。
例如,在开发的早期阶段应该无须选择数据库系统,因为软件的高层策略不应该关心其底层到底使用哪一种数据库。事实上,如果软件架构师足够小心,软件的高层策略甚至可以不用关心该数据库是关系型数据库,还是分布式数据库,是多级数据库,还只是一些文本文件而已。
在开发的早期阶段不应过早地采用依赖注入框架(dependency injection framework),因为高层策略不应该操心如何解析系统的依赖关系。
DeepSeek:
2. 依赖注入框架属于什么?
DI 框架(如 Spring、Guice)是一个具体的技术细节和底层机制。它负责在运行时将各个组件“粘合”在一起。它属于架构中的“交付机制”(Delivery Mechanism),类似于 Web 框架、数据库驱动等。
3. 过早引入框架的问题是什么?
- 污染核心业务逻辑: 当你直接在业务代码中大量使用
@Autowired
、@Component
等框架特有注解时,你的高层策略(业务类)就与这个特定的框架紧耦合了。业务逻辑本应“纯净”,但现在它却知道自己是被一个容器管理的,并且依赖于这个容器的特定注解来工作。- 降低了业务逻辑的可测试性: 理想情况下,测试一个业务类应该不需要启动整个 DI 容器。你可以直接
new
这个类,并传入模拟的依赖对象进行单元测试。但如果业务类严重依赖框架的 DI 功能,测试时就可能被迫启动一个轻量级容器(如 Spring TestContext),这增加了测试的复杂度和耗时,不再是纯粹的单元测试。- 框架锁定和替换成本高: 假设项目初期选择了 Spring,所有业务代码都充满了 Spring 的注解。到了后期,如果发现 Guice 或 Dagger 更符合项目需求,想要更换框架将是一项浩大的工程,因为你需要修改每一个被框架“污染”的类。如果业务逻辑是独立的,那么更换框架只需要修改“组合根”(Composition Root,即组装所有对象的地方),而核心代码无需改动。
- 分散了注意力: 在项目早期,团队应该聚焦于理解和实现复杂的业务规则,而不是纠结于如何配置 DI 容器、解决循环依赖等框架技术问题。过早优化依赖关系管理可能会让你忽视更重要的架构问题。
4. 正确的做法是什么?
清洁架构建议采用 “插件式架构”:
- 定义清晰的抽象(接口): 让高层策略(业务逻辑)只依赖于它所需要的抽象接口(例如
OrderRepository
),而不是具体的实现(如MySqlOrderRepository
)。- 在核心层避免框架依赖: 业务逻辑层(领域层)的代码应该是纯正的 Java 对象,不包含任何 DI 框架的注解。依赖通过构造函数或方法参数传入(这就是所谓的“纯依赖注入”或“手动依赖注入”)。
- 将框架推到边缘:** 在架构的最外层(通常是启动层或配置层),建立一个 “组合根”。在这个地方,你才引入 DI 框架。框架的唯一职责就是读取配置,并将具体的实现“注入”或“装配”到业务逻辑层定义的抽象接口上**。
那么如果其他人已经替我们做出了决策呢?譬如说,我们的公司已经指定了某个数据库,或某种Web服务,或某个框架,这时应该怎么办?通常一个优秀的软件架构师会假装这些决策还没有确定,并尽可能长时间地让系统有推迟或修改这些决策的能力。
一个优秀的软件架构师应该致力于最大化可选项数量。
第16章 独立性
正如我们之前所述,一个设计良好的软件架构必须支持以下几点。
- 系统的用例与正常运行。
- 系统的维护。
- 系统的开发。
- 系统的部署。
用例
我们认为一个系统的架构必须能支持其自身的设计意图。也就是说,如果某系统是一个购物车应用,那么该系统的架构就必须非常直观地支持这类应用可能会涉及的所有用例。事实上,这本来就是架构师们首先要关注的问题,也是架构设计过程中的首要工作。软件的架构必须为其用例提供支持。
然而,正如我们前面所讨论的,一个系统的架构对其行为并没有太大的影响。虽然架构也可以限制一些行为选项,但这种影响所涉及的范围并不大。一个设计良好的架构在行为上对系统最重要的作用就是明确和显式地反映系统设计意图的行为,使其在架构层面上可见。
譬如说,一个架构优良的购物车应用看起来就该像是一个购物车应用。该系统的主要用例会在其系统结构上明确可见。开发人员将不需要在系统中查找系统所应有的行为,因为这些行为在系统顶层作为主要元素已经是明确可见的了,这些元素会以类、函数或模块的形式在架构中占据明显位置,它们的名字也能够清晰地描述对应的功能。
运行
对一些系统来说,这意味着它的架构应该支持将其计算部分拆分成一系列小型服务,然后让它们并行运行在不同的服务器上。而在另一些系统中,采用一堆轻量级线程,然后让这些线程共享一个运行在单处理器上的进程的地址空间。还有一些系统,它们可能只是一组运行在独立地址空间内的进程。甚至有些系统设计为一个单进程的单体程序就够了。
开发
系统的架构在支持开发环境方面当然扮演着重要的角色,我们在这里可以引述一下康威定律:任何一个组织在设计系统时,往往都会复制出一个与该组织内沟通结构相同的系统。
一个由多个不同目标的团队协作开发的系统必须具有相应的软件架构。这样,这些团队才可以各自独立地完成工作,不会彼此干扰。
部署
一个系统的架构在其部署的便捷性方面起到的作用也是非常大的。设计目标一定是实现“立刻部署”。一个设计良好的架构通常不会依赖于成堆的脚本与配置文件,也不需要用户手动创建一堆“有严格要求”的目录与文件。总而言之,一个设计良好的软件架构可以让系统在构建完成之后立刻就能部署。
同样的,这些也需要通过正确地划分、隔离系统组件来实现,这其中包括开发一些主组件,让它们将整个系统黏合在一起,正确地启动、连接并监控每个组件。
保留可选项
采用一些原则总是有助于提前解决一些平衡问题。通过遵守这些原则可以帮助我们正确地将系统划分为一些隔离良好的组件,以便尽可能长时间地为我们的未来保留尽可能多的可选项。
一个设计良好的架构应该通过保留可选项的方式,让系统在任何情况下都能方便地做出必要的变更。
按层解耦
从用例的角度来看,架构师的目标是让系统结构支持其所需要的所有用例。但是问题恰恰是我们无法预知全部的用例。好在架构师应该还是知道整个系统的基本设计意图的。也就是说,架构师应该知道自己要设计的是一个购物车系统,或是运输清单系统,还是订单处理系统。所以架构师可以通过采用单一职责原则(SRP)和共同闭包原则(CCP),以及既定的系统设计意图来隔离那些变更原因不同的部分,集成变更原因相同的部分。
哪些部分的变更原因是不同的呢?这在有些情况下是很显而易见的。譬如,用户界面的变更原因肯定和业务逻辑是不相关的,而业务用例则通常在两边都存在着相关的元素。所以很显然,优秀的架构师应该会将用例的UI部分与其业务逻辑部分隔离,这样这两部分就既可以各自进行变更,也能保证用例的完整清晰。
而业务逻辑则既可以是与应用程序紧密相关的,也可以是更具有普适性的。例如,对输入字段的校验是一个与应用程序本身紧密相关的业务逻辑。相反,计算账户利息以及清点库存则是一个与具体领域更为相关的业务逻辑。这两种不同的业务逻辑通常有着不同的变更速率和变更原因——它们应该被相互隔离,以方便各自的变更。
至于数据库,以及其所采用的查询语言,甚至表结构,这些都是系统的技术细节信息,它们与业务规则或UI毫无关系。这就意味着它们的变更原因、变更速率必然与系统的其他方面各不相同。因此,架构师也应该将它们与系统其他部分隔离,以方便各自的变更。
这样一来,我们就发现了一个系统可以被解耦成若干个水平分层——UI界面、应用独有的业务逻辑、领域普适的业务逻辑、数据库等。
用例的解耦
接下来,还有什么不同原因的变更呢?答案正是这些用例本身!譬如说,添加新订单的用例与删除订单的用例在发生变更的原因上几乎肯定是不同的,而且发生变更的速率也不同。因此,我们按照用例来切分系统是非常自然的选择。
与此同时,这些用例也是上述系统水平分层的一个个垂直切片。每个用例都会用到一些UI、特定应用的业务逻辑、应用无关的业务逻辑以及数据库功能。因此,我们在将系统水平切分成多个分层的同时,也在按用例将其切分成多个垂直切片。
为了实现这样的解耦,我们应该将增加订单这个用例的UI与删除订单用例的UI分开。而且,对业务逻辑的部分、数据库的部分,也要做同样的事情,将其按照用例进行垂直切分。
由此,我们可以总结出一个模式:**如果我们按照变更原因的不同对系统进行解耦,就可以持续地向系统内添加新的用例,而不会影响旧有的用例。**如果我们同时对支持这些用例的UI和数据库也进行了分组,那么每个用例使用的就是不同面向的UI与数据库,因此增加新用例就更不太可能会影响旧有的用例了。
解耦的模式
现在我们来想想所有的这些解耦动作对架构设计的第二个目标——系统运行——究竟有什么意义。如果不同面向之间的用例得到了良好的隔离,那么需要高吞吐量的用例就和需要低吞吐量的用例互相自然分开了。如果UI和数据库的部分能从业务逻辑分离出来,那么它们就可以运行在不同的服务器上。而且需要较大带宽的应用也可以在多个服务器上运行多个实例。
总而言之,这种按用例解耦的动作是有利于系统运行的。然而出于系统运行效率的考虑,我们的解耦动作还应该注意选择恰当的模式。譬如,为了在不同的服务器上运行,被隔离的组件不能依赖于某个处理器上的同一个地址空间,它们必须是独立的服务,然后通过某种网络来进行通信。
许多架构师将上面这种组件称为“服务”或“微服务”。对于这种基于服务来构建的架构,架构师们通常称之为面向服务的架构(service-oriented architecture)。
开发的独立性
只要系统按照其水平分层和用例进行了恰当的解耦,整个系统的架构就可以支持多团队开发,不管团队组织形式是分功能开发、分组件开发、分层开发,还是按照别的什么变量分工都可以。
部署的独立性
这种按用例和水平分层的解耦也会给系统的部署带来极大的灵活性。实际上,如果解耦工作做得好,我们甚至可以在系统运行过程中热切换(hot-swap)其各个分层实现和具体用例。在这种情况下,我们增加新用例就只需要在系统中添加一些新的jar文件,或启动一些服务即可,其他部分将完全不受影响。
再谈解耦模式
让我们再回到解耦模式的问题上来。按水平分层和用例解耦一个系统有很多种方式。例如,我们可以在源码层次上解耦、二进制层次上解耦(部署),也可以在执行单元层次上解耦(服务)。
- 源码层次:我们可以控制源代码模块之间的依赖关系,以此来实现一个模块的变更不会导致其他模块也需要变更或重新编译(例如Ruby Gem)。在这种解耦模式下,系统所有的组件都会在同一个地址空间内执行。人们经常把这种模式叫作单体结构。
- 部署层次:我们可以控制部署单元(譬如jar文件、DLL、共享库等)之间的依赖关系,以此来实现一个模块的变更不会导致其他模块的重新构建和部署。在这种模式下,大部分组件可能还是依然运行在同一个地址空间内,通过彼此的函数调用通信。但有一些别的组件可能会运行在同一个处理器下的其他进程内,使用跨进程通信,或者通过socket或共享内存进行通信。这里最重要的是,这些组件的解耦产生出许多可独立部署的单元,例如jar文件、Gem文件和DLL等
- 服务层次:我们可以将组件间的依赖关系降低到数据结构级别,然后仅通过网络数据包来进行通信。这样系统的每个执行单元在源码层和二进制层都会是一个独立的个体,它们的变更不会影响其他地方(例如,常见的服务或微服务就都是如此的)。
一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。
并且,一个设计良好的架构在上述过程中还应该能保护系统的大部分源码不受变更影响。对整个系统来说,解耦模式也应该是一个可选项。我们在进行大型部署时可以采用一种模式,而在进行小型部署时则可以采用另一种模式。
第17章 划分边界
软件架构设计本身就是一门划分边界的艺术。边界的作用是将软件分割成各种元素,以便约束边界两侧之间的依赖关系。
一个系统最消耗人力资源的是什么?答案是系统中存在的耦合——尤其是那些过早做出的、不成熟的决策所导致的耦合。
那么,怎样的决策会被认为是过早且不成熟的呢?答案是那些决策与系统的业务需求(也就是用例)无关。这部分决策包括我们要采用的框架、数据库、Web服务器、工具库、依赖注入等。在一个设计良好的系统架构中,这些细节性的决策都应该是辅助性的,可以被推迟的。一个设计良好的系统架构不应该依赖于这些细节,而应该尽可能地推迟这些细节性的决策,并致力于将这种推迟所产生的影响降到最低。
几个悲伤的故事
P公司雇用了一群二十几岁的Java程序员,开始着手将他们的产品Web化。这群Java小子满脑子朝思暮想的就是如何将大规模服务器集群应用起来,所以他们采用了一个三层的富“架构”[2](这里将“架构”用引号括起来是因为这三层结构并不是真正的架构,而是一种部署拓扑,而后者恰恰是一个好的架构所要延后的决策之一。),将系统的各层应用分布到一个大型服务集群中,这样一来,GUI、中间件和数据库自然就都要运行在不同的服务器上。
FitNesse
我和我儿子Micah在2001年创立了一家叫FitNesse的公司。想法很简单,就是用一个简单的wiki来包装一下Ward Cunningham的FIT工具,以编写验收测试(acceptance test)。
这件事情发生在Maven工具面世并“解决了”jar文件问题之前。我当时坚信我们的产品不应该让用户下载超过一个的jar文件,我称这条规则为“下载即可执行”。这条规则指导了我们之后的很多决策。
- 第一个决策是根据FitNesse的需要专门编写了属于我们自己的Web服务器。这可能听起来很傻,即使在2001年,市面上也有足够多的开源的Web服务器可供选用。然而编写属于自己的Web服务器实际上是一个非常好的决策,因为一个只包含基本功能的Web服务器部署起来非常简单,它允许我们将任何与具体Web框架相关的决策延后[4]。
- 另一个早期决策是避免考虑数据库问题。
在开发FitNesse的早期,我们在业务逻辑和数据库之间画了一条边界线。这条线有效地防止了业务逻辑对数据库产生依赖,它只能访问简单的数据访问方法。
简单来说,通过划清边界,我们可以推迟和延后一些细节性的决策,这最终会为我们节省大量的时间、避免大量的问题。这就是一个设计良好的架构所应该带来的助益。
应在何时、何处画这些线
边界线应该画在那些不相关的事情中间。GUI与业务逻辑无关,所以两者之间应该有一条边界线。数据库与GUI无关,这两者之间也应该有一条边界线。数据库又与业务逻辑无关,所以两者之间也应该有一条边界线。
大部分人都已经习惯性地认为数据库是与业务逻辑不可分割的了,有些人甚至认为,数据库相关逻辑部分本身就是业务逻辑的具体体现。然而正如我们在第18章中将会讲到的,这个想法从根本上就是错误的。数据库应该是业务逻辑间接使用的一个工具。业务逻辑并不需要了解数据库的表结构、查询语言或其他任何数据库内部的实现细节。业务逻辑唯一需要知道的,就是有一组可以用来查询和保存数据的函数。这样一来,我们才可以将数据库隐藏在接口后面。
我们可以从图17.1中清晰地看到,BusinessRules是通过DatabaseInterface来加载和保存数据的。而DatabaseAccess则负责实现该接口,以及其与实际Database的交互。那么这里的边界线应该被画在哪里?边界应该穿过继承关系,在DatabaseInterface之下(见图17.2)。
请注意,DatabaseAccess类的那两个对外的箭头。这两个箭头都指向了远离DatabaseAccess 类的方向,这意味着它们所指向的两个类都不知道DatabaseAccess类的存在。
下面让我们把抽象层次拉高一点,看一下包含多个业务逻辑类的组件与包含数据库及其访问类的组件之间是什么关系(见图17.3)。
请注意,图17.3中的箭头指向,它说明了Database组件知道BusinessRules组件的存在,而BusinessRules组件则不知道Database组件的存在。这意味着DatabaseInterface类是包含在BusinessRules组件中的,而DatabaseAccess类则被包含在Database组件中。
这个箭头的方向很重要。因为它意味着Database组件不会对BusinessRules组件形成干扰,但Database组件却不能脱离BusinessRules组件而存在。
如果读者对上面这段话感到困惑,请记住一点,Database组件中包含了将BusinessRules组件中的函数调用转化为具体数据库查询语言的代码。这些转换代码当然必须知道BusinessRules组件的存在。
通过在这两个组件之间画边界线,并且让箭头指向BusinessRules组件,我们现在可以很容易地明白为什么BusinessRules组件可以使用任何一种数据库。在这里,Database组件可以被替换为多种实现,BusinessRules组件并不需要知道这件事。
输入和输出怎么办
开发者和使用者经常会对系统边界究竟如何定义而感到困惑。由于GUI能够直观看到,就很自然地把GUI当成了系统本身。这些人以GUI的视角来定义整个系统,所以认为从系统开发一开始GUI部分就应该正常工作。这是错误的,这里他们没有意识到**一个非常重要的原则,即I/O是无关紧要的**。
所以,GUI和BusinessRules这两个组件之间也应该有一条边界线(见图17.4)。
插件式架构
综上所述,我们似乎可以基于数据库和GUI这两个为例来建立一种向系统添加其他组件的模式。这种模式与支持第三方插件的系统模式是一样的。
事实上,软件开发技术发展的历史就是一个如何想方设法方便地增加插件,从而构建一个可扩展、可维护的系统架构的故事。系统的核心业务逻辑必须和其他组件隔离,保持独立,而这些其他组件要么是可以去掉的,要么是有多种实现的(见图17.5)。
插件式架构的好处
将系统设计为插件式架构,就等于构建起了一面变更无法逾越的防火墙。换句话说,只要GUI是以插件形式插入系统的业务逻辑中的,那么GUI这边所发生的变更就不会影响系统的业务逻辑。
所以,边界线也应该沿着系统的变更轴来画。也就是说,位于边界线两侧的组件应该以不同原因、不同速率变化着。
这其实就是单一职责原则(SRP)的具体实现,SRP的作用就是告诉我们应该在哪里画边界线。
为了在软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。
其实,这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向。
第18章 边界剖析
跨边界调用
在运行时,跨边界调用指的是边界线一侧的函数调用另一侧的函数,并同时传递数据的行为。构造合理的跨边界调用需要我们对源码中的依赖关系进行合理管控。
为什么需要管控源码中的依赖关系呢?因为当一个模块的源码发生变更时,其他模块的源码也可能会随之发生变更或重新编译,并需要重新部署。所谓划分边界,就是指在这些模块之间建立这种针对变更的防火墙。
令人生畏的单体结构
最简单、最常见的架构边界通常并没有一个固定的物理形式,它们只是对同一个进程、同一个地址空间内的函数和数据进行了某种划分。在第16章中,我们称之为源码层次上的解耦模式。
但是从部署的角度来看,这一切到最后都产生了一个单独的可执行文件——这就是所谓的单体结构。这个文件可能是一个静态链接形成的C/C++项目,或是一个将一堆Java类绑定在一起的jar可执行文件,或是由一系列.NET二进制文件组成的.EXE文件等。
虽然这类系统的架构边界在部署过程中并不可见,但这并不意味着它们就不存在或者没有意义。因为即使最终所有的组件都被静态链接成了一个可执行文件,这些边界的划分对该系统各组件的独立开发也是非常有意义的。
因为这类架构一般都需要利用某种动态形式的多态[6]来管理其内部的依赖关系。这也是为什么面向对象编程近几十年来逐渐成为一种重要编程范式的原因之一。如果不采用面向对象编程模式或是类似的多态实现,架构师们就只能退回到用函数指针这种危险的模式来进行组件解耦的时代。由于大部分架构师认为大量采用函数指针过于危险,所以在那样的情况下,他们通常都在权衡利弊之后就干脆放弃划分组件了。
最简单的跨边界调用形式,是由低层客户端来调用高层服务函数,这种依赖关系在运行时和编译时会保持指向一致,都是从低层组件指向高层组件。
在图18.1中,我们可以看到控制流跨越边界的方向是从左向右的,Client调用了Service上的函数f(),并向它传递了一个Data实例。这里的<DS>标记是指Data是一个数据结构。Data实例的具体传递方法可以是函数的调用参数,也可以是其他更复杂的传递方式。读者在这里需要注意的是,Data的定义位于边界的被调用方一侧。
但当高层组件中的客户端需要调用低层组件中的服务时,我们就需要运用动态形式的多态来反转依赖关系了。在这种情况下,系统在运行时的依赖关系与编译时的依赖关系就是相反的。
在图18.2中,控制流跨越边界的方向与之前是一样的,都是从左至右的。这里是高层组件Client通过Service接口调用了低层组件ServiceImpl上的函数f()。但请读者注意,图18.2中所有的依赖关系却都是从右向左跨越边界的,方向是由低层组件指向高层组件的。同时,我们也应该注意到,这一次数据结构的定义是位于调用方这一侧的。
即使是在一个单体部署、静态链接的可执行文件中,这种自律式的组件划分仍然可以极大地帮助整个项目的开发、测试与部署,使不同的团队可以独立开发不同的组件,不会互相干扰。高层组件与低层细节之间也可以得到良好的隔离,独立演进。
部署层次的组件
下面我们来看看系统架构最常见的物理边界形式:动态链接库。这种形式包括.Net的DLL、Java的jar文件、Ruby Gem以及UNIX的共享库等。这种类型的组件在部署时不需要重新编译,因为它们都是以二进制形式或其他等价的可部署形式交付的。这里采用的就是部署层次上的解耦模式。部署这种类型的项目,就是将其所有可部署的单元打包成一个便于操作的文件格式,例如WAR文件,甚至可以只是一个目录(或者文件夹)。
除这一点以外,这种按部署层次解耦的组件与单体结构几乎是一样的,其所有的函数仍然处于同一个进程、同一个地址空间中。管理组件划分依赖关系的策略也基本上是和上文一致的[7]。
线程
单体结构和按部署层次划分的组件都可以采用线程模型。当然,线程既不属于架构边界,也不属于部署单元,它们仅仅是一种管理并调度程序执行的方式。一个线程既可以被包含在单一组件中,也可以横跨多个组件。
本地进程
系统架构还有一个更明显的物理边界形式,那就是本地进程。本地进程一般是由命令行启动或其他等价的系统调用产生的。本地进程往往运行于单个处理器或多核系统的同一组处理器上,但它们拥有各自不同的地址空间。一般来说,现有的内存保护机制会使这些进程无法共享其内存,但它们通常可以用某种独立的内存区域来实现共享。
最常见的情况是,这些本地进程会用socket来实现彼此的通信。当然,它们也可以通过一些操作系统提供的方式来通信,例如共享邮件或消息队列。
每个本地进程都既可以是一个静态链接的单体结构,也可以是一个由动态链接组件组成的程序。在前一种情况下,若干个单体过程会被链接到同一个组件中。而在后一种情况下,这些单体过程可以共享同一个动态链接的可部署组件。
DeepSeek:
情况一:静态链接的单体结构
“若干个单体过程会被链接到同一个组件中”
含义:多个独立的功能模块被编译、链接到同一个可执行文件中。
举例:一个传统的单体银行应用
银行应用 (bank_app.exe) - 静态链接的单体
├── 用户认证模块 (auth.c)
├── 账户管理模块 (account.c)
├── 交易处理模块 (transaction.c)
└── 报表生成模块 (report.c)
这些模块在编译时被静态链接成一个独立的可执行文件
bank_app.exe
。当系统需要启动多个进程时:
- 认证服务进程:运行
bank_app.exe --service=auth
- 交易服务进程:运行
bank_app.exe --service=transaction
- 报表服务进程:运行
bank_app.exe --service=report
特点:
- 每个进程都包含所有模块的代码
- 通过命令行参数决定激活哪个功能
- 部署简单(只有一个文件),但内存占用大(每个进程都包含冗余代码)
情况二:动态链接的可部署组件
“这些单体过程可以共享同一个动态链接的可部署组件”
含义:多个进程共享相同的动态链接库,每个进程只包含自己特有的代码。
举例:基于微服务架构的现代银行系统
共享组件:
├── common_utils.dll - 通用工具库
├── data_models.dll - 数据模型定义
└── protocol.dll - 通信协议库独立进程:
├── auth_service.exe (依赖上述DLLs + 自有认证逻辑)
├── transaction_service.exe (依赖上述DLLs + 自有交易逻辑)
└── report_service.exe (依赖上述DLLs + 自有报表逻辑)
特点:
- 每个进程只包含必要的业务逻辑代码
- 共享的组件在内存中只需加载一次
- 更新共享组件时,所有相关进程自动受益
- 更灵活,但依赖管理更复杂
我们在这里可以将本地进程看成某种超级组件,该进程由一系列较低层次的组件组成,我们将通过动态形式的多态来管理它们之间的依赖关系。
另外,本地进程之间的隔离策略也与单体结构、二进制组件基本相同,其源码中的依赖关系跨越架构边界的方向是一致的,始终指向更高层次的组件。
对本地进程来说,这就意味着高层进程的源码中不应该包含低层进程的名字、物理内存地址或是注册表键名。请读者务必要记住,该系统架构的设计目标是让低层进程成为高层进程的一个插件。
本地进程之间的跨边界通信需要用到系统调用、数据的编码和解码,以及进程间的上下文切换,成本相对来说会更高一些,所以这里需要谨慎地控制通信的次数。
服务
系统架构中最强的边界形式就是服务。一个服务就是一个进程,它们通常由命令行环境或其他等价的系统调用来产生。服务并不依赖于具体的运行位置,两个互相通信的服务既可以处于单一物理处理器或多核系统的同一组处理器上,也可以彼此位于不同的处理器上。服务会始终假设它们之间的通信将全部通过网络进行。
服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的,其往返时间可以从几十毫秒到几秒不等。因此我们在划分架构边界时,一定要尽可能地控制通信次数。在这个层次上通信必须能够适应高延时情况。
除此之外,我们可以在服务层次上使用与本地进程相同的规则。也就是让较低层次服务成为较高层次服务的“插件”。为此,我们要确保高层服务的源码中没有包含任何与低层服务相关的物理信息(例如URI)。
本章小结
除单体结构以外,大部分系统都会同时采用多种边界划分策略。一个按照服务层次划分边界的系统也可能会在某一部分采用本地进程的边界划分模式。事实上,服务经常不过就是一系列互相作用的本地进程的某种外在形式。无论是服务还是本地进程,它们几乎肯定都是由一个或多个源码组件组成的单体结构,或者一组动态链接的可部署组件。
这也意味着一个系统中通常会同时包含高通信量、低延迟的本地架构边界和低通信量、高延迟的服务边界。
第19章 策略与层次
本质上,所有的软件系统都是一组策略语句的集合。是的,可以说计算机程序不过就是一组仔细描述如何将输入转化为输出的策略语句的集合。
在大多数非小型系统(nontrivial system)中,整体业务策略通常都可以被拆解为多组更小的策略语句。一部分策略语句专门用于描述计算部分的业务逻辑,另一部分策略语句则负责描述计算报告的格式。除此之外,可能还会有一些用于描述如何校验输入数据的策略。
软件架构设计的工作重点之一就是,将这些策略彼此分离,然后将它们按照变更的方式进行重新分组。其中变更原因、时间和层次相同的策略应该被分到同一个组件中。反之,变更原因、时间和层次不同的策略则应该分属于不同的组件。
架构设计的工作常常需要将组件重排组合成为一个有向无环图。图中的每一个节点代表的是一个拥有相同层次策略的组件,每一条单向链接都代表了一种组件之间的依赖关系,它们将不同级别的组件链接起来。
这里提到的依赖关系是源码层次上的、编译期的依赖关系。这在Java语言中就是指import语句,在C#语言中就是指using语句,在Ruby语言中就是指require语句。这里的依赖关系都是在编译过程中所必需的。
在一个设计良好的架构中,依赖关系的方向通常取决于它们所关联的组件层次。一般来说,低层组件被设计为依赖于高层组件。
层次(Level)
我们对“层次”是严格按照“输入与输出之间的距离”来定义的。也就是说,一条策略距离系统的输入/输出越远,它所属的层次就越高。而直接管理输入/输出的策略在系统中的层次是最低的。
在图19.1中,Translate组件是这个系统中层次最高的组件,因为该组件距离系统输入/输出距离最远[8]。
另外需要注意的是,图19.1中的数据流向和源码中的依赖关系并不总处于同一方向上。这也是软件架构设计工作的一部分。我们希望源码中的依赖关系与其数据流向脱钩,而与组件所在的层次挂钩。
但我们很容易将这个加密程序写成下面这样,这就构成了一个不正确的架构:
function encrypt(){while(true)writeChar(translate(readChar()));
}
上面这个程序架构设计的错误在于,它让高层组件中的函数encrypt()依赖于低层组件中的函数readChar()与writeChar()。
更好的系统架构设计应如图19.2所示。请注意图19.2中被虚线框起来的Encrypt类及其两个接口CharReader和CharWriter。所有的依赖关系都指向了边界内部。这一切都说明它是该系统中最高层次的组件。
另外应该注意的是,这个架构将高层的加密策略与低层的输入/输出策略解耦了。也就是说,当输入/输出部分的策略发生变更时,它们不太可能会影响加密部分的策略。
正如之前提到的,我们应该根据策略发生变更的方式来将它们分成不同的组件。变更原因和变更时间相同的策略应在SRP和CCP这两个原则的指导下合并为同一组件。离输入/输出最远的策略——高层策略——一般变更没有那么频繁。即使发生变更,其原因也比低层策略所在的组件更重大。反之,低层策略则很有可能会频繁地进行一些小变更。
从另一个角度来说,低层组件应该成为高层组件的插件。
综上所述,本章针对策略的讨论涉及单一职责原则(SRP)、开闭原则(OCP)、共同闭包原则(CCP)、依赖反转原则(DIP)、稳定依赖原则(SDP)以及稳定抽象原则(SAP)。
第20章 业务逻辑
如果我们要将自己的应用程序划分为业务逻辑和插件两部分,就必须更仔细地了解业务逻辑究竟是什么,它到底有几种类型。
严格地讲,业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程。更严格地讲,无论这些业务逻辑是在计算机上实现的,还是人工执行的,它们在省钱/赚钱上的作用都是一样的。
我们通常称这些逻辑为“关键业务逻辑”,因为它们是一项业务的关键部分,不管有没有自动化系统来执行这项业务,这一点是不会改变的。“关键业务逻辑”通常会需要处理一些数据,我们将这些数据称为“关键业务数据”,这是因为这些数据无论自动化程序存在与否,都必须要存在。
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。我们将这种对象称为“业务实体(Entity)”[9]。
业务实体
业务实体实际上就是计算机系统中的一种对象,这种对象中包含了一系列用于操作关键数据的业务逻辑。这些实体对象要么直接包含关键业务数据,要么可以很容易地访问这些数据。业务实体的接口层则是由那些实现关键业务逻辑、操作关键业务数据的函数组成的。
当我们创建这样一个类时,其实就是在将软件中具体实现了该关键业务的部分聚合在一起,将其与自动化系统中我们所构建的其他部分隔离区分。这个类独自代表了整个业务逻辑,它与数据库、用户界面、第三方框架等内容无关。
有些读者可能会担心我在这里把业务实体解释成一个类。不是这样的,业务实体不一定非要用面向对象编程语言的类来实现。业务实体这个概念只要求我们将关键业务数据和关键业务逻辑绑定在一个独立的软件模块内。
用例
并不是所有的业务逻辑都是一个纯粹的业务实体。例如,有些业务逻辑是通过定义或限制自动化系统的运行方式来实现赚钱或省钱的业务的。这些业务逻辑就不能靠人工来执行,它们只有在作为自动化系统的一部分时才有意义。
用例本质上就是关于如何操作一个自动化系统的描述,它定义了用户需要提供的输入数据、用户应该得到的输出信息以及产生输出所应该采取的处理步骤。当然,用例所描述的是某种特定应用情景下的业务逻辑,它并非业务实体中所包含的关键业务逻辑。
在我们的系统中,用例本身也是一个对象,该对象中包含了一个或多个实现了特定应用情景的业务逻辑函数。当然除此之外,用例对象中也包含了输入数据、输出数据以及相关业务实体的引用,以方便调用。
当然,业务实体并不会知道是哪个业务用例在控制它们,这也是依赖反转原则(DIP)的另一个应用情景。也就是像业务实体这样的高层概念是无须了解像用例这样的低层概念的。反之,低层的业务用例却需要了解高层的业务实体。
那么,为什么业务实体属于高层概念,而用例属于低层概念呢?因为用例描述的是一个特定的应用情景,这样一来,用例必然会更靠近系统的输入和输出。而业务实体是一个可以适用于多个应用情景的一般化概念,相对地离系统的输入和输出更远。所以,用例依赖于业务实体,而业务实体并不依赖于用例。
请求和响应模型
在通常情况下,用例会接收输入数据,并产生输出数据。但在一个设计良好的架构中,用例对象通常不应该知道数据展现给用户或者其他组件的方式。很显然,我们当然不会希望这些用例类中的代码出现HTML和SQL。
因此,用例类所接收的输入应该是一个简单的请求性数据结构,而返回输出的应该是一个简单的响应性数据结构。这些数据结构中不应该存在任何依赖关系,它们并不派生自HttpRequest和HttpResponse这样的标准框架接口。这些数据接口应该与Web无关,也不应该了解任何有关用户界面的细节。
这种独立性非常关键,如果这里的请求和响应模型不是完全独立的,那么用到这些模型的用例就会依赖于这些模型所带来的各种依赖关系。
可能有些读者会选择直接在数据结构中使用对业务实体对象的引用。毕竟,业务实体与请求/响应模型之间有很多相同的数据。但请一定不要这样做!这两个对象存在的意义是非常、非常不一样的。随着时间的推移,这两个对象会以不同的原因、不同的速率发生变更。所以将它们以任何方式整合在一起都是对共同闭包原则(CCP)和单一职责原则(SRP)的违反。这样做的后果,往往会导致代码中出现很多分支判断语句和中间数据。
这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。