6 软件架构
6.5 策略与层次
本质上,所有的软件系统都是一组策略语句的集合。可以说,计算机程序不过就是一组仔细描述如何将输入转化为输出的策略语句的集合。
在大多数非小型系统(nontrivial system)中,整体业务策略通常都可以被拆解为多组更小的策略语句。一部分策略语句专门用于描述计算部分的业务逻辑,另一部分策略语句则负责描述计算报告的格式,除此之外,可能还会有一些用于如何校验输入数据的策略。
软件架构设计的工作重点之一就是,将这些策略彼此分离,然后将它们按照变更的方式进行重新分组。其中变更原因、时间和层次相同的策略应该被分到同一个组件中。反之,变更原因、时间和层次不同的策略则应该分属于不同的组件。
架构设计的工作通常需要将组件重排组合成为一个有向无环图,图中的每一个节点代表的是一个拥有相同层次策略的组件,每一条单向链接都代表了一种组件之间的依赖关系,它们将不同级别的组件链接起来。
在一个设计良好的架构中,依赖关系的方向通常取决于它们所关联的组件层次,一般来说,低层组件被设计为依赖于高层组件。
层次(Level)是严格按照“输出与输出之间的距离”来定义的,也就是说,一条策略距离系统的输入/输出越远,它所属的层次就越高,而直接管理输入/输出的策略在系统中的层次是最低的。
以下图为例:
上图表示的是一个简单加密程序的数据流向图,该程序从输入设备读取字符,然后用查表法转换这些字符,并将转换后的字符输出到输出设备。图中数据的流向用弯曲实心箭头表示,对于经妙设计过的源码中的依赖关系则使用直虚线来标识。Translate组件是这个系统中层次最高的组件,因为该组件距离系统输入/输出距离最远。
另外需要注意的是,图中的数据流向和源码中的依赖关系并不总处于同一方向上,这也是软件架构设计工作的一部分,我们希望 源码中的依赖关系与其数据流向脱钩,而与组件所在的层次挂钩 。
再来看下图:
图中被虚线框起来的Encrypt类以及两个接口CharReader和CharWriter,所有的依赖关系都指向了边界内部,因此它是系统中最高层次的组件。而ConsoleReader和ConsoleWriter都属于具体类,由于它们与输入/输出最近,因此属于低层组件。另外应该注意的是,这个架构将高层的加密策略与低层的输入/输出策略解耦了,也就是说,当输入/输出部分的策略发生变更时,它们不太可能会影响加密部分的策略。
正如之前提到的,我们应该根据策略发生变更的方式来将它们分成不同的组件,变更原因和变更时间相同的策略应在SRP(单一职责原则)和CCP(共同闭包原则)这两个原则的指导下合并为同一组件。离输入/输出最远的策略——高层策略——一般变更没有那么频繁,即使发生变更,其原因也比低层策略所在的组件更重大,反之,低层策略则很有可能会频繁地进行一些小变更。
通过将策略隔离,并让源码中的依赖方向都统一调整为指向高层策略,我们可以大幅度降低系统变更所带来的影响,因为一些针对系统低层组件的紧急小修改几乎不会影响系统中更高级、更重要的组件。
从另一个角度来说,低层组件应该成为高层组件的插件,如下图所示,Encryption组件对IODevices组件的情况一无所知,而IODevices组件则依赖于Encryption组件:
6.6 业务逻辑
如果我们要将自己的应用程序划分为业务逻辑和插件两部分,就必须更仔细地了解业务逻辑究竟是什么,它到底有几种类型。
严格地讲,业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程。更严格地讲,无论这些业务逻辑是在计算机上实现的,还是人工执行的,它们在省钱/赚钱上的作用都是一样的。“关键业务逻辑”通常会需要处理一些数据,例如,在借贷的业务逻辑中,我们需要知道借贷的数量、利率以及还款日程。我们将这些数据称为“关键业务数据”,这是因为这些数据无论自动化程序存在与否,都必须要存在。
关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理,我们称这种对象为“业务实体(Entity)”。
业务实体实际上就是计算机系统中的一种对象,这种对象中包含了一系列用于操作关键数据的业务逻辑,这些实体对象要么直接包含关键业务数据,要么可以很容易地访问这些数据。业务实体的接口层则是由那些实现关键业务逻辑、操作关键业务数据的函数组成的。
以下图为例:
上图是一个对应于借贷业务的实体类Loan的UML图,该类中包含了三个关键业务数据,以及三个代表了其关键业务逻辑的接口,当我们创建这样一个类时,其实就是在将软件中具体实现了该关键业务的部分聚合在一起,将其与自动化系统中我们所构建的其他部分隔离区分,这个类独自代表了整个业务逻辑,它与数据库、用户界面、第三方框架等内容无关。该类可以在任何一个系统中提供与其业务逻辑相关的服务,它不会去管这个系统是如何呈现给用户的,数据是如何存储的,或者是以何种方式运行的。总而言之,业务实体这个概念中应该只有业务逻辑,没有别的。
注意,业务实体并不是一个类,业务实体不一定非要用面向对象编程语言的类来实现,业务实体这个概念只要求我们将关键业务数据和关键业务逻辑绑定在一个独立的软件模块内。
并不是所有的业务逻辑都是一个纯粹的业务实体,例如,有些业务逻辑是通过定义或限制自动化系统的运行方式来实现赚钱或省钱的业务的,这些业务逻辑就不能靠人工来执行,它们只有在作为自动化系统的一部分时才有意义。
例如,假设我们现在有一个银行职员们用来新建借贷的应用程序,银行可能设计的业务逻辑是,银行职员必须首先收集、验证客户的联系信息,确保客户的信用值在500以上,然后才允许向用户提供借贷还款的预估值,银行就必须要求在设计其计算机系统时确保两件事:首先客户必须能通过屏幕填写所有的联系信息并且让其通过相关验证;其次,客户只有在其信用值大于既定阈值时才能进入还款预估页。
这里描述的就是一个“用例(use case)”,用例本质上就是关于如何操作一个自动化系统的描述,它定义了用户需要提供的输入数据、用户应该得到的输出信息以及产生输出所应该采取的处理步骤。当然,用例所描述的是某种特定应用情景下的业务逻辑,它并非业务实体中所包含的关键业务逻辑。
用例中包含了对如何调用业务实体的关键业务逻辑的定义,简而言之,用例控制着业务实体之间的交互方式。此外,用例除正式地描述了数据流入/流出接口以外,并不详细描述用户界面。 也就是说,如果我们只看用例,是没有办法分辨出系统是在Web平台上交付的,还是交付了某种富客户端;或者是以命令行模式交付的,还是以一个内部服务模式交付的。
用例并不描述系统与用户之间的接口,它只描述该应用在某些特定情景下的业务逻辑,这些业务逻辑所规范的是用户与业务实体之间的交互方式,它与数据流入/流出系统的方式无关。在我们的系统中,用例本身也是一个对象,该对象中包含了一个或多个实现了特定应用情景的业务逻辑函数,当然,除此之外,用例对象中也包含了输入数据、输出数据以及相关业务实体的引用,以方便调用。
当然,业务实体并不会知道是哪个业务用例在控制它们,这也是依赖反转原则(DIP)的另一个应用情景。也就是说,像业务实体这样的高层概念是无须了解像用例这样的低层概念的。反之,低层的业务用例却需要了解高层的业务实体。也因为用例描述的是一个特定的应用情景,因此用例必然会更靠近系统的输入和输出。相对来讲,业务实体则离系统的输入和输出更远,因此业务实体是高层概念,用例是低层概念。
在通常情况下,用例会接收输入数据,并产生输出数据,但在一个设计良好的架构中,用例对象通常不应该知道数据展现给用户或者其他组件的方式。因此,用例类所接收的输入应该是一个简单的请求性数据结构,而返回输出的应该是一个简单的响应性数据结构。这些数据结构中不应该存在任何依赖关系,它们并不派生自HttpRequest和HttpResponse这样的标准框架接口,这些数据接口应该与Web无关,也不应该了解任何有关用户界面的细节。
这种独立性非常关键,如果这里的请求和响应模型不是完全独立的,那么用到这些模型的用例就会依赖于这些模型所带来的各种依赖关系。
业务逻辑是一个软件系统存在的意义,它们属于核心功能,是系统用来赚钱或省钱的那部分代码,是整个系统中的皇冠明珠。这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西,在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。
软件的系统架构应该为该系统的用例提供支持。 这就像住宅和图书馆的建筑计划满篇都在非常明显地凸显该应用程序会有哪些用例。
6.7 尖叫的软件架构
架构设计不是(或者说不应该是)与框架相关的,这件事不应该是基于框架来完成的。 对于我们来说,框架只是一个可用的工具和手段,而不是一个架构所规范的内容。如果我们的架构是基于框架来设计的,它就不能基于我们的用例来设计了。
一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。架构师应该花费很多精力来确保该架构的设计在满足用例需要的情况下,尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web服务以及其他与环境相关的工具。同时,良好的架构设计还应该让我们很容易改变这些决定。总之,良好的架构设计应该只关注用例,并能将它们与其他的周边因素隔离。
如果系统架构的所有设计都是围绕着用例来展开的,并且在使用框架的问题上保持谨慎的态度,那么我们就应该可以在不依赖任何框架的情况下针对这些用例进行单元测试。另外,我们在运行测试的时候不应该运行Web服务,也不应该需要连接数据库,我们测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系。总而言之,我们应该通过用例对象来调度业务实体对象,确保所有的测试都不需要依赖框架。
一个系统的架构应该着重于展示系统本身的设计,而并非该系统所使用的框架,如果我们要构建的是一个医疗系统,新来的程序员第一次看到其源码时就应该知道这是一个医疗系统。新来的程序员应该先了解该系统的用例,而非系统的交付方式,他们可能会起来来问你:
“我看到了一些看起来像是模型的代码——但它们的视图和控制器在哪里?”
这时你的回答应该是:
“哦,我们现在先不考虑这些细节问题,回头再来决定应该怎么做。”
6.8 整洁架构
在过去的几十年中,我们曾见证过一系列关于系统架构的想法被提出,例如:
(1) 六边形架构(Hexagonal Architecture)(也称为端口与适配器架构,Ports and Adpaters),该架构由Alistair Cockburn首先提出。Steve Freeman和Nat Pryce在他们合写的著作Growing Object Oriented Software with Tests一书中对该架构做了隆重的推荐;
(2) DCI架构:由James Coplien和Trygave Reenskaug首先提出;
(3) BCE架构:由Ivar Jacobson在他的Object Oriented Software Engineering: A Use-Case Driven Approach一书中首先提出;
六边形架构(Hexagonal Architecture),也称为端口和适配器架构(Ports and Adapters Architecture),其核心思想是将应用程序的核心业务逻辑与外部世界之间的交互进行清晰隔离,并通过定义一组抽象接口(即端口)来实现这一点。在六边形架构中,系统被设计为一个独立的“内核”,这个内核包含了所有的业务规则和领域模型,不依赖于任何特定的外部组件或基础设施。内核向外暴露了一系列的端口,每个端口定义了系统对外提供服务或者接收请求的一组契约。
外部各方(如用户界面、数据库、第三方服务等)通过适配器与这些端口交互,适配器负责将外部世界的协议转换为内部端口能够理解的格式,并反向操作以将内核的响应传递回外部,这样做的好处包括:
(1) 松耦合:业务逻辑与技术实现细节分离,使得业务规则更易于维护和测试;
(2) 可替换性:不同的适配器可以无缝切换,比如更换数据库引擎或前端框架,而不影响核心业务逻辑;
(3) 可测试性:可以通过模拟适配器(Mock Adapter)对内核进行单元测试,无需实际调用外部资源;
(4) 驱动与从动适配器:区分出驱动应用的适配器(例如用户输入或定时任务)和受应用驱动的适配器(如存储或网络服务),确保内核的稳定性和一致性;
六边形架构强调的是系统的中心化业务内核应该完全掌控系统行为,而外围的适配器则灵活应对变化的技术环境,增强了系统的适应性和扩展性:
DCI架构(Data,Context,and Interaction)强调在程序设计中结合对象的数据(Data)、上下文(Context)以及交互行为(Interaction),DCI架构提供将关注点转移到场景驱动的行为上,即在特定的业务场景下,对象如何相互协作以完成业务逻辑。
在DCI架构中:
(1) Data:指的是对象的状态或属性,它们代表了领域模型中的实体和值对象;
(2) Context:指特定的业务场景或者用例,在这个上下文中,一组对象集合在一起共同执行某个任务或完成一段业务流程;
(3) Interaction:定义了在特定上下文中,各个对象之间的动态角色分配及其相应的职责和行为,一个对象可以在不同的上下文中扮演不同的角色,并且当它进入一个新上下文时,可以临时获得新的行为(方法)来响应当前场景的需求;
通过DCI,开发者能够更加清晰地表达系统的动态行为,让代码更接近于描述现实世界中的业务流程,同时保持较低的耦合度和较高的可读性,在实现层面,可以通过各种方式来体现DCI原则,例如利用Ruby、Smalltalk等语言的开放类特性,或者在Java、C#等语言中使用混入(Mixin)、代理模式等技术来实现角色和行为的切换:
BCE架构(Business Component Enterprise,业务组件企业架构)是一种面向服务的架构(SOA,Service-Oriented Architecture)设计模式,它强调在企业应用开发中基于业务组件进行组织和构建,BCE架构旨在提高系统的可重用性、灵活性以及业务与技术的解耦。
在BCE架构中:
(1) Business Components(业务组件):是根据业务逻辑或业务功能来划分的独立单元,这些组件封闭了特定的业务规则和操作,并对外提供统一的服务接口,每个组件都代表了一段完成特定业务任务的功能实体,它们可以在不同的上下文中复用,以实现模块化和标准化的设计;
(2) Component Collaboration(组件协作):业务组件之间通过明确的接口进行通信和协作,以完成复杂的业务流程,这意味着每个组件可以独立地开发、部署和维护,同时通过定义良好的接口与其他组件集成,从而降低系统间的耦合度;
(3) Enterprise Layering(企业分层):BCE架构通常会遵循多层结构,例如常见的三层架构(表现层、业务逻辑层、数据访问层),或者更细分的层次模型,确保各层职责清晰,有利于团队分工合作,并且支持不同层级的技术替换与升级;
(4) 框架映射:在实际项目中,BCE架构理念会被映射到具体的软件开发框架上,比如在Java EE环境中可能通过EJB(Enterprise JavaBeans)、Spring框架等技术手段来实现业务组件和服务化架构;
简单来说,BCE架构是一种指导企业级应用开发实践的方法论,它鼓励将复杂的业务逻辑分解为一系列可管理、可复用的业务组件,通过松散耦合的方式让组件之间相互配合,形成灵活而稳定的企业应用架构体系:
虽然这些架构在细节上各有不同,但总体来说是非常相似的,它们都具有同一个设计目标:按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。
按照这些架构设计出来的系统,通常都具有以下特点:
(1) 独立于框架:这些系统的架构并不依赖某个功能丰富的框架之中的某个函数,框架可以被当成工具来使用,但不需要让系统来适应框架;
(2) 可被测试:这些系统的业务逻辑可以脱离UI、数据库、Web服务以及其他的外部元素来进行测试;
(3) 独立于UI:这些系统的UI变更起来很容易,不需要修改其他的系统部分,例如,我们可以在不修改业务逻辑的前提下,将一个系统的UI由Web界面替换成命令行界面;
(4) 独立于数据库:我们可以轻易将这些系统使用的Oracle、SQL Server替换成Mongo、BigTable、CouchDB之类的数据库,因为业务逻辑与数据库之间已经完成了解耦;
(5) 独立于任何外部机构:这些系统的业务逻辑并不需要知道任何其他外部接口的存在;
下图是将上述所有架构的设计理念综合成为一个独立的理念:
图中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高,外层圆代表的是机制,内层圆代表的是策略。这其中有一条贯穿整个架构设计的规则,即它的依赖关系规则:
源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
换句话说,就是任何属于内层圆中的代码都不应该牵涉外层圆中的代码,尤其是内层圆中的代码不应该引用外层圆中代码所声明的名字,包括函数、类、变量以及一切其他有命名的软件实体。同样的道理,外层圆中使用的数据格式也不应该被内层圆中的代码所使用,尤其是当数据格式是由外层圆的框架所生成时,总之,我们不应该让外层圆中发生的任何变更影响到内层圆的代码。
业务实体这一层中封装的整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。无论如何,只要它能被系统中的其他不同应用复用就可以。
如果我们在写的不是一个大型系统,而是一个单一应用的话,那么我们的业务实体就是该应用的业务对象,这些对象封装了该应用中最通用、最高层的业务逻辑,它们应该属于系统中最不容易受外界影响而变动的部分,例如,一个针对页面导航方式或者安全问题的修改不应该触及这些对象,一个针对应用在运行时的行为所做的变更也不应该影响业务实体。
软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例,这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
我们既不希望在这一层所发生的变更影响业务实体,同时也不希望这一层受外部因素(譬如数据库、UI、常见框架)的影响,用例层应该与它们都保持隔离。然而,我们知道应用行为的变化会影响用例本身,因此一定会影响用例层的代码,因此如果一个用例的细节发生了变化,这一层中的某些代码自然要受到影响。
软件的接口适配层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统(譬如数据库以及Web)最方便操作的格式。例如,这一层中应该包含整个GUI MVC框架,展示器、视图、控制器都应该属于接口适配器层,而模型部分则应该由控制器传递给用例,再由用例传回展示器和视图。
同样的,这一层的代码也会负责将数据从业务实体与用例而言最方便操作的格式,转化为对所采用的持久性框架(譬如数据库)最方便的格式,总之,在从该层再往内的同心圆中,其代码就不应该依赖任何数据库了,譬如说,如果我们采用的是SQL数据库,那么所有的SQL语句都应该被限制在这一层的代码中——而且是仅限于那些需要操作数据库的代码。
当然,这一层的代码也会负责将来自外部服务的数据转换成系统内用例与业务实体所需的格式。
最外层的模型层一般由工具、数据库、Web框架等组成的,在这一层中,我们通常只需要编写一些与内层沟通的黏合性代码。框架与驱动程序层中包含了所有的实现细节。Web是一个实现细节,数据库也是一个实现细节,我们将这些细节放在最外层,这样它们就很难影响到其他层了。
图中所显示的同心圆只是为了说明架构的结构,真正的架构很可能会超过四层,并没有某个规则约定一个系统的架构有且只能有四层,然而,这其中的依赖关系原则是不变的。也就是说,源码层面的依赖关系一定要指向同心圆的内侧,层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多,最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。
上图的右下侧,是在架构中跨边界的情况,具体来说就是控制器、展示器与下一层的用例之间的通信过程,注意这里控制流的方向:它从控制器开始,穿过用例,最后执行展示器的代码,但同时,源码中的依赖方向却都是向内指向用例的。
这种相反性可以使用依赖反转原则(DIP)来解决,例如,在Java这一类的语言中,可以通过调整代码的接口和继承关系,利用源码中的依赖关系来限制控制流只能在正确的地方跨越架构边界。假设某些用例代码需要调用展示器,这里一定不能直接调用,因为这样做会违反依赖关系原则:内层圆中的代码不能引用其外层的声明。我们需要让业务逻辑代码调用一个内层接口,并让展示器来负责实现这个接口。
我们可以采用这种方式跨越系统中所有的架构边界,利用动态多态技术,我们将源码中的依赖关系与控制流的方向进行反转,不管控制流原本的方向如何,我们都可以让它遵守架构的依赖关系规则。
一般来说,会跨越边界的数据在数据结构上都很简单,如果可以的话,我们会尽量采用一些基本的结构体或简单的可传输数据对象,或者直接通过函数调用的参数来传递数据。另外,我们也可以将数据放入哈希表,或整合成某种对象。这里最重要的是这个跨边界传输的对象应该有一个独立、简单的数据结构。总之,不要投机取巧地直接传递业务实体或数据库记录对象。同时,这些传递的数据结构中,也不应该存在违反依赖规则的依赖关系。
例如,很多数据库框架会返回一个便于查询的结果对象,我们称之为“行结构体”,这个结构体不应该跨边界向架构内的内层传递,因为这等于让内层的代码引用外层代码,违反依赖规则,因此,当我们进行跨边界传输时,一定要采用内层最方便使用的形式。