什么是“软件架构”呢?软件架构师的工作内容究竟是什么?这项工作又是什么时候进行的呢?
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。
一个开发起来很困难的软件系统一般不太可能会有一个长久、健康的生命周期,所以系统架构的作用就是要方便其开发团队对它的开发。
不同的团队结构应该采用不同的架构设计。
这样的团队可能会发现软件架构在早期开发中反而是一种障碍。这可能就是为什么许多系统都没有设计一个良好架构的原因,因为它们的开发团队起初都很小,不需要设计一些上层建筑来限制某些事情。
这种一个组件对应一个团队的架构不太可能是该系统在部署、运行以及维护方面的最优方案。但不管怎样,如果研发团队只受开发进度来驱动的话,他们的架构设计最终一定会倾向于这个方向。
为了让开发成为有效的工作,软件系统就必须是可部署的。在通常情况下,个系统的部署成本越高,可用性就越低。因此,实现一键式的轻松部署应该是我们设计软件架构的一个目标。
软件架构对系统运行的影响远不及它对开发、部署和维护的影响。几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计。
设计良好的系统架构应该可以使开发人员对系统的运行过程一目了然,简化他们对于系统的理解,这将为整个系统的开发与维护提供很大的帮助。
在软件系统的所有方面中,维护所需的成本是最高的。满足永不停歇的新功能需求,以及修改层出不穷的系统缺陷这些工作将会占去绝大部分的人力资源。
系统维护的主要成本集中在**“探秘”和“风险”**这两件事上。其中,“探秘(spelunking) ”的成本主要来自我们对于现有软件系统的挖掘,目的是确定新增功能或被修复问题的最佳位置和最佳方式。而“风险(risk) ”,则是指当我们进行上述修改时,总是有可能衍生出新的问题,这种可能性就是风险成本。
我们可以通过精雕细琢的架构设计极大地降低这两项成本。通过将系统切分为组件,并使用稳定的接口将组件隔离,我们可以将未来新功能的添加方式明确出来,并大幅度地降低在修改过程中对系统其他部分造成伤害的可能性,
正如之前章节中所说的,软件有行为价值与架构价值两种价值。这其中的第二种价值又比第一种更重要,因为它正是软件之所以“软”的原因。
软件被发明出来就是因为我们需要一种灵活和便捷的方式来改变机器的行为。而软件的灵活性则取决于系统的整体状况、组件的布置以及组件之间的连接方式。
我们让软件维持“软”性的方法就是尽可能长时间地保留尽可能多的可选项。
那么到底哪些选项是我们应该保留的?它们就是那些无关紧要的细节设计。
基本上,所有的软件系统都可以降解为策略与细节这两种主要元素。
软件架构师的目标是创建一种系统形态,该形态会以策略为最基本的元素,并让细节与策略脱离关系,以允许在具体决策过程中推迟或延迟与细节相关的内容。
如果在开发高层策略时有意地让自己摆脱具体细节的纠缠,我们就可以将与具体实现相关的细节决策推迟或延后,因为越到项目的后期,我们就拥有越多的信息来做出合理的决策。
这样做还可以让我们有机会做不同的尝试。例如。如果我们现在手里有部分与数据库无关的高层策略,那么我们就可以用不同的数据库来做实验,以检验该系统与不同数据库之间的适应性和性能。
我们保留这些可选项的时间越长,实验的机会也就越多。而实验做得越多,我们做决策的时候就能拥有越充足的信息。
如果其他人已经替我们做出了决策?通常一个优秀的软件架构师会假装这些决策还没有确定,并尽可能长时间地让系统有推迟或修改这些决策的能力。
一个优秀的软件架构师应该致力于最大化可选项数量。
优秀的架构师会小心地将软件的高层策略与其底层实现隔离开,让高层策略与实现细节脱钩,使其策略部分完全不需要关心底层细节,当然也不会对这些细节有任何形式的依赖。另外,优秀的架构师所设计的策略应该允许系统尽可能地推迟与实现细节相关的决策,越晚做决策越好。
一个设计良好的软件架构必须支持以下几点:
一个系统的架构必须能支持其自身的设计意图。也就是说,如果某系统是一个购物车应用,那么该系统的架构就必须非常直观地支持这类应用可能会涉及的所有用例。
一个设计良好的架构在行为上对系统最重要的作用就是明确和显式地反映系统设计意图的行为,使其在架构层面上可见。
架构在支持系统运行方面扮演着更实际的角色。如果某个系统每秒要处理100 000个用户,该系统的架构就必须能支持这种级别的吞吐量和响应时间。同样的,如果某个系统要在毫秒级的时间内完成对大数据仓库的查询,那么该系统的架构也必须能支持这类操作。
系统的架构在支持开发环境方面当然扮演着重要的角色。
一个由多个不同目标的团队协作开发的系统必须具有相应的软件架构。这样,这些团队才可以各自独立地完成工作,不会彼此干扰。这就需要恰当地将系统切分为一系列隔离良好、可独立开发的组件。然后才能将这些组件分配给不同的团队,各自独立开发。
一个系统的架构在其部署的便捷性方面起到的作用也是非常大的。
设计目标一定是实现“立刻部署”。一个设计良好的架构通常不会依赖于成堆的脚本与配置文件,也不需要用户手动创建一堆“有严格要求”的目录与文件。总而言之,一个设计良好的软件架构可以让系统在构建完成之后立刻就能部署。
一个设计良好的架构应该充分地权衡以上所述的所有关注点,然后尽可能地形成一个可以同时满足所有需求的组件结构。
要实现这种平衡是很困难的。主要问题是,我们在大部分时间里是无法预知系统的所有用例的。事实上我们想要达到的目标本身就是模糊多变的。
一个设计良好的架构应该通过保留可选项的方式,让系统在任何情况下都能方便地做出必要的变更。
从用例的角度来看,架构师的目标是让系统结构支持其所需要的所有用例。但是问题恰恰是我们无法预知全部的用例。好在架构师应该还是知道整个系统的基本设计意图的。架构师可以通过采用单一职责原则(SRP)和共同闭包原则(CCP),以及既定的系统设计意图来隔离那些变更原因不同的部分,集成变更原因相同的部分。
我们进行架构设计的第三个目标是支持系统的开发。很显然,当系统组件之间被高度解耦之后,开发团队之间的干扰就大大减少了。
只要系统按照其水平分层和用例进行了恰当的解耦,整个系统的架构就可以支持多团队开发,不管团队组织形式是分功能开发、分组件开发、分层开发,还是按照别的什么变量分工都可以。
当系统按用例和水平分层的解耦之后,会给系统的部署带来极大的灵活性。
按水平分层和用例解耦一个系统有很多种方式。例如,我们可以在源码层次上解耦、二进制层次上解耦(部署),也可以在执行单元层次上解耦(服务)。
通常,我会倾向于将系统的解耦推行到某种一旦有需要就可以随时转变为服务的程度即可,让整个程序尽量长时间地保持单体结构,以便给未来留下可选项。
一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。
一个系统所适用的解耦模式可能会随着时间而变化,优秀的架构师应该能预见这一点,并且做出相应的对策。
软件架构设计本身就是一门划分边界的艺术。
架构师们所追求的目标是最大限度地降低构建和维护一个系统所需的人力资源。一个系统最消耗人力资源的是系统中存在的耦合——尤其是那些过早做出的、不成熟的决策所导致的耦合。
过早且不成熟决策——那些与系统的业务需求(也就是用例)无关的决策。
这部分决策包括我们要采用的框架、数据库、Web服务器、工具库、依赖注入等。
通过划清边界避免过早且不成熟的决策:
通过划清边界,我们可以推迟和延后一些细节性的决策,这最终会为我们节省大量的时间、避免大量的问题。这就是一个设计良好的架构所应该带来的助益。
边界线应该画在那些不相关的事情中间。
如下图, 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组件并不需要知道这件事
软件开发技术发展的历史就是一个如何想方设法方便地增加插件,从而构建一个可扩展、可维护的系统架构的故事。
系统的核心业务逻辑必须和其他组件隔离,保持独立,而这些其他组件要么是可以去掉的,要么是有多种实现的(见下图)
将系统设计为插件式架构,就等于构建起了一面变更无法逾越的防火墙。
例如:只要GUI是以插件形式插入系统的业务逻辑中的,那么GUI这边所发生的变更就不会影响系统的业务逻辑。
这其实就是单一职责原则(SRP)的具体实现,SRP的作用就是告诉我们应该在哪里画边界线。
为了在软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。
其实,这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向
一个系统的架构是由一系列软件组件以及它们之间的边界共同定义的。
跨边界调用指的是边界线一侧的函数调用另一侧的函数,并同时传递数据的行为。
构造合理的跨边界调用需要我们对源码中的依赖关系进行合理管控。
需要管控源码中的依赖关系原因:
最常见的架构边界通常并没有一个固定的物理形式,它们只是对同一个进程、同一个地址空间内的函数和数据进行了某种划分。这种划分称之为源码层次上的解耦模式。
但从部署的角度来看,这一切到最后都产生了一个单独的可执行文件——这就是所谓的单体结构。
横跨型变更处理
在单体结构中,我们可以运用多态来反转依赖关系。自律式的组件划分仍然可以极大地帮助整个项目的开发、测试与部署,使不同的团队可以独立开发不同的组件,不会互相干扰。高层组件与低层细节之间也可以得到良好的隔离,独立演进。
对本地进程来说,良好的边界划就意味着高层进程的源码中不应该包含低层进程的名字、物理内存地址或是注册表键名。需要注意的是,系统架构的设计目标是让低层进程成为高层进程的一个插件。
一个服务就是一个进程。服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的,一定要尽可能地控制通信次数。
我们可以在服务层次上使用与本地进程相同的规则。也就是让较低层次服务成为较高层次服务的“插件”。要确保高层服务的源码中没有包含任何与低层服务相关的物理信息。
除单体结构以外,大部分系统都会同时采用多种边界划分策略。一个按照服务层次划分边界的系统也可能会在某一部分采用本地进程的边界划分模式。
事实上,服务经常不过就是一系列互相作用的本地进程的某种外在形式。无论是服务还是本地进程,它们几乎肯定都是由一个或多个源码组件组成的单体结构,或者一组动态链接的可部署组件。
这也意味着一个系统中通常会同时包含高通信量、低延迟的本地架构边界和低通信量、高延迟的服务边界。
所有的软件系统都是一组策略语句的集合。计算机程序就是一组仔细描述如何将输入转化为输出的策略语句的集合。
在大多数非小型系统(nontrivial system)中,整体业务策略通常都可以被拆解为多组更小的策略语句。
软件架构设计的工作重点之一就是:将策略彼此分离,然后按照变更的方式进行重新分组。
架构设计的工作常常需要将组件重排组合成为一个有向无环图。图中的每一个节点代表的是一个拥有相同层次策略的组件,每一条单向链接都代表了一种组件之间的依赖关系,它们将不同级别的组件链接起来。
这里提到的依赖关系是源码层次上的、编译期的依赖关系。这在Java语言中就是指import语句。这里的依赖关系都是在编译过程中所必需的。
“层次”是按照“输入与输出之间的距离”来定义的。
注意:
组件划分原则:
离输入/输出最远的策略——高层策略——一般变更没有那么频繁。即使发生变更,其原因也比低层策略所在的组件更重大。反之,低层策略则很有可能会频繁地进行一些小变更。
策略隔离好处:
通过将策略隔离,并让源码中的依赖方向都统一调整为指向高层策略,可以大幅度降低系统变更所带来的影响。因为一些针对系统低层组件的紧急小修改几乎不会影响系统中更高级、更重要的组件。
本章针对策略涉及:单一职责原则(SRP)、开闭原则(OCP)、共同闭包原则(CCP)、依赖反转原则(DIP)、稳定依赖原则(SDP)以及稳定抽象原则(SAP)。
应用程序可以划分为业务逻辑和插件两部分。
业务逻辑是程序中那些真正用于赚钱或省钱的业务逻辑与过程,也称为“关键业务逻辑”。
“关键业务逻辑”通常会需要处理一些数据,这些数据称为“关键业务数据”。
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理。我们将这种对象称为“业务实体(Entity)”。
业务实体是一种对象,这种对象中包含了一系列用于操作关键数据的业务逻辑。
这些实体对象要么直接包含关键业务数据,要么可以很容易地访问这些数据。
业务实体的接口层则是由那些实现关键业务逻辑、操作关键业务数据的函数组成的。
业务实体中的类:
备注:业务实体这个概念只要求我们将关键业务数据和关键业务逻辑绑定在一个独立的软件模块内。
用例是关于如何操作一个自动化系统的描述,它定义了用户需要提供的输入数据、用户应该得到的输出信息以及产生输出所应该采取的处理步骤。
用例中包含了对如何调用业务实体中的关键业务逻辑的定义。用例控制着业务实体之间的交互方式。
用例并不描述系统与用户之间的接口,它只描述该应用在某些特定情景下的业务逻辑,这些业务逻辑所规范的是用户与业务实体之间的交互方式,它与数据流入/流出系统的方式无关。
业务实体并不会知道是哪个业务用例在控制它们,这也是依赖反转原则(DIP)的另一个应用情景。也就是像业务实体这样的高层概念是无须了解像用例这样的低层概念的。反之,低层的业务用例却需要了解高层的业务实体。
为什么业务实体属于高层概念,而用例属于低层概念呢?
因为用例描述的是一个特定的应用情景,这样一来,用例必然会更靠近系统的输入和输出。而业务实体是一个可以适用于多个应用情景的一般化概念,相对地离系统的输入和输出更远。所以,用例依赖于业务实体,而业务实体并不依赖于用例。
用例会接收输入数据,并产生输出数据。但在一个设计良好的架构中,用例对象通常不应该知道数据展现给用户或者其他组件的方式。
业务逻辑是一个软件系统存在的意义,它们属于核心功能,是系统用来赚钱或省钱的那部分代码,是整个系统中的皇冠明珠。
这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。
软件的系统架构应该为该系统的用例提供支持。软件系统的架构设计图应该非常明确地凸显该应用程序会有哪些用例。
架构设计不应该是与框架相关的,这件事不应该是基于框架来完成的。对于我们来说,框架只是一个可用的工具和手段,而不是一个架构所规范的内容。
一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。
良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web服务以及其他与环境相关的工具。同时,良好的架构设计还应该让我们很容易改变这些决定。总之,良好的架构设计应该只关注用例,并能将它们与其他的周边因素隔离。
框架通常可以是非常强大、非常有用的,使用框架也是有代价的,我们需要懂得权衡如何使用一个框架并持有怀疑的态度审视每一个框架。
我们需要仔细考虑如何能保持对系统用例的关注,避免让框架主导我们的架构设计。
一个系统的架构应该着重于展示系统本身的设计,而并非该系统所使用的框架。
这些架构具有同一个设计目标:按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。
架构特点:
如上图中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。
贯穿整个架构设计的规则,即它的依赖关系规则:
源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
我们不应该让外层圆中发生的任何变更影响到内层圆的代码。
业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。要求能被其他不同应用复用。
软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
软件的接口适配器层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统最方便操作的格式。
上图中最外层的模型层一般是由工具、数据库、Web框架等组成的。在这一层中,我们通常只需要编写一些与内层沟通的黏合性代码。
框架与驱动程序层中包含了所有的实现细节。Web是一个实现细节,数据库也是一个实现细节。我们将这些细节放在最外层,这样它们就很难影响到其他层了。
上图中所显示的同心圆只是为了说明架构的结构,真正的架构很可能会超过四层。然而,这其中的依赖关系原则是不变的。也就是说,源码层面的依赖关系一定要指向同心圆的内侧。层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多。最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。
在上图的右下侧,我们示范的是在架构中跨边界的情况。具体来说就是控制器、展示器与下一层的用例之间的通信过程。
请注意这里控制流的方向:它从控制器开始,穿过用例,最后执行展示器的代码。但同时我们也该注意到,源码中的依赖方向却都是向内指向用例的。
我们通常采用依赖反转原则(DIP)来解决这种相反性。
假设某些用例代码需要调用展示器,这里一定不能直接调用,因为这样做会违反依赖关系原则:内层圆中的代码不能引用其外层的声明。
我们可以采用这种方式(DIP)跨越系统中所有的架构边界。利用动态多态技术,我们将源码中的依赖关系与控制流的方向进行反转。不管控制流原本的方向如何,我们都可以让它遵守架构的依赖关系规则。
注意:所有跨边界的依赖线都是指向内的,这很好地遵守了架构的依赖关系规则。
通过将系统划分层次,并确保这些层次遵守依赖关系规则,就可以构建出一个天生可测试的系统,这其中的好处是不言而喻的。
谦卑对象模式最初的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离。
其设计思路非常简单,就是将这两类行为拆分成两组模块或类。
视图部分属于难以测试的谦卑对象。这种对象的代码通常应该越简单越好,它只应负责将数据填充到GUI上,而不应该对数据进行任何处理。
应用程序所能控制的、要在屏幕上显示的一切东西,都应该在视图模型中以字符串、布尔值或枚举值的形式存在。然后,视图部分除了加载视图模型所需要的值,不应该再做任何其他事情。因此,我们才能说视图是谦卑对象。
强大的可测试性是一个架构的设计是否优秀的显著衡量标准之一。谦卑对象模式就是这方面的一个非常好的例子。
我们将系统行为分割成可测试和不可测试两部分的过程常常就也定义了系统的架构边界。展示器与视图之间的边界只是多种架构边界中的一种,另外还有许多其他边界。
对于用例交互器(interactor)与数据库中间的组件,我们通常称之为数据库网关。这些数据库网关本身是一个多态接口,包含了应用程序在数据库上所要执行的创建、读取、更新、删除等所有操作。
交互器尽管不属于谦卑对象,却是可测试的,因为数据库网关通常可以被替换成对应的测试桩和测试替身类。
在每个系统架构的边界处,都有可能发现谦卑对象模式的存在。因为跨边界的通信肯定需要用到某种简单的数据结构,而边界会自然而然地将系统分割成难以测试的部分与容易测试的部分,所以通过在系统的边界处运用谦卑对象模式,我们可以大幅地提高整个系统的可测试性。
由于构建完整的架构边界很耗费成本。
需要为系统设计双向的多态边界接口,用于输入和输出的数据结构,以及所有相关的依赖关系管理,以便将系统分割成可独立编译与部署的组件。这里会涉及大量的前期工作,以及大量的后期维护工作。
设计架构边界的成本很高,但为了应对将来可能的需要,通常还是希望预留一个边界。
但这种预防性设计显然违背了YAGNI原则(“You Aren’t Going to Need It”,意即“不要预测未来的需要”)。架构师的工作本身就是要做这样的预见性设计,这时候,我们就需要引入不完全边界(partial boundary)的概念了。
在设计一套完整的系统架构边界时,往往需要用反向接口来维护边界两侧组件的隔离性。而且,维护这种双向的隔离性,通常不会是一次性的工作,它需要我们持续地长期投入资源维护下去。这里我们可以使用单向边界。
案例:
如下图Client使用的是一个由ServiceImpl类实现的ServiceBoundary接口。
优点:
存在的问题:
采用门户模式(facadepattern),其架构如图24.2所示。
优点:
存在的问题:
本章所介绍的这策略,每种实现方式都有相应的成本和收益。每种方式都有自己所适用的场景,它们可以被用来充当最终完整架构边界的临时替代品。同时,如果这些边界最终被证明是没有必要存在的,那么也可以被自然降解。
架构师的职责之一就是预判未来哪里有可能会需要设置架构边界,并决定应该以完全形式还是不完全形式来实现它们。
入门通常将系统分成三个组件:UI、业务逻辑和数据库,但不仅仅如此。通过文本游戏加深理解。
这个游戏的操作是通过一些像GO EAST和SHOOTWEST这样的简单文字命令来完成的。玩家在输入命令之后,计算机就会返回玩家角色所看到的、闻到的、听到的或体会到的事情。在这个游戏中,玩家会在一系列洞穴中追捕Wumpus。玩家必须避开陷阱、陷坑以及其他一系列危险。
将UI与游戏业务逻辑之间的耦合解开,以便我们的游戏版本可以在不同地区使用不同的语言。
假设玩家在游戏中的状态会被保存在某种持久化存储介质中——有可能闪存,也有可能是某种云端存储,或只是本机内存。无论怎样,我们都并不希望游戏引擎了解这些细节。
这里的设计也要合理地遵守依赖关系原则,如下图:
这里具备了采用整洁架构方法所需要的一切,包括用例、业务实体以及对应的数据结构都有了,但架构边界还不清晰。
例如,语言并不是UI变更的唯一方向。我们可能还会需要变更文字输入/输出的方式。例如,我们的输入/输出可以采用命令行窗口,或者用短信息,或者采用某种聊天程序。这里的可能性有很多。
这类变更应该有一个对应的架构边界。也许我们需要构造一个API,以便将语言部分与通信部分隔开,该设计的结构应如图25.3所示。
在该图中,虚线框代表的是抽象组件,它们所定义的API通常要交由其上下层的组件来实现。
需要注意的是:GameRules 与 Language 以及 GameRules 与 Data Storage 都是双向边界。
重点看一下这句:如果我们进一步查看GameRules内部,就会发现GameRules组件的代码中使用的Boundary 多态接口是由 Language 组件来实现的;同时还会发现Language组件使用的Boundary多态接口由GameRules代码实现。
我们可以去掉所有的具体实现类,只保留API组件来进一步简化上面这张设计图,其简化的结果如图25.4所示。
所有的箭头都是朝上的。这样GameRules组件就被放在顶层的位置。这种朝向设计很好地反映了GameRules作为最高层策略组件的事实。
信息流的方向:
所有来自用户的信息都会通过左下角的TextDelievery组件传入。当这些信息被上传到Language组件时,就会转换为具体的命令输入给GameRules组件。然后,GameRules组件会负责处理用户的输入,并将数据发送给右下角的DataStorage组件。
接下来,GameRules会将输出向下传递到Language组件,将其转成合适的语言并通过TextDelievery将该语言传递给用户。
这种设计方式将数据流分成两路。
两条数据流在顶部的 GameRules 汇聚。GameRules 组件是所有数据的最终处理者。
假设我们现在要在网络上与多个其他玩家一起玩这个游戏,就会需要一个网络组件,如图25.5所示。这样一来,我们有了三条数据流,它们都由GameRules组件所控制。
GameRules组件:游戏的部分业务逻辑处理的是玩家在地图中的行走。这一部分需要知道游戏中的洞穴如何相连,每个洞穴中有什么物体存在,还要知道如何将玩家从一个洞穴移到另一个洞穴,以及如何触发各种需要玩家处理的事件。
游戏中还有一组更高层次的策略——这些策略负责了解玩家的血量,以及每个事件的后果和影响。高层组件则要管理玩家状态(如图25.6所示),最终该策略将会决定玩家在游戏中的输赢。
我们设计这个例子的目的就是为了证明架构边界可以存在于任何地方。作为架构师,我们必须要小心审视究竟在什么地方才需要设计架构边界。另外,我们还必须弄清楚完全实现这些边界将会带来多大的成本。
同时,我们也必须要了解如果事先忽略了这些边界,后续再添加会有多么困难。
作为架构师,我们应该怎么办?这个问题恐怕没有答案:
作为软件架构师,我们必须有一点未卜先知的能力。有时候要依靠猜测——当然还要用点脑子。软件架构师必须仔细权衡成本,决定哪里需要设计架构边界,以及这些地方需要的是完整的边界,还是不完全的边界,还是可以忽略的边界。
而且,这不是一次性的决定。我们不能在项目开始时就决定好哪里需要设计边界,哪里不需要。相反,架构师必须持续观察系统的演进,时刻注意哪里可能需要设计边界,然后仔细观察这些地方会由于不存在边界而出现哪些问题。
当出现问题时,我们还需要权衡一下实现这个边界的成本,并拿它与不实现这个边界的成本对比——这种对比经常需要反复地进行。我们的目标是找到设置边界的优势超过其成本的拐点,那就是实现该边界的最佳时机。
在所有的系统中,都至少要有一个组件来负责创建、协调、监督其他组件的运转。我们将其称为Main组件。
Main组件是系统中最细节化的部分——也就是底层的策略,它是整个系统的初始点。在整个系统中,除了操作系统不会再有其他组件依赖于它了。
Main组件的任务是创建所有的工厂类、策略类以及其他的全局设施,并最终将系统的控制权转交给最高抽象层的代码来处理。
Main组件中的依赖关系通常应该由依赖注入框架来注入。在该框架将依赖关系注入到Main组件之后,Main组件就应该可以在不依赖于该框架的情况下自行分配这些依赖关系了。
注意:Main组件是整个系统中细节信息最多的组件。
Main组件也可以被视为应用程序的一个插件——这个插件负责设置起始状态、配置信息、加载外部资源,最后将控制权转交给应用程序的其他高层组件。
由于Main组件能以插件形式存在于系统中,因此我们可以为一个系统设计多个Main组件,让它们各自对应于不同的配置。
当我们将Main组件视为一种插件时,用架构边界将它与系统其他部分隔离开将更容易实现。
面向服务的“架构”以及微服务“架构”,在服务之间并不一定做到了强一致性或支持独立开发和部署。
服务不等于架构。架构设计的任务就是找到高层策略与低层细节之间的架构边界,同时保证这些边界遵守依赖关系规则。
服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关。
不管是单体程序,还是多组件程序,系统架构都是由那些跨越架构边界的关键函数调用来定义的,并且整个架构必须遵守依赖关系规则。系统中许多其他的函数虽然也起到了隔离行为的效果,但它们显然并不具有架构意义。
服务是一种跨进程/平台边界的函数调用。有些服务会具有架构上的意义,有些则没有。
服务可能所带来的好处:解耦合、独立开发部署,这些好处是需要经过精心设计的服务才有。
通过对SOLID设计原则的仔细考虑,一开始就设计出一系列多态化的类,以应对将来新功能的扩展需要。
服务也可以按照SOLID原则来设计,按照组件结构来部署,这样就可以做到在添加/删除组件时不影响服务中的其他组件。
系统的架构边界事实上并不落在服务之间,而是穿透所有服务,在服务内部以组件的形式存在。
为了处理这个所有大型系统都会遇到的横跨型变更问题,我们必须在服务内部采用遵守依赖关系原则的组件设计方式。服务边界并不能代表系统的架构边界,服务内部的组件边界才是。
虽然服务化可能有助于提升系统的可扩展性和可研发性,但服务本身却并不能代表整个系统的架构设计。系统的架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中各组件之间的调用和通信方式无关。 一个服务可能是一个独立组件,以系统架构边界的形式隔开。一个服务也可能由几个组件组成,其中的组件以架构边界的形式互相隔离。在极端情况下[19],客户端和服务端甚至可能会由于耦合得过于紧密而不具备系统架构意义上的隔离性。
我们可以将测试组件视为系统架构中最外圈的程序。它们始终是向内依赖的,而且系统中没有其他组件依赖于它们。
测试组件可以独立部署的。测试组件通常是一个系统中最独立的组件。测试组件的存在是为了支持开发过程,而不是运行过程。
测试应该是系统的一个重要组成部分,我们需要精心设计这些测试,才能让它们发挥验证系统稳定性和预防问题复发的作用。
软件(software)应该是一种使用周期很长的东西,而固件(firmware)则会随着硬件演进而淘汰过时。
“虽然软件质量本身并不会随时间推移而损耗,但是未妥善管理的硬件依赖和固件依赖却是软件的头号杀手。”
未意识到的固件代码:
如果你在代码中嵌入了SQL或者是代码中引入了对某个平台的依赖的话,其实就是在写固件代码。
不要再写固件代码了,让我们的代码活得更久一点!
软件与固件集成在一起也属于设计上的反模式(anti-pattern)。符合这种反模式的代码修改起来都会很困难。
软件与固件之间的分割线往往没有代码与硬件之间的分割线那么清晰。为了将边界定义的更清晰,软件与固件之间的边界被称为硬件抽象层(HAL)。
HAL的存在是为了给它上层的软件提供服务,HAL的API应该按照这些软件的需要来量身定做。
光有HAL还不够,程序往往运行在操作系统上。为了延长代码的生命周期,我们必须将操作系统也定义为实现细节,让代码避免与操作系统层产生依赖。
整洁的嵌入式架构会引入操作系统抽象层(OSAL,如图29.6所示),将软件与操作系统分隔开。
定义了OSAL好处:
分层架构的理念是基于接口编程的理念来设计的。当模块之间能以接口形式交互时,我们就可以将一个服务替换成另外一个服务。
为了让我们的产品能长期地保持健康,请别让你的代码都变成固件。如果一个系统的代码只能在目标硬件上测试,那么它的开发过程会变得非常艰难。
从系统架构的角度来看,数据库并不重要——它只是一个实现细节,在系统架构中并不占据重要角色。如果就数据库与整个系统架构的关系打个比方,它们之间就好比是门把手和整个房屋架构的关系。
数据的组织结构,数据的模型,都是系统架构中的重要部分,但是从磁盘上存储/读取数据的机制和手段却没那么重要。关系型数据库强制我们将数据存储成表格并且以SQL访问,主要是为了后者。总而言之,数据本身很重要,但数据库系统仅仅是一个实现细节。
Web技术被普遍应用以来,全部计算资源集中在中央服务器上,还是将计算资源分散到各个终端上,在过去十几年中来回摇摆了几次。
GUI只是一个实现细节。而Web则是GUI的一种,所以也是一个实现细节。作为一名软件架构师,我们需要将这类细节与核心业务逻辑隔离开来。
Web只是一种I/O设备,编写设备无关应用程序是非常重要的。
框架并不等同于系统架构。
框架是解决框架作者以及可能还包括他的亲戚朋友所遇到的问题,而不是使用框架者遇到的问题。
我们与框架作者之间的关系是非常不对等的。我们要采用某个框架就意味着自己要遵守一大堆约定,但框架作者却完全不需要为我们遵守什么约定。
对框架作者来说,应用程序与自己的框架耦合是没有风险的。作者对框架有绝对的控制权。
有一些框架是避免不了使用的。例如,如果你在用C++,那么STL就是很难避免使用的。但这应该是自己主动选择的结果。
当我们面临框架选择时,尽量不要草率地做出决定。在全身心投入之前,应该首先看看是否可以部分地采用以增加了解。另外,请尽可能长时间地将框架留在架构边界之外,越久越好。