如果你曾经参与过大规模软件的开发,无论时间是长是短,你都一定曾遇到过以下这些表现应用程序架构与设计衰败的迹象:
在本文中,我将为读者介绍一种在大规模应用程序中使用的架构引用模型(ARM),在近几年间,我已经在三个大型的企业级应用程序开发过程中成功地进行了实施(其中两个应用与金融相关,而一个则是在线拍卖应用)。
ARM模型由五个架构层组成(用户界面层、应用层、领域层、基础设施层和平台层)。ARM模型最主要的目标是为大规模应用程序的解耦提供一套清晰的规则集合,它鼓励关注分离、在应用程序内部(首要)以及多个应用程序之间(次要)将代码的重用最大化。由此带来了良好的代码结构、减少重复,在不断变化的需求的背景下改善应用程序的稳定性。
为了实现这一目标,ARM模型引用了一套相关的、易于应用的规则集合,以保证你的应用程序的内聚结构,带来的好处包括:更清晰的职责(哪部分代码做哪些事),改善的代码重用(减少了代码的重复性),增加了打包规则的一致性,在包之间更好地进行依赖管理。这些都有效地减轻了以上所提到的各种问题。
(单击图片以放大)
图1 – 架构引用模型的五个层次,每一层都可作为一个或多个包的容器。某个包具体应当属于哪一层,取决于这个包里的类的功能,以及这些类依赖于其它哪些包。
如图1所示,ARM模型共分为5个基本的架构层次,在接下来的部分我将对每一层进行详细解说。每一层都是包的容器,而这些包联合在一起构成了整个应用程序的源代码。特别要指出的是,这些层本身并不是包,但按照以下部分的规则所述,这些层能够帮助你判断每个包中应当包括哪些功能,并且不应当包括哪些功能。
ARM模型包含两个相关规则的集合:通用规则适合于整个模型,以及特定于层的规则,适用于单独的层:
层的划分是根据功能性与自然的依赖性决定的。每一层都有其相关的职责指南(亦称为关注面),它决定了这一层里的包与类所允许的功能。每一层都自然地依赖于其下面的一层,与之类似,这一层里的类的功能也是基于其下一层中的类与包所提供的功能而创建的。
向下依赖。 在某一层中的包只允许依赖于,或者说导入同一层或是下一层中所属的包。另外,虽然对同一层中的包进行依赖并不会过度增加复杂性,但也应当尽量避免。
包只属于一个层。假设某个包中包含了某些处于同一个层的类,同时还包含了属于上一层的某些类。经过良好设计的应用程序通常在较低的层次中包含了大量的类(实际上ARM模型的一个目标就是实现这种结构),因此很明显,如果某个包里的某些类跨越了层的边界,你就应该考虑重构这一部分。同样,按照规则来说,如果某个应用程序的全部或者大部分的包都属于较高的层次,那么这种结构就是比较糟糕的,它很可能表现出本文开头部分所描述的一系列问题。
平台层(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与图3),但依然足以显示某些架构方面的复杂性。
图2 —— 影碟商店应用程序的第3个迭代中的部分需求。该应用程序包含两种用户界面,一个基于图形界面,一个基于移动手机。图3表示了与该应用程序相关联的领域模型。
图3 —— 影碟商店应用程序的领域模型
图4显示了影碟商店项目的第3个迭代之后的架构
(单击图片以放大)
图4 —— 影碟商店系统架构的详细视图,每一层都经过合理设计。包的名称也暗示了包所处的层。其中某个基础设施层的包(SMS)是由之前的项目引入的。而持久化包(负责对象/关系映射的管理,而与领域无关)只显示了大概的轮廓。
用户界面层中包含了三个包,它们的职责是让应用程序发挥实际作用:
这三个包中的类都继承于某个父类,或是实现了某个接口,因此它们都依赖于基础设施层或平台层中的包。在三种情况下,应用程序通过某种回调方式(使用某种称为控制反转或依赖注入的技术),将控制基础设施层或平台层的代码中传递到用户界面层的代码中。这种情况并不少见,在构建代码时通过这种方式移除了对应用程序或特定领域的依赖,以此保证了更好的代码结构,并且使得SMS包可在多种场景下使用。ARM模型为这种代码结构提供了保证。
应用层中包含了四个解耦的的包,每个包中各自提供了一系列的服务,可用于用户界面层的代码。注意,在图4中提到的ReservationServices代码将由SMSParser与InterfaceReservationGUI两个包进行引用,每个包各自为相同的底层功能提供了一个不同的用户界面。这种代码结构能够将用户界面层与应用层中的关注进行分离。
领域层中包含了四个包,每个包包含了领域模型中定义的某个实体,而每个实体都遵循了通用的项目模式(见图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类,它能够验证是否正确地调用了通知。这是一个典型的依赖注入的方法。
基础设施层中包含了两个包。其中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]成功地下单租借该影碟。
平台层中包含了支撑整个开发过程的构建块。如果你假设该影碟商店应用程序是用Java编写的,那么它多数会使用标准的Java类库以创建图形用户界面,并且与数据库进行通信(通过JDBC),实现基本的计时机制,并发送电子邮件。
从以上的示例中你能够看到,这个引用模型是相当概念化的,它帮助你思考如何进行应用程序的结构设计。但它也是实用的,它帮助你将源代码中划分为实际的包,帮助我们获得一个结构良好的应用程序,具有高度的内聚性以及受控的依赖项。
结论:
ARM模型(包括它的规则)规定了 将一个大型的应用程序横向地切分为多个包,由包中的类的职责,以及这些类之间所存在的自然的依赖决定如何进行切分。
那么,在这个模型中,所谓的某一层处于另一个层的“上方”究竟代表了什么含义呢?ARM模型包括了优秀的应用程序结构所必备的三个要素:
如你所见,平台层是技术的根本,它支撑着整个应用程序的开发。建立一套正确的平台层组件本身就足以成为一个项目,并成为ARM模型的一部分,尽管它与基础设施层中有许多相似之处。不过,平台层与基础设施层的区别非常明显:如果某些部分是由外部构成的,并且与领域层无关,那它就是平台层的一部分。如果某些部分是内部实现的,那它就是基础设施层的一部分。再次强调,不要让领域层的依赖渗透到这一层。
用户界面层与应用层的区别同样十分明显——如果某个类直接与特定于应用程序的用户界面相关联,那它就属于用户界面代码;如果某段代码用于处理与外部系统的集成 ,例如web services,那它还是用户界面代码;如果某段代码用于对某个特定于应用程序的文件格式进行解析,例如某个特定于应用程序的XML格式,那它还是用户界面代码。但请注意,用户界面代码也能够分解为通用目的(平台)与特定于应用程序(用户界面)两部分,因此多数的XML解析器都属于平台部分。XML本身与领域并不相关,但特定的DTD需要编写自定义的用户界面代码,用于对特定于领域的概念进行解释。
关于应用层与领域层的界限一直存在大量的疑问。如同我之前所说的那样,应用层代码用于为用户界面代码提供一套事务服务,而领域代码则提供了一套领域级别的抽象,例如Book、Account等等。这些抽象与应用层代码组合在一起,提供了应用程序的功能。如果没有应用层的存在,解耦的领域包之间会永远保持解耦,并且你的应用程序也将不存在了!
在多产品线构架中,应用层与领域层的界限更显得尤为重要,例如你正在开发一套互操作,互相关联的应用程序。考虑一下之前所讨论的影碟商店应用程序的例子,假设其中包含了一套库存系统,还包括一套后台管理系统。是否应当将领域层包的一部分,例如Videos进行重用,以进行库存管理呢?当然应该了!在这种场景下,这个领域层包应该同时用于两个应用程序中,但它们各自包含不同的应用层代码,这是因为不同的功能需求导致了这种开发模式。
设计指南与开发流程是互不相干的概念,你可以选择事先经过几个月的设计以得出架构,也可以选择让ARM模型帮助你的架构逐渐演变。如果你的选择是后者,那么本文对你就是有所帮助的!
ARM模型就在于打包。使用ARM模型所编写的每一行代码必须符合客户的需求,自动化测试的需求,以及交付一个高质量、组织良好、并且能够在代码级别反映出设计意图的应用程序的需求。并不是说你必须为ARM编写更多的代码,你要做的只是将正确的代码放到正确的包里就行了。
不。考虑一下上面的例子中提到的ReservationVideoButton,它直接继承于PlatformGUI包。如果应用了这条规定,那么其中所牵涉到的每个层都需要将下面的一层所提供的功能封装起来,而实现这一点将可能带来无意义的复杂性。
并非总是如此。要创建一个通用目的的持久化机制需要大量的精力与优秀的技能,而对于单一的项目来说这或许有些过度了。在这种情况下,编写一种特定于领域的持久化机制(在某些时候会为领域类创建相应的中介(broker))也不是一种罕见的选择。这种方式虽然会带来一定程度上的代码重复(理想的方式是将重复的代码重新组织到某个基础设施层的包之内),但这种重复或许是必要的。最终答案完全取决于实际的环境。
代码生成,例如以EJB的风格生成特定于领域的持久化代码,是一个与ARM不相关的关注面,但它也许会造成某些困惑之处。如果你发现你对某些部分感到困难,不妨看看所生成的代码是否能够与ARM良好地进行配合。
没错,正如我之前所暗示的那样,在层与属于该层的包之间存在着一种1对多的关系。从完整性的角度来看,你需要在划分时遵照某些指南,以下两个打包的原则应当对你有所帮助:
使用ARM模型跟踪你的项目的打包结构是极其方便的。你只需要一块巨大的白板,根据层次将它分为五个水平部分。随后,为你的项目中的每个包画出一个包的符号,注明包的名称。以箭头的方式显示出包之间的依赖,当然,方向都是向下的。这种图形通常被称作打包图。
在本文中,我为读者展示了一个架构引用模型,这个模型我已在多个企业级应用程序中成功地应用了。该模型与其中的规则将帮助你改善你的代码结构,尤其是使包级别的职责更加清晰,改善整体的代码组织,减少代码重复,并使你能够更有效地管理包之间的依赖。
接下来的整篇文章介绍了如何应对本文中所引入的一些问题:
参考以下资料,你将会了解更多与ARM模型相关的内容:
[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也为高知特进行顾问工作,可以通过[email protected] 或 [email protected]与他进行联系。
查看英文原文:ARM Yourself for Enterprise Application Development