6 软件架构
6.9 展示器和谦卑对象
在《架构整洁之道-软件架构-策略与层次、业务逻辑、尖叫的软件架构、整洁架构》有我们提到了展示器(presenter),展示器实际上是采用谦卑对象(humble object)模式的一种形式,这种设计模式可以很好的帮助识别和保护系统架构的边界。
谦卑对象模式最初的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离。其设计思路非常简单,就是将这两类行为拆分成两组模块或类。其中一组模块被称为谦卑(Humble)组,包含了系统中所有难以测试的行为,而这些行为已经被简化到不能再简化了。另一组模块则包含了所有不属于谦卑对象的行为。
例如,GUI通常是很难进行测试的,因为让计算机自行检视屏幕内容,并检查指定元素是否出现是非常困难的,然后,GUI中的大部分行为实际上是很容易被测试的。这时候,我们可以利用谦卑对象模式将GUI的这两种行为拆分成展示器与视图两部分。
视图部分属于难以测试的谦卑对象,这种对象的代码通常应该越简单越好,它只应负责将数据填充到GUI上,而不应该对数据进行任何变更。应用程序所能控制的、要在屏幕上显示的一切东西,都应该在视图模型中以字符串、布尔值或枚举值的形式存在,视图部分除了加载视图模型所需要的值,不应该再做任何其他的事情。因此,视图是谦卑对象。
展示器则是可测试的对象,展示器的工作是负责从应用程序中接收数据,然后按视图的需要将这些数据格式化,以便视图将其呈现在屏幕上。
众所周知,强大的可测试性是一个架构的设计是否优秀的显著衡量标准之一。 谦卑对象模式就是这方面的一个非常好的例子。我们将系统分割成可测试和不可测试两部分的过程也就定义了系统的架构边界。展示器与视图之间的边界只是多种架构边界的一种,另外还有许多其他边界:
(1) 数据库网关:对于用例交互器(interactor)与数据库中间的组件,我们通常称之为数据库网关。这些数据库网关本身是一个多态接口,包含了应用程序在数据库上所要执行的创建、读取、更新、删除等所有操作。SQL不应该出现在用例层中,所以这部分的功能就需要通过网关接口来提供,而这些接口的实现在数据库层中,这些实现属于谦卑对象,它们应该只利用SQL或其他数据库提供的接口来访问所需要的数据。与之相反,交互器则不属于谦卑对象,因为它们封装的是特定应用场景下的业务逻辑,它是可测试的;
(2) 数据映射器:实际上就是Hibernate这类的ORM框架,ORM框架将数据从关系型数据库加载到了对应的数据结构中,在数据库和数据库网关接口之间构建了另一种谦卑对象的边界,它属于数据库层的模块;
(3) 服务监听器:当我们的应用程序需要与其他服务进行某种交互,或者该应用本身要提供某一套服务时,我们的应用程序会将数据加载到简单的数据结构中,并将这些数据结构跨边界传输给那些能够将其格式化并传递到其他外部服务的模块,而在输入端,服务监听器会负责从服务接口中接收数据,并将其格式化成该应用程序易用的格式,以实现跨服务边界的传输;
在每个系统架构的边界处,都有可能发现谦卑对象模式的存在,因为跨边界的通信肯定要用到某种简单的数据结构,而边界会自然而然地将系统分割成难以测试的部分与容易测试的部分,所以通过在系统的边界处运用谦卑对象模式,可以大幅地提高整体系统的可测试性。
6.10 不完全边界
构建完整的架构边界是一件很耗费成本的事,在这个过程中,需要为系统设计双向的多态边界接口,用于输入和输出的数据结构,以及所有相关的依赖关系管理,以便将系统分割成可独立编译与部署的组件。
在很多情况下,非常优秀的架构师也会认为设计架构边界的成本太高了,但为了应对将来可能的需要,通常还是希望预留一个边界。但这种预防性设计在敏捷社区里是饱受诟病的,因为它显然违背了YAGNI原则(You Aren't Going to Need It),然而,架构师的工作本身就是要做这样的预见性设计,因此,我们引入了不完全边界(partial boundary)的概念。
构建不完全边界的一种方式就是在将系统分割成一系列可独立编译、独立部署的组件之后,再把它们构建成一个组件。换句话说,在将系统中所有的接口、用于输入/输出的数据格式等每一件事都设置好之后,仍选择将它们统一编译和部署为一个组件。
显然,这种不完全边界所需要的代码量以及设计的工作量,和设计完整边界时是完全一样的。但它省去了多组件管理这部分的工作,这就等于省去了版本号管理和发布管理方面的工作。
在设计一套完整的系统架构边界时,往往需要用反向接口来维护边界两侧组件的隔离性。而且,维护这种双向的隔离性,通常不会是一次性的工作,它需要我们持续地长期投入资源维护下去。
下图中,你会看到一个临时占位的,将来可被替换成完整架构边界的更简单的结构,这个结构采用了传统的策略模式(strategy pattern),Client使用的是一个由ServiceImpl类实现的ServiceBoundary接口:
很明显,上述设计为未来构建完整的系统架构边界打下了坚实基础,为了未来将Client与ServiceImpl隔离,必要的依赖反转已经做完了,图中的虚线箭头代表了未来可能很快就会出现的隔离问题,由于没有采用双向反向接口,这部分就只能依赖开发者和架构师的自律性来保证组件持久隔离了。
除了策略模式外,门户模式也体现了架构边界设计,如下图所示:
架构的边界由Facade类来定义,这个类的背后是一份包含了所有服务函数的列表,它会将负责Client的调用传递给对Client不可见的服务函数,但需要注意的是,在该设计中Client会传递性地依赖于所有的Service类,在静态类型语言中,这就意味着对Service类的源码所做的任何修改都会导致Client的重新编译。
6.11 层次与边界
人们通常习惯于将系统分成三个组件:UI、业务逻辑和数据库,对于一些简单系统来说,的确可以这样,但稍复杂一些的系统的组件就远不止三个组件了。
我们以1972年风靡一时的基本文本的冒险游戏Hunt the Wumpus来举例,这个游戏的操作是通过一些像GO EAST和SHOOT WEST这样的简单文字命令来完成的,玩家在输入命令之后,计算机就会返回玩家角色所看到的、闻到的、听到的或体会到的事情,在这个游戏中,玩家会在一系列洞穴中追捕Wumpus,玩家必须避开陷阱、陷坑以及其他一系列的危险。
现在,假设我们决定保留这种基于文本的UI,但是需要将UI与游戏业务逻辑之间的耦合解开,以便我们的游戏版本可以在不同地区使用不同的语言,也就是说,游戏的业务逻辑与UI之间应该用一种与自然语言无关的API来进行通信,而由UI负责将API传递进来的信息转换成合适的自然语言,多个UI组件复用同一套游戏业务逻辑,而游戏的业务逻辑组件不知道,也不必知道UI正在使用哪一种自然语言:
同时,假设玩家在游戏中的状态会保存在某种持久化存储介质中------有可能闪存,也有可能是某种云端存储,或只是本机内存,无论怎样,我们都并不希望游戏引擎了解这些细节,所以,我们仍然需要创建一个API来负责游戏的业务逻辑组件与数据存储组件之间的通信。由于我们不会希望让游戏的业务逻辑依赖于不同各类的数据存储,所以这里的设计也要合理地遵守依赖关系原则,这样的话,该游戏的结构如下图所示:
很显然,这里具备了采用整洁架构方法所需要的一切,包括用例、业务实体以及对应的数据结构都有了,但我们是否已经找到了所有相应的架构边界呢?
例如,语言并不是UI变更的唯一方向,我们可能还会需要变更文字输入/输出的方式,例如,我们的输入/输出可以采用命令行窗口,或者用短信息,或者采用某种聊天程序,这里的可能性有很多,这就意味着这类变更应该有一个对应的架构边界,也许我们需要构造一个API,以便将语言部分与通信部分隔开,这样一来,该设计的结构应如下图所示:
在上图中可以看到,现在系统的结构已经变得有点复杂了,在该图中,虚线框代表的是抽象组件,它们所定义的API通常要交由其上下层的组件来实现,例如,Language部分的API是由English和Spanish这两个组件来实现的,GameRules与Language这两个组件之间的交互是通过一个由GameRules定义,并由Language实现的API来完成的,同样的,Language与TextDelievery实现的API来完成,这些API的定义和维护都是由使用方来负责的,而非实现方。GameRules组件的代码中使用的Boundary多态接口是由Language组件来实现的,Language组件使用的Boundary多态接口是由GameRules代码实现的。而Language组件的Boundary多态接口是在TextDelievery组件的代码来实现的,而TextDelievery使用的Boundary多态接口则由Language实现的。
在所有的这些场景中,由Boundary接口所定义的API都是由其使用者的上一层组件负责维护的。不同的具体实现类,例如English、SMS、CloudData都实现了由抽象的API组件所定义的多态接口,例如,Language组件中定义的多态接口是由English和Spanish这两个组件来定义的,当我们去掉所有的具体实现类,只保留API组件来进一步简化上面这张设计图,则如下所示:
注意,上图中的所有箭头都是朝上的,这样GameRules组件就被放在顶层的位置,这种朝向设计很好的反映了GameRules作为最高层策略组件的事实。再来看一下信息流的方向,首先,所有来自用户的信息都会通过左下角的TextDelievery组件传入,当这些组件被上传到Language组件时,就会转换为具体的命令输入给GameRules组件,然后,GameRules组件会负责处理用户的输入,并将数据发送给右下角的DataStorage组件。接下来,GameRules会将输出向下传递到Language组件,将其转成合适的语言并通过TextDelievery将语言传递给用户。
这种设计方式将数据流分成两路,左侧的数据流关注如何与用户通信,而右侧的数据流关注的是数据持久化,两条数据流在顶部的GameRules汇聚,GameRules组件是所有数据的最终处理者。
那么,这个例子中是否永远只有这两条数据流呢?当然不是,假设我们现在要在网络上与多个其他玩家一起玩这个游戏,就会需要一个网络组件,这样一来,我们就有了三条数据流,它们都由GameRules组件所控制,如下图所示:
由此可见,随着系统的复杂化,组件在架构中自然会分裂出多条数据流来。
再来看一下GameRules组件,游戏的部分业务逻辑处理的是玩家在地图中的行走,这一部分需要知道游戏中的洞穴如何相连,每个洞穴中有什么物体存在,还要知道如何将玩家从一个洞穴移到另一个洞穴,以及如何触发各种需要玩家处理的事件。
但是游戏中还有一组更高层次的策略------------这些策略负责了解玩家的血量,以及每个事件的后果和影响。这些策略既可以让玩家逐渐损失血量,也可能由于发现食物而增加血量。总而言之,游戏的低层策略会负责向高层策略传递事件,例如,FoundFood和FellInPit。而高层组件则要管理玩家状态,最终该策略将会决定玩家在游戏中的输赢:
这些究竟是否属于架构边界呢?是否需要设计一个API来分割MoveManagement和PlayerManagement呢?在回答这些问题之前,让我们把问题弄得更有意思一点,再往里面加上微服务吧。假设我们现在面对的是一个可以面向海量玩家的新版Hunt The Wumpus游戏,它的MoveManagement组合是由玩家的本地计算机来处理的,而PlayerManagement组件则由服务端来处理,但PlayerManagement组件会为所有连接上它的MoveManagement组件提供一个微服务的API。
下图是一个简化版的设计图,现实中的Network组件通常会比图中的更复杂一些。在图中,可以看到MoveManagement与PlayerManagement之间存在一个完整的系统架构边界:
6.12 Main组件
在所有的系统中,都至少要有一个组件来负责创建、协调、监督其他组件的运转。我们将其称为Main组件。
Main组件是系统中最细节化的部分------------也就是底层的策略,它是整个系统的初始点,在整个系统中,除了操作系统不会再有其他组件依赖于它了。Main组件的任务是创建所有的工厂类、策略类以及其他的全局设施,并最终将系统的控制权转交给最高抽象层的代码来处理。
Main组件中的依赖关系通常应该由依赖注入框架来注入,在该框架将依赖关系注入到Main组件之后,Main组件就应该可以在不依赖于该框架的情况下自行分配这些依赖关系了。Main组件是整个系统中细节信息最多的组件。
Main组件也可以被视为应用程序的一个插件------------这个插件负责设置起始状态、配置信息、加载外部资源,最后将控制权转交给应用程序的其他高层组件,另外,由于Main组件能以插件形式存在于系统中,因此,我们可以为一个系统设计多个Main组件,让它们各自对应于不同的配置。
6.13 服务:宏观与微观
面向服务的"架构"以及微服务"架构"近年来非常流行,其中的原因如下:
(1) 服务之间似乎是强隔离的;
(2) 服务被认为是支持独立开发和部署的;
然而,事实上,并不是这样。
如前文所述,架构设计的任务就是找到高层策略与低层细节之间的架构边界,同时保证这些边界遵守依赖关系规则。事实上,所谓的服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关。
当然,这里并不是说所有的服务都应该具有系统架构上的意义。有时候,用服务这种形式来隔离不同平台或进程中的程序行为这件事本身就很重要------------不管它们是否遵守依赖关系规则。我们只是认为,服务本身并不能完全代表系统架构。
以函数为例,不管是单体程序,还是多组件程序,系统架构都是由那些跨越架构边界的关键函数调用来定义的,并且整个架构必须遵守依赖关系规则,系统中许多其他的函数虽然也起到了隔离行为的效果,但它们并不具有架构意义。服务的情况也是一样,服务这种形式说到底不过是一种跨进程/平台边界的函数调用而已。有些服务会具有架构上的意义,有些则没有。
很多人认为将系统拆分成服务的一个最重要的好处就是让每个服务之间实现强解耦。毕竟,每个服务都是以一个不同的进程来运行的,甚至运行在不同的处理器上,因此,服务之间通常不能访问彼此的变量,服务之间的接口一定是充分定义的。
从一定程度上来说,这是对的。确实,服务之间的确在变量层面做到了彼此隔离。然而,它们之间还是可能会因为处理器内的共享资源,或者通过网络共享资源而彼此耦合的,另外,任何形式的共享数据行为都会导致强耦合。
例如,如果给服务之间传递的数据记录中增加了一个新字段,那么每个需要操作这个字段的服务都必须要做出相应的变更,服务之间必须对这条数据的解读达成一致。因此,其实这些服务全部是强耦合于这条数据结构的,因此它们是间接彼此耦合的。
也就是说,"服务之间似乎是强隔离的"并不完全是这样的。
人们认为的另一个使用服务的好处就是,不同的服务可以由不同的专门团队负责和运维,这让开发团队可以采用dev-ops混合的形式来编写、维护以及运维各自的服务,这种开发和部署上的独立性被认为是可扩展的,这种观点认为大型系统可以由几十个、几百个、甚至几千个独立开发部署的服务组成,整个系统的研发、维护以及运维工作就可以由同等量级的团队来共同完成。
这种理念有一些道理,但也仅仅是一些而已。首先,无数历史事实证明,大型系统一样可以采用单体模式,或者组件模式来构建,不一定非要服务化,因此服务化并不是构建大型系统的唯一选择。其次,上文说到的解耦合谬论已经说明拆分服务并不意味着这些服务可以彼此独立开发、部署和运维。如果这些服务之间以数据形式或者行为形式相耦合,那么它们的开发、部署和运维也必须彼此协调来进行。
我们来看一个出租车调度系统,该系统会负责统一调度给定城市中的多个出租车提供商,而用户可以集中在它那里下订单,在这里,我们假设用户在租车时往往会附带一组参考条件,例如接送时间、价格、豪华程度、司机的经验等。
我们希望整个系统是可扩展的,于是该系统大量采用了微服务架构,然后,我们进一步将整个研发团队划分为许多个小团队,每个团队都负责开发、维护和运维相应的小数量的微服务。其架构如下所示:
整个系统都是依靠服务来构建的,TaxiUI服务负责与用户打交道,用户会通过移动设备向它下订单。TaxiFinder服务负责调用不同的TaxiSupplier服务来获取可用车辆的信息,并且找出可用的出租车以作为可推荐项。这些可推荐项会短期地被固化成一条数据记录,与用户信息挂钩。TaxiSelector服务则负责根据用户所选择的价格、时间、豪华程度等条件从可选项中筛选结果,最后这些结果会被传递给TaxiDispatcher服务,由它负责分派订单。
这时,用户提出了新的需求:公司将在城市中建立几个猫咪集散点,允许用户向系统下订单,要求将他们的猫咪送到自己家里或者办公室,当用户下订单时,附近的一辆出租车将被选中去集散点取猫,并将猫送到指定地点。而由于有些司机会对猫过敏,所以系统还必须要避免选中这些人去运送猫咪。同样的,由于出租车的乘客中也会有对猫过敏的人,所以当他们叫车时,系统也必须避免指派过去三天内运送过猫咪的车。
这时我们会发现,上述系统架构图内的所有微服务都需要变更,换句话说,这些服务事实上全部都是强耦合的,并不能真正做到独立开发、部署和维护。
这就是所谓的横跨型变更(cross-cutting concern)问题,它是所有的软件系统都要面对的问题,无论服务化还是非服务化的。而上图这样按功能切分服务的架构方式,在跨系统的功能变更时是最脆弱的。
如果采用组件化的系统架构,如何解决这个难题呢?通过对SOLID设计原则的仔细考虑,我们应该一开始就设计出一系列多态化的类,以应对将来新功能的扩展需要。
这种策略下的系统架构如下图所示:
这个架构下的类与前文的服务是大致对应的,但它设置了架构边界,并且遵守了依赖关系原则。现在,原先服务化设计中的大部分逻辑都被包含在对象模型的基类中,然而,针对每次特定行程的逻辑被抽离到一个单独的Rides组件中,运送猫咪的新功能被放入到Kittens组件中。这两个组件覆盖了原始组件中的抽象基类,这种设计模式被称作模板方法模式或策略模式。
同时我们也会注意到Rides和Kittens这两个新组件都遵守了依赖关系原则,另外,实现功能的类也都是由UI控制下的工厂类创建出来的。显然,如果我们在这种架构下引入运送猫咪的功能,TaxiUI组件就必须随之变更,但其他的组件则无须变更了,这里只需要引入一个新的jar文件或者Gem、DLL。系统在运行时就会自动动态地加载它们。这样一来,运送猫咪的功能就与系统的其他部分实现了解耦,可以实现独立开发和部署了。
那么,问题来了:服务化也可以做到这一点吗?答案是肯定的。服务并不一定必须是小型的单体程序,服务也可以按照SOLID原则来设计,按照组件结构来部署,这样就可以做到在添加/删除组件时不影响服务中的其他组件。
我们可以将Java中的服务看作是一个或多个jar文件中的一组抽象类,而每个新功能或功能扩展都是另一个jar文件中的类,它们都扩展了之前jar文件中的抽象类。这样一来,部署新功能就不再是部署服务了,而只是简单地在服务的加载路径下增加一个jar文件。换句话说,这种增加新功能的过程符合开闭原则(OCP)。
这种服务的架构如下所示:
在该架构中服务仍然和之前一样,但是每个服务中都增加了内部组件结构,以便使用衍生类来添加新功能,而这些衍生类都有各自所生存的组件。
综上,系统的架构边界事实上并不落在服务之间,而是穿透所有服务,在服务内部以组件的形式存在。 为了处理这个所有大型系统都会遇到的横跨型变更问题,我们必须在服务内部采用遵守依赖关系原则的组件设计方式,总而言之,服务边界并不能代表系统的架构边界,服务内部的组件边界才是。
虽然服务化可能有助于提升系统的可扩展性和可研发性,但服务本身却并不能代表整个系统的架构设计。系统的架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中各组件之间的调用和通信方式无关。
一个服务可能是一个独立组件,以系统架构边界的形式隔开。一个服务也可能由几个组件组成,其中的组件以架构边界的形式互相隔离。在极端情况下,客户端和服务端甚至可能会由于耦合得过于紧密而不具备系统架构意义上的隔离性。