如果你曾经参与过大规模软件的开发,无论时间是长是短,你都一定曾遇到过以下这些表现应用程序架构与设计衰败的迹象:
- 由于无法做到以合理的方式重用代码,不得不大量 _ 剪切与粘贴 _ 重复性的代码(当然,是与 bug 相关的)。糟糕的代码结构使得代码 _ 重用 _ 变得不可能;
- 以上一点导致了整个应用中充斥着 _ 重复性 _——即使只是一个简单的单一功能变更,也需要在整个应用程序中的各处进行修改;
- 整个应用程序的 _ 包结构混乱不堪 _,意味着对于新员工来说这个学习成本过高 —— 对于每个包的功能职责没有清晰的划分;
- 每个人在对系统做出变更的时候都试图 _ 将他人的变更踩在脚下 _ ——由于打包规则不一致,导致了对工作进行良好的规划显得困难重重;
- 难以实施自动化测试,由于糟糕的依赖管理,对任何包进行隔离测试都是不可能的;
- bug__ 修复非常困难,犹如意大利千层面一样混乱的类依赖使得对错误源头的追踪变得无比困难;
- 总而言之,整个应用程序显得非常不稳定,简单的功能性变更也会造成大量的代码改动,并且随时随地都会产生功能性破坏;
在本文中,我将为读者介绍一种在大规模应用程序中使用的架构引用模型(ARM),在近几年间,我已经在三个大型的企业级应用程序开发过程中成功地进行了实施(其中两个应用与金融相关,而一个则是在线拍卖应用)。
ARM 模型由五个架构层组成(用户界面层、应用层、领域层、基础设施层和平台层)。ARM 模型最主要的目标是为大规模应用程序的解耦提供一套清晰的规则集合,它鼓励关注分离、在应用程序内部(首要)以及多个应用程序之间(次要)将代码的重用最大化。由此带来了良好的代码结构、减少重复,在不断变化的需求的背景下改善应用程序的稳定性。
为了实现这一目标,ARM 模型引用了一套相关的、易于应用的规则集合,以保证你的应用程序的内聚结构,带来的好处包括:更清晰的职责(哪部分代码做哪些事),改善的代码重用(减少了代码的重复性),增加了打包规则的一致性,在包之间更好地进行依赖管理。这些都有效地减轻了以上所提到的各种问题。
1. 介绍 ARM 模型
(单击图片以放大)
图1 – 架构引用模型的五个层次,每一层都可作为一个或多个包的容器。某个包具体应当属于哪一层,取决于这个包里的类的功能,以及这些类依赖于其它哪些包。
1.1. 应用程序划分
如图 1 所示,ARM 模型共分为 5 个基本的架构层次,在接下来的部分我将对每一层进行详细解说。每一层都是包的容器,而这些包联合在一起构成了整个应用程序的源代码。特别要指出的是,这些层本身并不是包,但按照以下部分的规则所述,这些层能够帮助你判断每个包中应当包括哪些功能,并且不应当包括哪些功能。
ARM 模型包含两个相关规则的集合:通用规则适合于整个模型,以及特定于层的规则,适用于单独的层:
1.2. 通用规则
_ 层的划分是根据功能性与自然的依赖性决定的。_ 每一层都有其相关的职责指南(亦称为关注面),它决定了这一层里的包与类所允许的功能。每一层都自然地依赖于其下面的一层,与之类似,这一层里的类的功能也是基于其下一层中的类与包所提供的功能而创建的。
向下依赖。 在某一层中的包只允许依赖于,或者说导入同一层或是下一层中所属的包。另外,虽然对同一层中的包进行依赖并不会过度增加复杂性,但也应当尽量避免。
_ 包只属于一个层。_ 假设某个包中包含了某些处于同一个层的类,同时还包含了属于上一层的某些类。经过良好设计的应用程序通常在较低的层次中包含了大量的类(实际上 ARM 模型的一个目标就是实现这种结构),因此很明显,如果某个包里的某些类跨越了层的边界,你就应该考虑重构这一部分。同样,按照规则来说,如果某个应用程序的全部或者大部分的包都属于较高的层次,那么这种结构就是比较糟糕的,它很可能表现出本文开头部分所描述的一系列问题。
1.3. 特定于层的规则
平台层(Platform_)支撑着整个应用程序的开发。_ 平台层中包括了支持整个应用开发时,外部所需的各种包与工具。典型的例子包括 Jakarta Struts、java.lang.*、Swing、Microsoft Foundation Classes、.NET GUI 库等等。虽然本文并不打算深入讨论平台技术的选择,但它无疑是保证整个项目成功的关键因素,因此在 ARM 模型中也将其加入进来。
基础设施层(Infrastructure_)不包括、也不依赖于任何领域特定代码。_ 基础设施层中的包包括了用于通用目的(非领域特定)的类,它为多种不同类型的应用程序提供了工具类。基础设施层应当只对平台层进行依赖(即导入)。典型的例子包括:通用目的的对象 / 关系映射代码(持久化)、通用目的的观察者机制、通用目的的基于组的安全机制、以及对平台层的功能进行封装后,所暴露的受限的 API。
_ 领域层包括了特定于领域的类(通常称为实体)。_ 领域层中的包包含了领域特定的抽象,通常表现为实体关系或领域模型。用户或外部系统的界面 / 展示层的代码是绝对禁止出现在这一层的。在企业级应用程序中,领域层应该提供这样一种观念,即所有的领域类都是属于内存中的,与持久化机制相关的细节都被隐藏起来。领域层也可能会隐藏其它基础设施层的关注,前提是没有因此带来不必要的复杂性。领域层靠上的部分包含的包是由一些“引用”对象所组成的,这些对象通常自身包含与在关系型数据库中持久化相关的信息(例如主键等等)。典型的例子包括:客户功能包(提供了对客户信息进行访问与更新的功能),用户帐号功能包。领域层靠下的部分通常包含了一系列的包,提供了一些“值”对象,靠上的领域类通常会将这些值对象作为其属性的一部分。仅举几个例子:电话号码、日期、金额等等。
_ 应用层提供了一套面向服务架构。_ 应用层中的包提供了一套特定于应用程序的事务服务,可用于用户界面层对其进行查询或更新应用程序的状态。应用层的包通常会将解耦的领域层包进行“组合”或是提供“粘合剂”(见图 4)。在应用层靠上的部分会提供事务服务,例如 createCustomer、getAllCustomers、createAccount 和 getAccountsByCriteria 等等。如果应用层靠下的部分存在任何包,那么其中将包括各种工具类子服务,它们通常不会用于事务,而是用于帮助创建应用层上部所提供的事务服务。
_ 用户界面层的包使得应用程序发挥作用!_ 用户界面层最基本的目的是与外部世界进行交互,随后对应用层发出请求,以改变应用程序的内部状态。大多数情况下,用户界面层的包会提供特定于应用程序的用户界面,即特定于应用程序功能的用户界面类(而不是通用目的的 UI 工具类)。用户界面层中可能还包括解析特定于应用程序的文件格式转换代码(例如某种自定义的 XML 格式),或是与外部系统的请求进行交互(允许 web 交互的代码),或是特定于应用程序的时间相关的功能。
用户界面层上部通常包括了完整的用户界面对话框,例如“创建一个新的客户”的界面,或是“寻找某个视频”的界面。如果存在下部,那也许会包括特定于应用程序的用户界面“widget”,例如某个“根据名称查找视频”的面板(将在多个界面中使用),或是一个帐户类型的下拉菜单(包括例如“当前帐户”、“储蓄帐户”等值)。下部的界面 widget 将用于创建上部的用户界面。
2. 案例学习——影碟商店
理论部分到此已经足够了,我将为读者展现一个影碟商店应用程序的示例。虽然它的需求明显经过了简化(见图 2 与图 3),但依然足以显示某些架构方面的复杂性。
- 客户能够通过电子邮件或短信的方式得知某部影碟可以租借了
- 客户只需对短信进行回复,就可以租借这部影碟
- 客户可以通过影片的不完整标题进行搜索,并租借该影碟(使用 GUI)
图 2 —— 影碟商店应用程序的第 3 个迭代中的部分需求。该应用程序包含两种用户界面,一个基于图形界面,一个基于移动手机。图 3 表示了与该应用程序相关联的领域模型。
图 3 —— 影碟商店应用程序的领域模型
图 4 显示了影碟商店项目的第 3 个迭代之后的架构
(单击图片以放大)
图4 —— 影碟商店系统架构的详细视图,每一层都经过合理设计。包的名称也暗示了包所处的层。其中某个基础设施层的包(SMS)是由之前的项目引入的。而持久化包(负责对象/ 关系映射的管理,而与领域无关)只显示了大概的轮廓。
2.1. 用户界面层
_ 用户界面层 _ 中包含了三个包,它们的职责是让应用程序发挥实际作用:
- InterfaceReservationGUI —— 负责租借影碟的用户界面,
- InterfaceIncomingSMS —— 负责当某个客户回复了 SMS 之后,对该 SMS 文本进行解析,并创建一条该影碟的租借记录。
- InterfaceAlertControl —— 负责定期发送通知。
这三个包中的类都继承于某个父类,或是实现了某个接口,因此它们都依赖于基础设施层或平台层中的包。在三种情况下,应用程序通过某种回调方式(使用某种称为控制反转或依赖注入的技术),将控制基础设施层或平台层的代码中传递到用户界面层的代码中。这种情况并不少见,在构建代码时通过这种方式移除了对应用程序或特定领域的依赖,以此保证了更好的代码结构,并且使得 SMS 包可在多种场景下使用。ARM 模型为这种代码结构提供了保证。
2.2. 应用层
_ 应用层 _ 中包含了四个解耦的的包,每个包中各自提供了一系列的服务,可用于用户界面层的代码。注意,在图 4 中提到的 ReservationServices 代码将由 SMSParser 与 InterfaceReservationGUI 两个包进行引用,每个包各自为相同的底层功能提供了一个不同的用户界面。这种代码结构能够将用户界面层与应用层中的关注进行分离。
2.3. 领域层
_ 领域层 _ 中包含了四个包,每个包包含了领域模型中定义的某个实体,而每个实体都遵循了通用的项目模式(见图 5)。
特别值得一提的是,领域层中的每个包都与其它包完全解耦。任何一个领域层的包都不依赖于其它的包:Videos 不了解 Customers 包,Customers 包也不了解 Videos 包,或许更令人惊奇的是,Reservations 包(见图 6)不直接依赖于以上任意一个包。
图 5 —— 领域层中的 DomainVideos 包的内容的详细视图。Videos 包为应用层的代码提供了一套机制,让 Video 的键(Key)的集合能够返回应用层。每个单独的键将用于初始化各个 Video 信息。领域层中的其它包也遵循了相同的模式。
图6 —— DomainReservation 包与DomainVideo 或DomainCustomer 包是完全解耦的。通过一种简单的技术实现了这一点,即在Reservation 中仅存储reservedItem 对象与reserver 对象的键。在这个示例中,由应用层代码决定如何处理这些键。此外还有多种方式可以实现领域包的解耦。比方说:应用层代码可以使用适配器(Adapter)对领域层的包进行组装。请注意,这里所展示的键(Key)类是由持久化包引入的。(见图4)
DomainAlerts 包负责对通知进行追踪,并在需要时发送通知。
图 4 并没有显示发送通知的具体机制,它只是提供了一个(Java 类型的)接口,由 ApplicationAlertServices 负责具体实现。如果确定只需要一种通知机制,那么这种方式或许带来了些不必要的复杂性。但由于通知的发送可使用电子邮件或 SMS,因此这种设计带来了减少所需代码的好处,并且使得对其进行自动化测试更简单了(见图 7)。该设计由此获得了更好的扩展性。
由应用层的包对领域层的包所实现的行为进行定制化,这种做法并不罕见。实际上,这也是将领域层与应用层进行分离的原因之一。
图7 —— TestDomainAlerts 在这里充当了应用层的包的角色,它作为客户端调用领域层。为了对DomainAlerts 进行隔离测试,TestDomainAlerts 提供了一个MockAlerter 类,它能够验证是否正确地调用了通知。这是一个典型的依赖注入的方法。
2.4. 基础设施层
_ 基础设施层 _ 中包含了两个包。其中 InfrastructurePersistence 包本身的内容就需要单独一篇文章的篇幅进行描述,但对我们来说,只需要了解它的职责是管理对关系型数据库的接口、暴露 Key 类(很可能是一个实现了版本化的 Key 类)与 Transaction(unit of work)类,并确保所有的内容都将保存在数据库中。如图所示,应用层与领域层中的类都直接依赖于持久化层的功能。并且由应用层中的包所暴露的每个服务都会使用到 Transaction 类中的功能。
更令我们感兴趣的是 InfrastructrueSMS 包,它提供了通用目的的 SMS 功能,很显然它非常适合于成为基础设施层的一部分,并且可以在多个项目中进行重用。在这个示例中,我们初始化了一个 SMSListener 类,用以侦听某个电话号码(即我们用以获取文本信息的号码)。收到的消息会被转发给 SMSActioner 接口,并由 InterfaceIncomingSMS 包中的 SMSParser 接口进行调用。见图 8。
(单击图片以放大)
图8 ——[1] 收到的SMS 会通过回调调用InterfaceIncomingSMS 包。[2.1] SMSParser.processIncomingSMS 将收到的信息进行解析,获取其中的Video ID(包含在消息体中),随后[2.2] 通过电话号码找到发送了该SMS 的客户,最后[3] 成功地下单租借该影碟。
2.5. 平台层
_ 平台层 _ 中包含了支撑整个开发过程的构建块。如果你假设该影碟商店应用程序是用 Java 编写的,那么它多数会使用标准的 Java 类库以创建图形用户界面,并且与数据库进行通信(通过 JDBC),实现基本的计时机制,并发送电子邮件。
3. 重新审视 ARM 模型
3.1. 一个概念化与实用的模型
从以上的示例中你能够看到,这个引用模型是相当概念化的,它帮助你思考如何进行应用程序的结构设计。但它也是实用的,它帮助你将源代码中划分为实际的包,帮助我们获得一个结构良好的应用程序,具有高度的内聚性以及受控的依赖项。
结论:
ARM__ 模型(包括它的规则)规定了 将一个大型的应用程序横向地切分为多个包,由包中的类的职责,以及这些类之间所存在的自然的依赖决定如何进行切分。
3.2. 对层进行深入观察
那么,在这个模型中,所谓的某一层处于另一个层的“上方”究竟代表了什么含义呢?ARM 模型包括了优秀的应用程序结构所必备的三个要素:
- _ 依赖。_ 简单来说就是编译期的依赖。处于上层的包能够从相同的层或是较低的层中导入包,换句话说,依赖方向总是朝下的。获得一个高灵活性的应用程序架构的一个关键特性,就在于对依赖的理解与管理,而 ARM 将帮助你实现这一点。
- 功能的特性与共性。层次越高,所提供的逻辑就越与应用相关,操作的功能性也就越强,同时也越接近于一个完整的应用程序。换句话说,越靠近高层,越容易通过所提供的工具实现特定于应用程序的逻辑。反之,越靠近底层,创建特定于应用程序的逻辑所需要的工作就越多,但同时,你能够利用这一层所支持不同应用程序的可能性也就越大。
- 稳定性。层次越高,包的稳定性(出于客户需求变更的原因)就越差。如果你对包进行了正确的组织,那么对用户界面的包进行改动的可能性比起基础设施层来说要高得多。由于领域特定的功能已经从基础设施层从进行了分离,因此基础设施层显得相当稳定。但是,这里存在一个例外。由于现有的编程语言的技术的限制,对于某个非功能性需求的变更(例如将某个只支持单一用户的内存应用升级为支持多用户的数据库应用)仍然会导致对整个项目基础的颠覆。因此,应当尽量在项目的生命周期早期就解决这些非功能性的问题。
- 依赖管理与功能性的强弱以及特定性是整体相关的概念,正是因为这个原因,我们才将高级别的功能从低级别的功能中抽取出来,并且让前者依赖于后者。稳定性取决于是否能够将更有可能产生变更的高级别功能抽取出来。通过以上这些因素的结合,ARM 模型成为了企业级架构军火库中的强力武器。
3.3. 为何要定义 5 个层?
如你所见,平台层是技术的根本,它支撑着整个应用程序的开发。建立一套正确的平台层组件本身就足以成为一个项目,并成为 ARM 模型的一部分,尽管它与基础设施层中有许多相似之处。不过,平台层与基础设施层的区别非常明显:如果某些部分是由外部构成的,并且与领域层无关,那它就是平台层的一部分。如果某些部分是内部实现的,那它就是基础设施层的一部分。再次强调,不要让领域层的依赖渗透到这一层。
用户界面层与应用层的区别同样十分明显——如果某个类直接与特定于应用程序的用户界面相关联,那它就属于用户界面代码;如果某段代码用于处理与外部系统的集成 ,例如 web services,那它还是用户界面代码;如果某段代码用于对某个特定于应用程序的文件格式进行解析,例如某个特定于应用程序的 XML 格式,那它还是用户界面代码。但请注意,用户界面代码也能够分解为通用目的(平台)与特定于应用程序(用户界面)两部分,因此多数的 XML 解析器都属于平台部分。XML 本身与领域并不相关,但特定的 DTD 需要编写自定义的用户界面代码,用于对特定于领域的概念进行解释。
关于应用层与领域层的界限一直存在大量的疑问。如同我之前所说的那样,应用层代码用于为用户界面代码提供一套事务服务,而领域代码则提供了一套领域级别的抽象,例如 Book、Account 等等。这些抽象与应用层代码组合在一起,提供了应用程序的功能。如果没有应用层的存在,解耦的领域包之间会永远保持解耦,并且你的应用程序也将不存在了!
在多产品线构架中,应用层与领域层的界限更显得尤为重要,例如你正在开发一套互操作,互相关联的应用程序。考虑一下之前所讨论的影碟商店应用程序的例子,假设其中包含了一套库存系统,还包括一套后台管理系统。是否应当将领域层包的一部分,例如 Videos 进行重用,以进行库存管理呢?当然应该了!在这种场景下,这个领域层包应该同时用于两个应用程序中,但它们各自包含不同的应用层代码,这是因为不同的功能需求导致了这种开发模式。
4. 问题?
-
_ 我们采用的是敏捷开发,这种方式看起来需要大量的前期设计……_ 设计指南与开发流程是互不相干的概念,你可以选择事先经过几个月的设计以得出架构,也可以选择让 ARM 模型帮助你的架构逐渐演变。如果你的选择是后者,那么本文对你就是有所帮助的!
-
_ 对于这些分层,需要消耗多大的成本(从代码的角度来说)?_ARM 模型就在于打包。使用 ARM 模型所编写的每一行代码必须符合客户的需求,自动化测试的需求,以及交付一个高质量、组织良好、并且能够在代码级别反映出设计意图的应用程序的需求。并不是说你必须为 ARM 编写更多的代码,你要做的只是将正确的代码放到正确的包里就行了。
-
_ 依赖看起来是跨层的,那么是否应当规定某个层只允许使用下面的层的功能呢?_ 不。考虑一下上面的例子中提到的 ReservationVideoButton,它直接继承于 PlatformGUI 包。如果应用了这条规定,那么其中所牵涉到的每个层都需要将下面的一层所提供的功能封装起来,而实现这一点将可能带来无意义的复杂性。
-
_ 持久化必须属于基础设施层或是平台层吗?_ 并非总是如此。要创建一个通用目的的持久化机制需要大量的精力与优秀的技能,而对于单一的项目来说这或许有些过度了。在这种情况下,编写一种特定于领域的持久化机制(在某些时候会为领域类创建相应的中介(broker))也不是一种罕见的选择。这种方式虽然会带来一定程度上的代码重复(理想的方式是将重复的代码重新组织到某个基础设施层的包之内),但这种重复或许是必要的。最终答案完全取决于实际的环境。
-
_ARM__ 能够配合代码生成器共同工作吗?_ 代码生成,例如以 EJB 的风格生成特定于领域的持久化代码,是一个与 ARM 不相关的关注面,但它也许会造成某些困惑之处。如果你发现你对某些部分感到困难,不妨看看所生成的代码是否能够与 ARM 良好地进行配合。
-
_ 怎样对应用程序进行垂直划分呢?ARM__ 只关注水平划分,不是吗?_ 没错,正如我之前所暗示的那样,在层与属于该层的包之间存在着一种 1 对多的关系。从完整性的角度来看,你需要在划分时遵照某些指南,以下两个打包的原则应当对你有所帮助:
- 通用闭包原理(Common Closure Principle),或称为 CCP[RMartin]。该原理说道:“将可能会一起产生变更的东西打包在一起”。DomainAlerts 包包含了 Alerts、Alert 以及 AlertSender 这几个包,因为这些它们具有高度的相关性(高度耦合),对其中任何一个进行变更都有可能影响到其它包。
- 通用重用原理(Common Re-use Principle),或称为 CRP [RMartin]。该原理说道:“将在一起使用的东西打包在一起”。ApplicationAlertServices(见图 4)包括了三个类,从它们在这个包的中的关系来看,它们显然是互不相关的。但经过更仔细的观察,发现 AlertServices.SendNecessaryAlerts 方法必须有一个或多个 AlertSenders 的具体实现才能够使用,以上的示例中包含了 EmailAlertSender 与 SMSAlertSender 这两个具体的实现类。证明了通用重用原理的应用。
-
_ 如果要在我自己的项目中应用这个模型,有哪些实际的操作步骤呢?_ 使用 ARM 模型跟踪你的项目的打包结构是极其方便的。你只需要一块巨大的白板,根据层次将它分为五个水平部分。随后,为你的项目中的每个包画出一个包的符号,注明包的名称。以箭头的方式显示出包之间的依赖,当然,方向都是向下的。这种图形通常被称作打包图。
5. 结论
在本文中,我为读者展示了一个架构引用模型,这个模型我已在多个企业级应用程序中成功地应用了。该模型与其中的规则将帮助你改善你的代码结构,尤其是使包级别的职责更加清晰,改善整体的代码组织,减少代码重复,并使你能够更有效地管理包之间的依赖。
接下来的整篇文章介绍了如何应对本文中所引入的一些问题:
- ARM 模型提倡将重复性的代码放到较低的层;将重复的应用层代码放到领域层或是基础设施层(取决于这段代码是否对领域产生依赖);将重复的领域层代码放到基础设施层,等等;
- 以上结果减少了代码重复,使得对功能的变更更加简单;
- 打包结构现在由一个或多个内聚的规则所决定,因此对于新员工来说,能够更容易地找到代码所在的位置;
- 在墙上挂起的打包图意味着工作进度的调整更为方便;
- 由于对依赖进行了更好的控制,因此自动化测试与代码修复变得更简单了。并且由于将代码放到了正确的层中,因此代码的组织也得到了改善。
- 整个应用程序变得更稳健了,由于与领域无关的代码放到了基础设施层中,意味着它们不会受到功能的变更所影响。而将重复性的应用层代码放到领域层也保证了在功能需求不断变化的情况下,所有的改动都尽量限定在本地。
参考以下资料,你将会了解更多与 ARM 模型相关的内容:
- 《极限编程研究》(Extreme Programming Examined)的第四章(Succi & Marchesi - Addison-Wesley,2001)。
6. 参考与致谢
[RMartin] – 《粒度》(granularity),Robert C. Martin - C++ Report,1996
特别感谢 Hubert Matthews,我在这一话题上的前几篇论文是与他共同完成的。此外还要感谢 Andrew Vautier 与来自埃森哲的 Anders Nestors,他所创建的那个超过 1 百万行的 C++ 银行项目为本文中提及的多个概念提供了极好的素材。
同样要感谢 Ilja Preuß,他帮助我审查了本文的草稿,并且提供了极宝贵的建议。
关于作者
Mark Collins-Cope在软件开发行业有着超过 20 年的经验,在 2000 年早期就成为了 Agile 使用者的一员。他也是《使用 Iconix 过程进行敏捷软件开发》– Apress,2005 一书的作者。本文的图片来自于某个为期一天的培训课程 ——企业架构及高级 OO 设计,通过以下邮件可以获得更多课程的细节。Mark 也为高知特进行顾问工作,可以通过 mark.collinscope@cognizant.com 或 markcollinscope@gmail.com 与他进行联系。
评论