软件设计与架构笔记(18): 面向质量属性的架构模式

截至目前我们已经讨论了若干种架构风格,实际上绝大多数工程实践都具备一定的架构风格(尽管有些风格是显式定义、有些则是隐式存在的)。显式地定义架构风格有助于维护架构设计的稳定性,例如表现该设计是基于C/S、SOA、REST或反应式架构风格,以及在应用架构风格时引入的自定义部分。某些被广泛采用的架构风格,有时也有另一个名字——架构模式(这里主要指基础架构模式)。风格与模式是完全不同的概念,前者通常更具理论性和抽象性,多用于描述设计指南或方法论;后者更注重实践性和可复用性,且通常遵循一套标准的模式语言(这得益于面向对象设计模式的流行),具有更精确的问题域和规范说明。而接下来首先要讨论的基础架构模式则兼具风格和模式的属性,它们具有普遍适用性,擅长于复杂问题的高层架构设计,当然也包含明确的结构和行为约束。此外还有数量更多的架构模式专注于解决特定场景中的问题,尽管这些问题可谓五花八门,但大部分的根本动机是改善架构质量属性,这也是本文讨论架构模式的主要视角。下一篇文章会从应用场景的角度讨论架构模式。

基础架构模式

基础架构模式产生于对复杂问题的初步认知和分解,类似构建思维导图,良好、清晰的系统结构往往是深入解决方案的前提。例如网络通信系统,先有经典的OSI七层模型,也有流行的TCP/IP五层模型,这里应用了分治策略把一个复杂问题分割为几个子问题(层),同时定义了子问题之间的关联方式。再例如一个信息系统可被划分为如下层次:表现层、领域层、数据驱动层。这种由上(外)及下(内)的层次结构,相邻层通过预定义的接口实现服务式交互,即下层作为服务提供者,上层作为消费者,即架构模式中最经典的分层模式(Layered Pattern)。分层模式表现出的优势在于:1)层次间相互独立;2)每层可复用;3)每一层可替换;4)易于理解,从而有利于维持架构设计的一致性。

分层模式并非万能,特别是某些糟糕的分层设计可能会导致较高的设计复杂度,比如虽然表面上实现了层次间的独立,但仍然存在大量逻辑和数据耦合,导致每层内部的变更不得不传递至外部——从相邻层乃至跨多层的级联变更,结果反而造成更高的维护成本和引发质量问题。同时,由于引入了层次间隔离,大多数分层设计会损失性能,在进行性能调优时也可能与该层的功能性划分冲突,导致更大范围的变更影响。分层模式的另一个挑战是软件的容错性:例如在设计错误处理时,除错误的描述以外,还要额外考虑错误处理的职责分配、处理方式、跨层传递以及其它现实因素。一种常见原则是在尽可能低层处理发生的错误,然后把错误信息传递给上层,其优点是避免由上层单独维护大量的错误处理场景。但上层通常也需要被告知错误信息并做出反应,甚至直达交互界面,以及方便运行时排错和易用性。因此,错误处理通常要在全局设计阶段进行考虑。

基础架构模式中还有一种常见的、类似水厂工艺流程的流水线(Pipeline)结构。在流水线中,原水被输入管道。依照标准工序,原水需要经过若干过滤方法处理,每道工序由对应的过滤器负责,过滤器从上游管道接收水进行加工,再把处理后水输入下游管道,再由后续过滤器处理——最终获得符合标准的自来水。在数据处理系统中,原始数据作为原水流经数据管道,管道中的过滤器依次对数据进行处理——这就是管道——过滤器模式(Pipe & Filters Pattern)。如果把过滤器视为层,那么管道——过滤器模式可以看作是分层模式的一种特例,特别是在可复用性、可替换性等方面的优势对数据处理系统来说更具价值。此外,该模式还支持可伸缩性:1)支持流(增量)数据的高效处理,因为上游过滤器不需要处理完全部数据再交给下游,从而减少整体等待时间;2)支持分块数据的高效处理,采用并行计算的能力实现过滤器的横向伸缩,缩短整体处理时间。

管道——过滤器模式也存在对应陷阱,比如一般要求过滤器之间应当完全独立,因为耦合意味着可能需要提供全局状态——这就限制了该模式在可伸缩性方面的优势,也可能会损害整体可维护性。此外,过滤器的接口设计也十分重要,例如Unix中的管道接口统一采用标准字节流,并不规定具体数据格式,因此管道两端的程序都需要实现相应的数据转换。这种方式的优点是保证高可移植性,但会增加数据序列化/反序列化的维护成本和系统开销。在许多实践中,上述成本远远超过了过滤器自身的负载需求。管道——过滤器模式还具有较分层模式更严重的容错性问题。因为不同于分层模式的“双向”通信,管道——过滤器一般是“单向”的,上游一般无法及时了解下游的错误状态,一种方法是引入全局状态管理错误信息,在此基础上实现过滤器同步——无疑会导致复杂性、可维护性以及性能等方面的损失。因此,数据处理系统一般尽量避免引入复杂的错误处理逻辑,只聚焦在错误信息采集和管理——意味着一旦错误发生,只能通过重启整条流水线来恢复系统。而如果系统对容错性有很严格的要求,则只有考虑更普适的分层模式作为妥协。

实践中还有许多复杂性问题是非确定的,例如天气/地震预报、语音识别乃至自动控制系统(机器人、无人驾驶或导弹防御系统等),凭借当前科技水平很难找到一个精确的模型能够与之对应。因此,为了不断优化得到更合理的结果,通常会考虑组合多种近似模型进行协作。其中,单一模型可能是复杂但相对确定的,多个模型间的协作方式则不确定。如果把每个模型视为独立组件,也就不存在一个固定的组件间依赖结构。这时可以采用中心化结构,即通过一个控制器控制组件协作,并且每个组件只从数据中心获取或写入数据,即黑板模式(Blackboard Pattern)。该模式包含三个基本元素:

  • 黑板,即数据中心,向知识源和控制器开放读写功能。可以是文件系统、数据库或消息队列等。

  • 知识源,即组成整体解决方案的组件,例如各种传感器、雷达、车辆控制器、武器发射控制器等。

  • 控制器,负责控制知识源的运行。

从结构上看,黑板模式可能是最复杂的、但也最灵活的一种架构模式。灵活性在于组件只需要考虑针对黑板的增删改查,由控制器负责监控黑板状态并调度组件执行,组件间几乎完全解耦。黑板模式同样具有良好的独立性、可复用性、可替换性和易修改性,还包括容错性——因为知识源只从黑板中读取和写入数据,组件错误不会发生级联传递。黑板模式主要的缺点在于响应性和复杂性,前者主要是由于组件间通信的不确定性,导致缺少可预估且合理的系统响应时间;后者是因为应用该模式所需的额外复杂度,整个工程包括前期设计、实现、调试和测试等活动都会面临挑战,当然其中一部分原因是问题自身的不确定性。

至此本文已经讨论了三种基础架构模式,它们分别代表了架构设计中的最基本结构:层次结构、流水线结构和中心化结构。尽管这些结构具有普适性,但也存在相应的陷阱,并最终影响着架构的质量属性。实践中还存在更多针对特定质量属性的架构模式,本文的后半部分将主要讨论这些模式。

面向可伸缩性的架构模式

可伸缩性是分布式系统架构的一个重要质量属性。在上一篇文章中,我们讨论了几种分布式系统架构风格的演变过程,上文提到的基本结构也普遍存在于分布式系统中。但是,仅凭这些无法实现分布式系统的可伸缩性:例如,分布式组件虽然通常都是物理隔离的,但是组件间的逻辑依赖依然存在。无论是点对点(Peer-to-Peer)结构还是主从(Master-Slave)结构,都无法避免产生组件间协作的需求。最基本的,例如服务调用方需要首先知道服务提供方的地址,这就导致组件间的动态耦合,从而限制了系统的可伸缩性。为解决该问题,实践中通常会引入一个独立的中介组件,服务提供方组件能够在中介组件中动态注册,而服务调用方只需要向中介组件发送消息,再由中介把消息交付至服务提供方——这样通过操作中介组件即可协调各个组件,这就是分布式系统架构的中介模式(Broker Pattern)。

中介模式是分布式系统的核心模式之一,如今已发展成为分布式架构的重要基础设施。中介模式的优势在于实现了分布式组件的动态解耦,并且把分布式系统协调的职责尽可能从业务组件中分离,使分布式系统开发的复杂性得到了有效管理。另一方面,中介组件会影响系统的整体响应性,也存在单点失败的问题,为此一般需要采用资源冗余的方式来保证可靠性,采用中介模式的系统还面临可追踪性与可测试性的挑战。因而,采用中介模式无论如何都会引入额外的开发和维护成本。

面向易用性的架构模式

系统易用性主要体现在交互方面,而MVC也许是迄今为止该领域中最为人熟知的架构模式,它把交互式系统划分为三种抽象组件:1)模型,提供核心功能和数据;2)视图,定义信息的展示方式;3)控制器,处理用户请求。其中,模型的数据更改会通知视图更新;视图需要依赖模型数据进行信息展示,视图还可以接受用户指令,然后将指令传递给控制器;控制器根据用户具体指令,调用模型中的具体业务逻辑以更改数据,然后调用特定的视图展示修改后的结果。MVC的重要贡献在于在一定程度上分离了模型及其用户端表示,使得交互式系统具有更好的易修改性——这一点尤为重要,因为用户界面往往是系统中改动最频繁的部分,而MVC的职责划分使视图组件更易于修改。另一方面,尽管界面变更的影响得到了限制,但模型变更则可能影响到视图和控制器,从而引起可维护性问题。MVC模式先后衍生出许多变种MV*模式,例如表示-抽象-控制(Presentation-Abstraction-Control)模式,层级模型-视图-控制器(Hierarchical Model-View-Controller)模式,模型-视图-表示(Model-View-Presenter)模式,模型-视图-视图模型(Model-View-ViewModel)模式等,这些模式通过更多抽象旨在进一步简化特定场景的用户界面,但也更加复杂从而导致更多局限性,这里不再进一步展开。

面向可适应性的架构模式

可适应性的一个典型的例子是操作系统:向下要支持多种硬件环境,向上要运行海量应用程序。可适应性指系统适应外部需求变化的能力,这一般要依赖于抽象设计:一种方式是构建一个仅实现必要功能的核心组件并对外提供服务接口,以此为基础通过扩展服务实现其它功能,包括外部扩展的功能,即微内核模式(Microkernel Pattern);另一种方式是允许外部环境通过接口改变组件功能和系统结构,即反射模式(Reflection Pattern)。下文进一步讨论这两种模式。

微内核模式要求核心组件只包含必要功能、高层结构和扩展接口,并尽可能把大多数功能分配给扩展服务。应用该模式需要先定义一个稳定的抽象内核,其它扩展功能只需要与内核实现交互。例如,通过硬件访问接口就可以把操作系统完整移植到不同的硬件平台。但是,微内核的前提是存在一个稳定的内核设计,这通常并不容易,因此会引入设计复杂度和试错成本。另一个挑战是针对性能可持续优化的能力,因为性能优化和抽象设计往往并不相互正交。

反射模式把系统划分为两层:元层(Meta level)和基础层(Base level),其中元层用于定义软件自身的结构和行为,元层中的数据被封装在元对象(Metaobjects)中,例如类型定义、算法或者函数调用机制等信息。基础层用于定义应用的基本逻辑,其中的组件通过访问元对象实现具体功能。这样,元对象本身就成为系统对外的扩展点,从实现动态可扩展性。为了对外提供接口,反射模式定义了元对象协议(Metaobject Protocol, MOP),允许客户端编辑元对象,从而影响基础层中的实际功能。反射模式旨在避免修改系统源代码的前提下实现系统功能扩展乃至变更,当然对降低软件的维护成本有积极意义,但是,这种灵活性是以牺牲可靠性为代价的,因为要验证元对象修改的正确性是十分困难的;同时,反射模式会显著增加系统的设计与开发成本;由于元层和基础层的相互交替也限制了系统性能及可持续优化能力。

面向可用性的架构模式

可用性是指系统对外持续提供服务的能力。系统会由于软硬件发生错误、维护等事件被迫停机而导致服务中断,一次关键服务中断可能会造成巨大损失。解决可用性问题的核心思想是引入冗余资源,并能够在错误发生时及时启用备用资源继续提供服务。但是,系统中的许多组件在运行时需要进行额外配置和管理,这些配置可能涵盖功能性和非功能性等方面,一旦配置更改需要进行重新构建组件等操作时就会迫使系统停机。一种解决办法是通过统一接口对外提供针对组件的状态控制和配置功能,包括组件的初始化、挂起、恢复、终止等,中央系统可以从组件仓库中请求获取组件,然后调用对应接口对组件进行动态配置,即组件配置器模式(Component Configurator Pattern)。

组件配置器模式的一个重要应用场景是热部署(Hot deployment),目的是避免大型系统在日常维护时发生中断服务的情况,同时还有助于提高系统的灵活性和可维护性。但是,该模式会导致开发、运维应用以及基础设施的复杂度显著提升,并使系统的安全性与可观测性等方面面临更大挑战。

面向安全性的架构模式

安全性是指系统免于被破坏的能力。从架构设计的角度看,一个重要的安全原则是最小权限原则(Least Privilege Principle),即任何个体只拥有访问它需要功能的权限。对此最常见的实践策略是身份和访问控制(Identity & Access Control),即当访问者向某个功能组件请求交互时,通过一个独立的安全组件对其进行验证和授权,后者可以选择允许或拒绝该次请求。由于每个功能组件的安全策略可能不同,因此需要功能组件提供一个可供外部拦截对其请求的接口,再由安全组件针对该接口实现验证或授权,即实现拦截器模式(Interceptor Pattern)。

拦截器模式的贡献在于隔离了功能性需求和非功能性需求例如安全性场景,此外还常被用于负载均衡、容错处理等其它场景中。但是,拦截器模式有时会因“过度灵活”而导致滥用,从而损害可维护性:例如一个功能组件被注册了多个拦截器,这些拦截器的影响并不正交,再结合执行序列问题,导致拦截器组件之间发生了逻辑耦合,额外增加了架构复杂度。

结论

本文讨论了几种经典架构模式[POSA96],包括基础架构模式,以及面向特定质量属性的架构模式。但这只是架构模式领域的冰山一角,直至今日依然不断有新的架构模式被提出和讨论。架构模式也从最初的普适性,到跟随应用场景的变化不断细分。尽管经过快速发展,始终不变的是任何模式都会导致后果——即对质量属性的影响,亦即架构模式的动机与陷阱。

引用

POSA96, Pattern-Oriented Software Architecture

Comments

软件设计与架构笔记(17): 架构风格演变——从Client/Server出发

我们可能听说过多种架构风格,不同架构风格之间往往不是非此即彼的关系,它们或可以被组合使用,或包含相同的成分,也可能存在各自的侧重方向。更重要的是,一组架构风格之间有时存在继承和发展关系,即本文要讨论的演变。人们通常更重视架构风格的具体内容,缺少理解架构风格的动机,极少了解背后隐藏的陷阱,而它们的重要性是依次上升的,这也从根本上导致了架构风格演变的产生。下文就以C/S架构风格的演变为例进一步讨论。

起源——Client/Server(C/S)

计算机网络的发明令计算资源的跨空间共享成为可能。受到这种能力所蕴含的经济价值驱动,人们在网络诞生初期就发明了集中式的计算设施(如Mainframe)以及更多相对轻量的工作站(如Workstation),分散的工作站通过远程作业输入(Remote Job Entry)实现向Mainframe发送数据处理作业并由后者进行处理。这就是如今早已枝繁叶茂的C/S架构风格雏形。

虽然在诞生初期即取得巨大成功,但C/S也经历了曲折的发展时期。一个极端是终端逐渐控制台化,仅提供基本输入输出能力,如数据库管理系统(DBMS)。另一个极端是,随着个人计算机(PC)问世,各种新领域需求激增,在一段时期内受网络、硬件条件限制,人们不再满足于轻量化终端,开始推崇更强大的通用型计算机,富客户端(Rich client)概念由此产生(单机软件需求在这一时期也达到了巅峰)。而如今,互联网、云计算、移动/IOT等领域的应用无一不建立在C/S(B/S)的基础上,多以服务的形式提供给用户或其它服务。另一方面,如今很少人会专门谈论C/S,因为随着应用场景的丰富以及待解决问题的深入,人们更多关注C/S的某些细节领域,从而衍生出细分的架构风格。然而,前述演变与C/S本身的动机和陷阱息息相关。

作为一种中心化的分布式架构,C/S相较于非分布式架构天然地具有更多复杂性,从今天的工程角度看绝不应把复杂性作为首选项。但我们知道,C/S最根本的出发点是提高软件(计算资源)的经济价值,这种价值判断一般更合理地来自业务评估(尽管有时可能也会受到IT管理制约),因此这种架构风格更多是被应用环境决定——这在如今互联网时代更加如此。

而C/S复杂性就在于,它把本就繁杂的架构质量属性的复杂度进一步提高了数个维度,且后果往往超出业务预料:

  • 可靠性,由本地环境到不稳定网络和共享服务端环境,可靠性保证的复杂度无疑显著增加。

  • 易用性,在相似开发成本和客户端侧技术等条件下,C/S的易用性往往较差。

  • 高效性,C/S的初衷是共享资源,从而提高利用率,但这并非是免费午餐。在满足相同服务等级的条件下,C/S要通过额外的动态资源调整以实现高效性。

  • 兼容性,表现基本一致。

  • 安全性,由于资源和服务共享的原因,C/S面临更复杂的安全问题。

  • 可维护性,相同系统复杂性条件下,C/S具有更高的设计成本,也因此具备可维护性优势。但C/S产品通常被寄希望于覆盖更多业务场景,导致复杂度一般更高,也因此更难以维护。

  • 可移植性,面向服务的C/S通常具有优势。但如果软件不是以服务的形式提供,而是所谓的On premise部署,其复杂性则会显著增加。

C/S逐步流行后,针对上述挑战,逐渐衍生出诸多细分的架构风格,对过去半个世纪乃至今天的软件架构产生了深远影响。

发展——远程过程调用(RPC)

上文提到由于C/S的分布式特征,使其面临极高的复杂度,这里原因之一是网络通信的不可靠性。因此人们首先想到抽象出一个独立的通信层,该层负责向下管理进程间通信要解决的问题,向上为应用层提供面向通信领域的语言级接口,即本节要讨论的RPC。

从工程实践的角度看,RPC拥有曲折的发展历程,此类汇总文献很多,本文不再赘述。如今随着RPC成为分布式架构的基础元素之一,不同平台、语言、甚至科技公司都专门开发了方便落地的RPC框架和工具。这里把RPC视为一种架构风格,因为除了眼花缭乱的RPC实现,绝大多数RPC都集中在解决如下几个问题,且具有相似的针对性约束和模式:

消息表示

虽然定义为语言级,但为了保证兼容性,RPC需要采用中立的通信数据流,即不依赖任何具体的系统或语言。例如常见且兼容性好的JSON、XML、YAML,或开放但实际专属的Protobuff、Thrift、Java Object Serialization Stream Protocol等,这就需要RPC框架各自实现语言的内存模型和数据流之间的相互模式——序列化和反序列化。兼容性和高效性是这里最关注的质量属性。

消息传递

消息传递往往是RPC中功能最复杂的部分,它主要提供对网络传输的抽象,因此包括但不限于通信模型、地址、协议栈、异常/超时处理、安全、多线程、缓存等众多领域。如果一个RPC框架试图解决前述所有问题,就不得不需要借助更高级别的抽象,难以想象其复杂性和易用性。因此,如今轻量化成为RPC领域的主流。

接口表示

理想情况下,程序中的方法应可直接作为RPC的接口使用,这也是定位于语言级的初衷。但实际上如今流行RPC框架都拥有跨语言的特性,一种更流行的模式就是构建一个中立的接口层,使用语言无关的技术定义接口信息,再在语言层面映射到具体方法。例如gRPC中采用Protocol Buffers定义服务:

1
2
3
4
5
6
7
8
9
10
11
service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

上述服务定义可以被直接编译成目标语言的客户端和服务端代码,从而作为接口被引用。

如今选择RPC更多是出于高效性或定制化的目的,即强烈依赖某些RPC工具的特性或特定场景。而一旦进入互联网的开放世界,这种依赖就成为普适性的阻碍,人们就会倾向于更加中立的架构风格。

成熟——REST

在互联网时代,应用面临着高效性、可维护性、简洁性和可靠性等方面更严峻的挑战。REST就是为了解决上述挑战而被设计和提出的,其本身由一套支持HTTP 1.1和URI标准协议的核心设计原则组成,后者已成为互联网标准之一。

REST的全称是表述性状态转移(REpresentational State Transfer),它把互联网抽象成一个由Web资源组成的网络,用户通过资源标识符和相关操作符向应用发送请求,以获取目标资源或更改目标资源的状态。REST包含如下设计约束:

  • C/S架构,即组件间的交互方式是以客户端向服务端的资源URI请求,由此获得响应。

  • 无状态性,即服务端不直接保持与客户端的会话信息,如有需要应由客户端在发起会话时携带相关信息。无状态使服务端避免维持与多个客户端之间会话信息,从而保证高效性。

  • 可缓存性,即Web资源应具有描述可缓存性的能力,并且客户端和其它中间设施应能够根据这些描述选择暂存相关资源,从而减轻服务端负载。

  • 分层系统,通过划分出具有不同职责的中间层,实现负载均衡、缓存、认证和授权等具体特性。

  • 统一接口,即组件间接口应遵循以下原则:

    • 采用资源标识符识别目标资源,资源表示支持多种格式,例如HTML、XML、JSON。

    • 通过资源表示实现资源操作,特别是针对资源的增加、修改、删除操作,要求客户端首先应保持目标资源的表示。

    • 自描述性消息,即消息本身应携带充分的描述,例如消息格式等信息,从而允许消费方能正确解析该消息。

    • HATEOAS(Hypermedia as the engine of application state),即客户端只需要保留访问应用的初始URI,其他资源的URI应当由每次请求返回的资源本身提供。

  • 可编程客户端(可选),通过编程的方式向客户端提供可供执行的程序,从而提高易用性。

REST侧重于解决更广泛存在的问题,其绝对的中立性是一大优势。但另一方面,除了总结出互联网应用架构设计的基本约束,REST缺少具体的设计和实践指南。在一段时期内,人们忽视了HTTP协议的开放价值,反而推崇大厂商通过合纵连横试图达到垄断的标准。

繁盛——面向服务架构(SOA)

与RPC、REST关注高效性、简洁性等基础架构属性相比,SOA是在分布式场景下面向业务模型的更高层抽象。这里的服务对应业务活动单元,并且多个服务可以组合成为更复杂的业务活动单元,这是一种旨在适应业务模型的架构风格,也一度被认为是未来互联网的标准。

从技术角度看,SOA把组件划分成三个基本角色:服务提供、服务消费、服务注册,服务提供和消费方通过服务注册进行识别,从而实现相互通信,以此为基础构建分布式架构。由少数厂商联盟发起,在SOA发展伊始就陆续推出了一套基于Web的服务架构协议,即风靡一时的Web服务(Web services)。这些基于XML的协议族包括了:

  • SOAP(Simple Object Access Protocol),规定消息表示,具体包含消息属性和内容。下列代码片段是Google搜索引擎的SOAP消息封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <gs:doGoogleSearch xmlns:gs="urn:GoogleSearch"> 
      <key>00000000000000000000000000000000</key>
      <q>REST book</q>
      <start>0</start>
      <maxResults>10</maxResults>
      <filter>true</filter>
      <restrict/>
      <safeSearch>false</safeSearch>
      <lr/>
      <ie>latin1</ie>
      <oe>latin1</oe>
    </gs:doGoogleSearch>
  </soap:Body>
</soap:Envelope> 
  • WSDL(Web Services Description Language),规定接口表示。具体包括抽象类型定义(types)、参数具体类型(message)、接口和SOAP绑定(binding)和服务描述(service)。下列代码片段把ping命令的功能封装为Web服务,并用WSDL进行描述:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:s="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="uri:weblogscom" targetNamespace="uri:weblogscom">
  <types>
    <s:schema targetNamespace="uri:weblogscom">
      <s:complexType name="pingResult">
        <s:sequence>
          <s:element minOccurs="1" maxOccurs="1" name="flerror" type="s:boolean" />
          <s:element minOccurs="1" maxOccurs="1" name="message" type="s:string" />
        </s:sequence>
      </s:complexType>
    </s:schema>
  </types>
  <message name="pingRequest">
    <part name="weblogname" type="s:string" />
    <part name="weblogurl" type="s:string" />
  </message>
  <message name="pingResponse">
    <part name="result" type="tns:pingResult" />
  </message>
  <portType name="pingPort">
    <operation name="ping">
      <input message="tns:pingRequest" />
      <output message="tns:pingResponse" />
    </operation>
  </portType>
  <binding name="pingSoap" type="tns:pingPort">
    <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http" />
    <operation name="ping">
      <soap:operation soapAction="/weblogUpdates" style="rpc" />
      <input>
        <soap:body use="encoded" namespace="uri:weblogscom" encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" />
      </input>
      <output>
        <soap:body use="encoded" namespace="uri:weblogscom" encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" />
      </output>
    </operation>
  </binding>
  <service name="weblogscom">
    <document>For a complete description of this service, go to the following
URL: http://www.soapware.org/weblogsCom</document>
    <port name="pingPort" binding="tns:pingSoap">
      <soap:address location="http://rpc.weblogs.com:80/" />
    </port>
  </service>
</definitions>
  • UDDI(Universal Description, Discovery, and Integration),规定服务注册和发布等。下列代码片段用于把前面提到的ping服务发布在UDDI目录中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<businessEntity businessKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 
                operator="www.weblogs.com/services/uddi" 
                authorizedName="xxxxxxxxxx">
  <discoveryURLs>
    <discoveryURL useType="businessEntity">http://www.weblogs.com/services/uddi/uddiget?businessKey=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</discoveryURL>
  </discoveryURLs>
  <name>Services</name>
  <description xml:lang="en">Web services resource site</description>
  <contacts>
    <contact useType="Founder">
      <personName>XX XX</personName>
      <phone useType="Founder" />
      <email useType="Founder">xx@xx.xx</email>
    </contact>
  </contacts>
  <businessServices>
    <businessService serviceKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 
                     businessKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
      <name>Ping</name>
      <description xml:lang="en">This is a ping service</description>
      <bindingTemplates>
        <bindingTemplate bindingKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 
                         serviceKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
          <description xml:lang="en">SOAP binding for ping service</description>
          <accessPoint URLType="http">http://rpc.weblogs.com:80/</accessPoint>
          <tModelInstanceDetails>
            <tModelInstanceInfo tModelKey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
          </tModelInstanceDetails>
        </bindingTemplate>
      </bindingTemplates>
    </businessService>
  </businessServices>
</businessEntity>

上述XML协议族看似提供了语言和平台无关的特性,但过度繁冗引发高效性的担忧。同时Web服务意图达到事无巨细,即便在今天也堪称过度设计,给当时软件开发造成了额外负担,与方兴未艾的敏捷运动背道而驰,却很少有人看到Web服务的真正价值。此外,Web服务的某些重要组件,例如其负责服务注册和通信的核心模式ESB(Enterprise Service Bus)在实践中引发严重的可维护性问题,企业往往投入巨资采购ESB却使得软件修改变得更加困难——成为过度设计的后遗症。因而尽管得到了大厂商鼓吹和投资,SOA包括Web服务终成昙花一现,逐渐被弃用。

必须承认,SOA同样也具有普适性的内核,许多SOA时代的设计成果如今已经成为微服务最佳实践的一部分,区别是它们不再成为少数大厂商及其联盟的专利。

回归——RESTful

当人们面对Web服务浩如烟海的XML协议群焦头烂额之际,目光重新聚焦在轻量化设计,伴随敏捷开发运动兴起,Web服务的最初形式逐渐被弃用,取而代之的是RESTful和相关开源社区崛起。

RESTful提倡充分利用HTTP 1.1和URI等既有协议,思考包括资源表示、状态表示、语义化API、资源定位等核心约束。同时得以兼容开源社区对HTTP生态的高效性、安全性扩展,最终形成了互联网时代的标准参考架构。

本节之所以称为回归RESTful,是因为从RPC到Web服务,架构设计一直试图以不断分层的方式重新定义问题和寻找新的解决方案,以至于忽视了很多当时已经成熟的方案。例如一些Web服务采用RPC over HTTP方案,即把HTTP仅作为消息传递工具,再在其上重新设计一套复杂的RPC框架——相当于坐在汽车上重新造轮子。当然,对于组织内部应用来说这也许只是一种潜在的过度设计,实施起来并没有太大麻烦,但这种笨重性根本无法适用于开放的互联网时代。RESTful相比于Web服务的过度设计,后者存在许多冗余特性,且在既有HTTP协议中存在对等替代,例如:

  • SOAP协议用XML重新封装了消息属性和内容,然后再用RPC或HTTP进行传递。但HTTP协议自带的头属性(Header)、内容(Body)、状态(Status)和方法(Methods)天然就拥有这种职责划分,且具备更好的性能。

  • WSDL协议用于描述服务接口、参数静态类型和消息格式,但在实际应用中很少有人会手写这些繁冗的信息,而是通过基于Java或C#等语言的工具自动生成WSDL,然后提供给需要服务的客户端应用,其目的是客户端可以通过方法调用的形式实现Web服务调用(与RPC类似)。这种接口层在某些封闭场景下可能是合理的,但在当前互联网时代只能是掣肘大于收益,缺少普适性。实际上,RESTful社区也曾经提出过一种同样基于XML但较轻量的WADL(Web application description language)作为替代协议,却永久停留在了文本阶段,甚至从未落地。

  • UDDI协议用于服务注册和发布,这是一种比WSDL更复杂的、希望把服务与业务需求关联起来并对外发布的目录协议,客户端可以使用UDDI查询并浏览可用的服务。遗憾的是,与其复杂的协议内容相比,UDDI这种超前设计实际缺少应用场景。今天的RESTful也只是实现了以开放API规范(OpenAPI Specification)等发布API功能和描述的最佳实践。

RESTful在Web服务中的回归使人的注意力重新回到对设计本质的思考,最终形成具有普适性的Web API设计指南——RESTful API,并伴随着开源Web框架Ruby on Rails、Django推广并流行至今,产生了巨大影响力,在如今开源Web框架领域,已经很难看到不支持RESTful API了。

展望——后RESTful时代

RESTful已成为互联网时代最具标志性的架构风格,尽管由于各种原因,许多应用实际上并未严格遵循RESTful,也会在细分场景中寻找替代方案。例如,RESTful(或RPC)中的每一个Endpoint都要提供明确的接口描述,包括URI、请求内容、响应内容等。由于接口的特殊性,实践中因为需求变更导致接口变更是一个繁琐且容易引起BUG的过程。当在数据读取的场景中所需数据可能发生频繁变更时,GraphQL就成为RESTful之外的另一个选项。

GraphQL旨在提供一种长期稳定且一致的API接口,从而避免频繁接口变更,其实现思路与RPC over HTTP类似,即通过向单一Endpoint发送一个数据查询对象,再获得所请求的数据,因此这种API是可以根据客户端需求动态返回恰好所需数据的。与RESTful相比,GraphQL无法充分利用HTTP的固有特性,特别是URI、缓存、数据操作等,因此不得不引入额外依赖并导致复杂性,在实践中也面临较多限定条件,例如查询语言、实现框架等。因此,GraphQL通常是作为RESTful的补充,而非绝对替代。RESTful的普适性还体现在云计算、移动/IOT领域的广泛应用场景。在服务化思想盛行的今天,RESTful的中立和兼容性使其仍然是互联网应用的默认选择。

结论

本文讨论了架构风格演变,特别是从计算机网络诞生到互联网时代,架构风格从C/S一路发展到RESTful的整个过程,以及各个里程碑背后的动机和陷阱。对于架构质量属性,单独了解和掌握并不困难,但工程中往往需要综合考虑多个质量属性,这就要求针对相关设计约束进行系统管理,架构风格就是这个过程里每个里程碑输出的产品。

Comments

软件设计与架构笔记(16): 架构风格——反应式架构

架构风格——反应式架构

在过去10年,多核、云计算、移动/IOT、用户体验等相关领域的发展使传统上以可维护性为核心的软件架构面临着新的挑战,这主要体现在软件系统的即时响应性(Responsiveness)、回弹性(Resilience)以及弹性(Elasticity)等架构质量属性[JDRM14]。

即时响应性旨在合理成本范围内提供低延迟的用户体验。显然这并非是全新的质量属性,传统上围绕它的解决方案包括算法优化、摩尔定律等。然而,随着系统复杂性不断上升,即时响应性不可避免地受到损害,业界发明了许多技术解决这一问题。从架构角度看,这些技术可以被划分为纵向(Scale up/down)和横向(Scale out/in)两种基本的扩展方案。其中,前者以多核技术为代表,后者则依赖分布式技术,两者在架构方面互为补充。由此衍生的细分领域包括但不限于并发编程、分布式通信、数据一致性、节点协调、错误处理、职责分离等,这些技术在实践中形成了一系列设计原则和模式。反应式架构(Reactive architecture)就是以这些原则和模式为基础、进而发展为一种面向现代高即时响应性的软件系统的架构风格。

多核与反应式编程

多核技术提供了纵向的单机扩展能力,但要利用这种底层能力离不开上层并发编程的支持。经典的并发编程框架如Java Concurrency[BG99],提供了最接近底层且功能强大的并发编程API,例如下列一个爬虫的代码片段(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Crawler {
   private ConcurrentHashMap<String, Boolean> seen = new ConcurrentHashMap<String, Boolean>();
   private AtomicInteger pending = new AtomicInteger(0);
   
   public Crawler(String baseUrl, int numOfThreads) {
       this.client = HttpClientBuilder.create().build();
       this.baseUrl = baseUrl;
       this.executorService = Executors.newFixedThreadPool(numOfThreads, new ThreadFactory() {
          public Thread newThread(Runnable r) {
              return new Thread(r, "Crawler-Worker");
           }
       });
   }
   
   public void start() {
       handle(baseUrl);
   }
   
   private void handle(final String link) {        
      if (seen.containsKey(link))
          return;
      seen.put(link, true);
      pending.incrementAndGet();
      executorService.execute(new Runnable() {
          public void run() {
              List<String> links = getLinksFromUrl(link);
              for (String link : links) {
                  handle(link);
              }
              pending.decrementAndGet();
              if (pending.get() == 0) {
                  synchronized (lock) {
                      lock.notify();
                  }
              }
          }
      });
   }
}

客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main{
  public static void main(String[] args) throws InterruptedException {
          if (args.length != 2) {
              System.err.println("Invalid syntax: <baseUrl> <numOfThreads>");
              System.exit(1);
          }
          String baseUrl = args[0];
          int numOfThreads = Integer.parseInt(args[1]);
          Crawler crawler = new Crawler(baseUrl, numOfThreads);
          crawler.start();
          crawler.join();
          crawler.shutdown();
  }
}

该例中的爬虫实现基于Java Concurrency的ExecutorService API,一种共享状态并发式编程模型。为了保证线程安全,代码中采用了ConcurrentHashMap、AtomicInteger、Lock和Synchronized等Java特有的并发编程技术,存在复杂度较高、易理解性差、共享状态维护难度高、易出错等缺点。

反应式编程(Reactive programming)是一种具有异步编程风格的编程框架,其衍生自基于数据流的并发声明式编程模型,采用事件驱动和非阻塞线程技术,从而降低因为资源等待导致的并发性能瓶颈。首先来看一个基于RxJava[RXJ14]的爬虫代码片段(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ObservableCrawler {
  private Subscriber<? super String> subscriber;
  
  public static Observable<String> create(Crawler crawler, String url, int numOfThreads) {
      ObservableCrawler o = new ObservableCrawler(crawler, numOfThreads);
      return Observable.create(subscriber -> {
          o.subscriber = subscriber;
          if (o.executorService == null) {
              o.process(url);
              subscriber.onCompleted();
          } else {
              o.processAsync(url);
          }
      });
  }
  private ObservableCrawler(Crawler crawler, int numOfThreads) {
      this.crawler = crawler;
      this.executorService = numOfThreads > 0 ?
              Executors.newFixedThreadPool(numOfThreads, r -> new Thread(r, "Crawler-" + threadIdGenerator.incrementAndGet()))
              : null;
  }
  
  private void processAsync(String url) {
         pendingTasks.incrementAndGet();
         executorService.submit(() -> {
             // If item is not unique, skip processing
             boolean isFirstTime =  results.add(url);
             if (isFirstTime) {
                 subscriber.onNext(url);
                 crawler.crawl(url, this::processAsync);
             }
             if (pendingTasks.decrementAndGet() == 0) {
                 subscriber.onCompleted();
                 executorService.shutdown();
             }
         });
  }
}

其客户端代码如下:

1
2
3
4
5
6
7
8
9
10
public class CrawlerClient {
  public CrawlerClient(Crawler crawler, String url, int numOfThreads) {
      this.observable = ObservableCrawler.create(crawler, url, numOfThreads);
      observable.subscribe(this::onNext, this::onError, this::onCompleted);
  }

  public void waitForCompletion() throws InterruptedException {
      completionLatch.await();
  }
}

与前面直接基于Java Concurrency的例子类似,该例同样采用了ExecutorService实现爬虫的并发执行,其中waitForCompletion也提供了阻塞客户端的能力。两者的区别在于,RxJava的版本采用观察者模式对爬虫类进行了封装,客户端不再需要等待代码执行结束后再执行后续指令。其中的一部分技术细节,例如线程安全代码被封装在API中,再通过回调函数接口提供给客户端,后者只需要关心onNext、onError和onCompleted的线程安全实现,这在一定程度上降低了Java并发编程的复杂度。

除了上例介绍中朴素的回调函数API之外,反应式编程通常还可能提供多种异步编程风格API,包括但不限于:

  • Futures/Promises,一种单赋值容器,支持针对单写/多读场景的、自上而下的异步编程风格,能够有效解决回调地狱(Callback hell)的问题。

  • 流(Streams),一种无界限的数据处理流,支持多个源点、汇点间的异步、非阻塞、背压(Backpressure)的数据变换管道。例如函数组合(Functional composition)提供的map、filter、fold等流式操作。这里的背压是指当异步执行管道中的消费端计算能力不足,上游仍然持续生产事件,从而导致系统过载的问题。反应式编程API一般通过配置不同策略以防止潜在的系统灾难。

  • 数据流变量(Dataflow variables),一种单赋值变量,能够基于输入、过程和其它变量实现自动更新。

前述例子中采用的RxJava遵循了ReactiveX标准——一套反应式编程的技术规范[REX],类似的反应式编程扩展(Rx)支持已被添加至许多编程语言中,如RxJS、Rx.NET、RxScala等。此外,RxJava还支持JVM平台的反应式流规范(Reactive Streams Specification)[RSS],从而具备与其它反应式编程API之间的互操作性。与传统编程模型相比,反应式编程在多核利用率、并发编程、系统模块性、工作流组装方面相较于传统编程模型具有优势。但其同样可能会损害代码的易理解性,并且通常只局限于单机计算,虽然有助于加强即时响应性,但并不能满足反应式架构要求的另外两个核心质量属性:

  • 回弹性,指系统在出错时能够自动恢复并保持正常的即时响应性目标。

  • 弹性,是指系统在异常工作负载下,能够自适应调整自身容量从而维持其正常的即时响应性目标。

这正是分布式系统成为云计算、移动/IOT领域核心技术的重要原因。

分布式与异步消息传递

分布式技术的目标是为系统提供横向扩展能力。通过负载监控和自动化伸缩技术,实现系统容量的自适应调节,从而满足弹性需求。另外,通过隔离组件控制系统错误/灾难的级联传递,再将错误提交至安全上下文中即时处理,从而满足回弹性需求。

反应式架构使用异步消息传递作为组件间的通信模式,通过在组件间建立临时边界,实现组件间的松耦合、隔离与地址透明化,达到时间、空间二重解耦的目的。值得注意的是,这里的空间解耦既可以指单机中的线程/进程,也可以指分布式系统的组件。“消息传递”意味着组件通常是长期存活且可被调用者直接定位、实现定向通信的方式,这与另一种流行的分布式系统通信模式——“事件传递”有着重要区别。因为“事件传递”是通过事件源的状态变化引发相应事件,再通过观察者模式通知“订阅”的组件,因此其关注点在于可定位事件源。而在“消息传递”中则相反,调用者需要明确知道被调用者的位置信息,即更加关注可定位接收者——这是反应式架构实现回弹性和弹性的重要基础。因为后者具有更强的控制力:负载管理、错误检测、消息丢弃/复制/排序、通过监控和调整消息队列实现流量控制以及背压等。

以应对回弹性为例,为了使系统在出错时仍然保持正常的即时响应性,需要实现调用者和被调用者的完全隔离,使后者发生的错误不被传递至前者,同时应支持消息被传递到多个复制组件中,即便错误发生时系统仍能正常提供服务。虽然容错性也是受到普遍重视的质量属性之一,但是传统上强耦合、深度嵌套的同步调用链代码缺少一致的容错方案。反应式架构明确要求把错误信息封装在消息中传递至其它组件,并使其在出错组件外部的一个安全上下文中得到有效处理(代理模式)。这里体现的基本思想是把错误处理从原有调用链中解耦,即移除客户端中针对服务端错误进行处理的职责。

再以弹性计算为例,系统被要求能够根据实际负载需求自动增加或减少所占用资源,从而动态调整吞吐量。这种自适应性意味着无介入实现系统伸缩、状态/行为冗余、负载均衡、失效备援和系统升级等能力。其基本思想是在编程抽象和语义层面实现组件空间位置的透明化,使系统更易于伸缩,且这种可伸缩性无需局限于CPU核甚至数据中心。

消息传递并发式模型及其应用

消息传递并发式模型是一种通过异步通信信道实现组件间通信的编程模型,目前被广泛应用于实现反应式架构中的“异步消息传递”模式,例如经典的Actor模型[CPR73]。

Actor是一种相比基于线程的并发编程更高级的抽象模型,其旨在解决如下问题:

  • 伸缩性,指包括单机和分布式环境下的系统扩容能力,即隐藏系统横向、纵向扩展的底层技术差异。

  • 透明性,指同时适应单机和分布式环境下的定位能力,例如在单机环境下采用并发编程语言,在分布式环境下采用网络通信,这些完全不同的资源定位方式导致系统难以从单机向分布式演化。

  • 不一致性,这里的不一致性是指在许多超大型系统中,面向人的信息系统交互存在不一致的问题,例如文档、标准等。

在Actor模型中,最基本的并发计算元素被称作actor。actor之间可以发送和接收消息,并且各自维护一个内部状态。当actor接收到消息时,可以并行执行下列响应方法:

  • 创建有限数量的新actor。

  • 发送有限数量的消息给其它actor。

  • 定义下一次接收消息时触发的行为。

Actor模型无论在计算理论还是在实际应用中都产生了重要影响,特别是可以被用于描述一些流行的并发编程框架,例如下面要介绍的Erlang/OTP Processes[EOP]和Akka Actors[AA]。

Erlang/OTP Processes

Erlang是一种声明式编程语言,除了基本的语法规则外,Erlang在其内核语言的基础上还提供了一套专有运行时系统和库——OTP(Open Telecom Platform),后者是Erlang实现分布式、软实时、高容错、高可用、热部署的基础[OTP]。其中,进程(Processes,注意这里不是操作系统进程)是Erlang并发编程的基本计算元素,类似actor。如下列代码所示(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-module(counter).
-export([run/0, counter/1]).

run() ->
    S = spawn(counter, counter, [0]),
    send_msgs(S, 100000),
    S.

counter(Sum) ->
    receive
        value -> io:fwrite("Value is ~w~n", [Sum]);
        {inc, Amount} -> counter(Sum+Amount)
    end.

send_msgs(_, 0) -> true;
send_msgs(S, Count) ->
    S ! {inc, 1},
    send_msgs(S, Count-1).

% Usage:
%    1> c(counter).
%    2> S = counter:run().
%       ... Wait a bit until all children have run ...
%    3> S ! value.
%    Value is 100000

上例中实现了一个并发计数器counter,并向外部提供两个函数run和counter。其中run函数的作用是创建一个新的counter进程,然后向其发送倒计时时间消息。counter定义了消息接收行为,包括打印和增数。send_msgs通过!向S进程发送消息,然后通过递归实现倒数。与actor概念类似,Erlang的进程相互之间完全隔离,并通过消息传递相互通信。由于进程的创建和销毁十分轻量化,从而允许系统中容纳数量非常可观的进程(在普通PC中即可实现千万级进程数),这些进程可以在运行时系统中存在很长时间,如果没有消息接收或者运行了太长时间,进程就会被重新放入调度队列,避免影响其它正常运行进程。

Akka Actors

Akka是一个基于JVM的并发编程框架,其中的核心模块Akka Actors的Scala语法部分借鉴自Erlang,例如:

1
2
3
4
5
6
7
8
9
10
object HelloWorld {
  final case class Greet(whom: String, replyTo: ActorRef[Greeted])
  final case class Greeted(whom: String, from: ActorRef[Greet])

  def apply(): Behavior[Greet] = Behaviors.receive { (context, message) =>
    context.log.info("Hello {}!", message.whom)
    message.replyTo ! Greeted(message.whom, context.self)
    Behaviors.same
  }
}

与此相比Java版本就显得较为繁琐一些,但也很容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class HelloWorld extends AbstractBehavior<HelloWorld.Greet> {

  public static final class Greet {
    public final String whom;
    public final ActorRef<Greeted> replyTo;

    public Greet(String whom, ActorRef<Greeted> replyTo) {
      this.whom = whom;
      this.replyTo = replyTo;
    }
  }

  public static final class Greeted {
    public final String whom;
    public final ActorRef<Greet> from;

    public Greeted(String whom, ActorRef<Greet> from) {
      this.whom = whom;
      this.from = from;
    }
  }

  public static Behavior<Greet> create() {
    return Behaviors.setup(HelloWorld::new);
  }

  private HelloWorld(ActorContext<Greet> context) {
    super(context);
  }

  @Override
  public Receive<Greet> createReceive() {
    return newReceiveBuilder().onMessage(Greet.class, this::onGreet).build();
  }

  private Behavior<Greet> onGreet(Greet command) {
    getContext().getLog().info("Hello {}!", command.whom);
    command.replyTo.tell(new Greeted(command.whom, getContext().getSelf()));
    return this;
  }
}

从编程模型的角度看,Akka Actors与Erlang Processes本质上是一致的,然而其底层系统存在巨大差别——更多是JVM与OTP的差别。两者如今也都形成了各自庞大的生态系统,成为设计反应式架构的重要参考,并且在通信、数字金融、在线游戏、在线交易、统计、社交媒体、移动应用等领域得到了广泛应用。

结论

本文介绍了一种流行的架构风格——反应式架构,详细讨论了即时响应性、回弹性和弹性等质量属性以及异步消息传递模式。在具体实践层面,首先讨论了事件驱动模式的反应式编程及其在多核环境中的应用,进一步介绍了消息传递模式的Actor模型及相关的流行编程框架——Erlang Processes和Akka Actors。由此可见,参考架构风格的关键在于理解其要解决的核心问题,即要满足的特定的功能或非功能需求是否符合期望。一旦确定架构风格,其特定的设计原则就应尽量被作为软件开发的通用设计原则。而针对架构风格中包含的多种模式、框架和系统,就需要依据具体上下文灵活做出选择。

引用

JDRM14, https://www.reactivemanifesto.org/

BG99, Java Concurrency in Practice

RXJ14, https://github.com/ReactiveX/RxJava

REX, http://reactivex.io/

RSS, http://www.reactive-streams.org/

CPR73, A universal modular ACTOR formalism for artificial intelligence

EOP, https://erlang.org/doc/reference_manual/processes.html

AA, https://doc.akka.io/docs/akka/current/typed/index.html

OTP, https://erlang.org/doc/

Comments

软件设计与架构笔记(15)

软件架构说什么?

架构(Architecture)一词源自建筑领域,尽管看起来与软件设计毫无关联,但人们从上世纪60年代起就注意到两者的相似性[PHJ06],并从结构和工程等方面大量借鉴了这个古老的学科,软件设计也因此获益匪浅[GHJV95]。当然不仅是软件,这个词也被其它领域广泛借鉴,例如作为计算机基础的体系结构(Computer architecture),后者主要指计算机的物理结构或者CPU指令集。又比如企业架构(Enterprise architecture)、解决方案架构(Solution architecture)或者信息架构(Information architecture)等,则是表示针对各自问题域的专业性实践集合。

虽然架构与设计同属于本系列的主题之一,但迄今为止我们都很少提及。一方面是因为,作为诞生于上世纪90年代的buzz word,软件架构是在软件设计的基础上发展而来的,前者继承了后者的许多核心思想,例如模块化、原则、模式等,逐渐形成了更加庞大的体系。另一方面,架构一词如今具有极其丰富的含义,以至于可能达到阻碍交流的地步,因此确有必要首先对部分概念予以澄清。此外,除非特别说明,本系列文章中的架构均指软件架构。

定义和解释

架构是指一个系统在其所在环境中的基本概念和属性,这体现为系统的元素、关系及其设计和演进的原则。

虽然这是ISO/IEC 42010对架构的正式定义,另一种USP(Unique selling proposition)定义则更详细地解释了这一点:

架构是一系列重要的决策,涉及描述软件系统的组织、确定结构化元素及其接口、确定元素在协作中的特定行为、指导结构和行为元素通过组合逐渐形成较大子系统的风格(涉及元素、接口、协作和组织等)。此外还要考虑用途、功能、性能、适应力、可重用性、可理解性、经济性、技术限制及其权衡、美学等因素。

以上定义明确指出了架构的表示(Representation)、质量属性(Quality attribute)以及风格(Style)等核心内容。此外,针对已有的架构方案,有时需要进行专门的架构评估(Architecture evaluation),从而提前发现问题并控制潜在风险。本文剩余部分将作进一步讨论。

架构表示

由于架构本身的丰富性,采用适当方法描述架构就变得非常重要,一种基本的架构描述工具是架构视图(Architecture view),其被用于表示架构在解决特定问题时所体现的结构化信息。由于完整的架构一般会涉及众多干系人,在单一视图中无法清楚表示所有信息,因此为了进一步在视图中区分来自不同干系人的诉求,采用架构视点(Architecture viewpoint)聚焦于某一类架构决策,并采用特定的标记和建模技术建立对应的架构视图。常见的架构视点有功能、逻辑、数据、模块、组件-连接器、需求、实现、并发、性能、安全、部署、用户使用及反馈等,由此可见其对应的架构视图也就非常丰富。

以组件-连接器类型的架构视图为例,该架构视图定义了系统中的可计算组件及其交互方式,其中组件是指可独立运行、且支持交互或存储数据的软件单元,连接器则被用于描述组件之间的交互机制。在构建组件-连接器视图的过程中,组件可以根据承担功能、可重用性、硬件单元,甚至团队的技术背景、康威定律以及产品演化路径等方式定义。同时,组件还需要描述其对外提供交互的接口(API),包括访问端口、参数以及参数类型。然后根据组件间交互的需求,如同步、异步、延迟、吞吐量等确定连接器的类型和通信协议。连接器两端的组件分别被称为调用者和被调用者,组件与连接器之间通常需要相关配置以确定关联信息。

架构描述语言

通常,架构视图是采用架构描述语言(Architecture description languages,ADL)具体实现的,如AADL、Wright、ACME、xADL等专门面向软件架构的语言。同时也可以采用通用的建模语言,例如UML,实际上后者在工业界更加流行。架构视图和ADL共同组成了架构表示的基本方法,但仍不足以有效应对架构的复杂性。这是因为在真实场景中架构视点可能是非常多的,架构需要从核心视点出发逐步完善,因此需要进一步参考适当的架构框架(Architecture framework)。

架构框架

架构框架是指在特定应用领域或干系人社区中,创建、解释、分析和使用架构表示的通用实践集合。一种经典的架构框架是“4+1架构视图模型”[PK95],其基本思想是需要采用若干个相互平行、且具有不同架构视点的架构视图,具体来说就是逻辑视图进程视图开发视图物理视图等四种主要架构视图,以及相应的用例和场景说明,从而达到表示完整架构的目的。

  • 逻辑视图,即把系统按照功能、通信、行为等进行结构化分解的结果,描述系统的静态信息。具体可以采用UML中的类图或状态图实现。

  • 进程视图,即对系统中进程和线程的通信、执行过程进行描述,即系统的动态运行信息。具体可以采用UML中的部署图和活动图实现。

  • 开发视图,也称作实现视图,用于描述软件开发过程中的软件结构,例如组件、包、类、子系统、代码库、文件等。具体可采用UML的组件图或包图实现。

  • 物理视图,描述系统运行的硬件资源结构,及其与系统进程之间的映射关系。具体可采用UML中的部署图、时序图或协作图实现。

  • 用例和场景,也称作用例视图,即从少量核心用例出发,描述系统中对象间、进程间的交互顺序。该视图主要用于构建可验证的架构原型,从而对当前架构进行测试。

根据上述5种架构视图,4+1架构视图模型能够建立一个核心的软件架构表示。然而从架构对整个软件工程的影响角度来说,架构框架作为架构表示的核心,往往还需要更多架构视点的支持,这与具体上下文密切相关,特别是接下来要讨论的质量属性。

质量属性

除了满足功能需求,架构还需要考虑系统的非功能需求(二者相互正交),后者也被称作系统的质量属性,例如性能、可靠性、资源利用率、可用性、精确性等。与功能需求最显著的不同在于,质量属性往往是相对概念,一般表现为某种程度,且具备多种领域背景。正因为如此,质量属性大大提升了架构的复杂性,也是除了功能需求变化外另一个可能引起架构变化的重要原因。

ISO/IEC 25010对软件质量进行了明确定义,其中功能性表示系统功能的完整性、正确性、适当性与合规性,此外还包含7种非功能属性以及对应的子属性:

  • 可靠性,指系统在特定时间和条件下维持当前性能的能力,包括成熟度、容错性、可恢复下、可用性等指标。

  • 易用性,指个体或群体在使用系统时的难易程度。包括易理解性、易学习性、易操作性、界面美观性、操作错误保护以及可访问性。

  • 高效性,指系统在特定条件下,资源使用量与软件性能之间的关系。包括耗时、资源利用率、容量等指标。

  • 兼容性,指系统在特定的软、硬件环境中能够正常运行的能力。包括共存性、互操作性等指标。

  • 安全性,指系统保护数据和执行正当行为的能力。包括保密性、完整性、非拒绝性、可审计性以及可验证性。

  • 可维护性,指系统在需要做出特定修改时所花费的成本大小。包括可分析性、可改变性、稳定性、可测试性、模块性、可重用性以及可修改性。

  • 可移植性,指系统迁移到其它环境的能力。包括可适应性、可安装性、可替换性等。

值得一提的是,经验研究表明并非所有的非功能需求都有同等机会引发架构变化[JAD16],尽管它们可能拥有相同的重要性。但在进行架构相关决策时,依然不可避免地要考虑功能以及多种质量属性,这就导致从零开始设计架构具有极高的成本和风险。因此绝大多数架构设计活动实际上是遵循着经受实践检验的经验,即下面要讨论的架构风格

架构风格

架构风格是指一系列满足功能和特定质量属性的设计决策与约束子集[RNE09],其意义在于:

  • 提供可重用的领域和工程知识,特别是相同领域或产品族中与应用无关的设计规则和决策,避免重新发明轮子。

  • 阻止架构腐化和偏离,帮助未来开发人员在不损害基本架构原则的基础上扩展系统。

  • 根据质量需求指导设计。

以经典的管道-过滤器架构为例,在该架构风格中,所有的过滤器都通过两个字节流“输入”和“输出”进行通信,这样就保证了任何过滤器都能够互相连接——即满足兼容性。另外,过滤器之间可以一次只传递部分数据,这样就能够尽可能提高过滤器之间并行计算的能力,从而提高系统效率。除此之外,应用中常见的架构风格还包括但不限于:

  • 客户端-服务器(C/S)架构。

  • 分层(三层或N层)架构。

  • 点对点(Peer-to-peer)架构。

  • 事件驱动(Event-driven)架构,也称隐式调用架构。

  • 表述性状态转移(REST)架构。

  • 面向服务架构(SOA)。

  • 领域驱动设计(DDD)。

限于篇幅本文无法详细讨论每种架构风格。而事实上,在实际软件开发过程中架构风格往往是在最初就确定的,因此可被视为架构设计的设计规则。另外,大部分情况下整个系统会拥有多种架构风格,从而满足各种质量属性需求。

架构评估

由于架构的重要性,团队通常需要对已有的架构方案进行评估。架构权衡分析(Architecture tradeoff analysis method)是一种架构评估方法,采用该方法首先需要建立一个专门的架构评审小组,该小组应至少包含所有的干系人。启动评估后,首先应确保所有参与者熟悉评估流程以及业务背景。向所有评估者展示更高层次的系统架构,包括所采用的架构风格。然后通过质量属性树描述系统所要特别关注的质量属性,并且为每个所要满足的属性提供一个具体场景。一个质量属性树的例子如下图所示:

Quality attribute tree

把所有场景按照优先级进行排序,然后逐一分析当前架构对该场景的适用性,根据相关反馈进行调整。最后在更大范围的干系人组织中分享当前架构知识。

结论

软件架构包含三个核心问题,分别是架构表示、质量属性和架构风格。架构表示是架构得以沟通并完善的重要途径,在架构设计的过程中不仅要考虑功能需求,还要考虑非功能需求(质量属性),不同的架构风格在应对特定质量属性方面具有优势,因此真实场景中需要组合架构风格以满足来自不同干系人的需求。

引用

PHJ06, The Past, Present, and Future of Software Architecture

GHJV95, Design Patterns: Elements of Reusable Object-Oriented Software

PK95, Architectural Blueprints—The “4+1” View Model of Software Architecture

RNE09, Software Architecture: Foundations, Theory, and Practice

JAD16, Are “Non-functional” Requirements really Non-functional?

Comments

软件设计与架构笔记(14)

敏捷软件设计

早期的软件开发方法源自传统制造业和建筑业,即按照需求、分析、设计、开发、测试、运营等阶段顺序执行。这种线性的软件开发过程被称作瀑布模型(Waterfall)。瀑布模型在上世纪70年代逐渐发展成熟,成为软件开发方法的事实标准。随着互联网的出现,软件工业迈入飞速发展,频繁变更的需求和快速更替的技术使瀑布模型遭遇了空前挑战。于是,通过从先进制造业汲取经验,行业一线的职业程序员们开始调整原有方法,90年代先后诞生了统一过程(Unified process)、Scrum极限编程(Extreme programming)等轻量级软件开发方法。2001年,程序员们从这些方法的核心思想中提炼出了著名的敏捷宣言,由此敏捷成为前述一系列软件开发方法的代名词。时至今日,对于需求明确并且依赖成熟技术的软件开发活动来说,严谨且可靠的瀑布模型仍然占有一席之地。敏捷思想则在自互联网时代开启的一系列新兴领域中更受欢迎,也更具备发展空间。

敏捷对软件设计产生了重大影响,正如Martin Fowler所指出的,极限编程不仅宣告了Big Design Up Front的终结,还严重影响了一批热门的技术实践例如UML、框架构建、设计模式[MFL00]。然而软件设计并未因敏捷而消失,敏捷也不意味着无设计或设计灾难,为了与传统瀑布模型的计划设计(Planned design)进行区别,敏捷设计被描述为演进式设计(Evolutionary design)、持续设计(Continuous design)或浮现式设计(Emergent design),或许这些名词有时夹带了浓厚的宣传意味,但是不可否认分析与设计、原则与模式依然是敏捷软件设计的核心,后者的主要特点在于更加强调轻量化的敏捷设计实践。这往往意味着:

  • 强调价值交付,交付价值是推动整个软件工业发展的重要经济基础,因此价值应当始终是软件开发的优先选项。

  • 强调团队责任,而不是把职责局限于分析师、设计师、XX师等不同工种,从而减少单点失败(Single point failure)。

  • 强调快速反馈,无论是测试驱动开发还是持续集成,通过尽可能的自动化实现软件设计质量的实时监控,且应保证快速响应。

具备代表性的实践有面向设计一致性的代码味道重构、面向功能一致性的测试驱动开发(Test driven development)以及面向团队一致性的结对编程(Pair programming)、代码评审(Code review)和持续集成(Continuous integration)等。敏捷正是通过前述一系列实践,从而避免从BDUF走向另一个设计熵增的极端。本文剩余部分将进一步讨论价值交付、团队责任和快速反馈在敏捷软件设计活动中的具体体现。

价值交付:扩展—收缩模式(Expand-Contract Pattern)

应对变化是敏捷软件设计的永恒主题。当现有设计发生变化时,这种变化可能通过接口向模块外传递,从而影响更多其它模块。特别对于公共接口来说,变更现有设计会产生较高成本,进而影响交付的价值。[MFL14]讨论了一种扩展—收缩模式,其核心思想是在变更设计的同时保持向后兼容,当新设计产生的价值得到验证后再移除旧设计。例如下列代码:

1
2
3
4
5
6
class WindowFactory {

    public Window createWindow(String title, int x, int y, int width, int height) {
        ...
    }
}

该例中的抽象工厂类WindowFactory能够创建不同类型的Window,参数列表接受窗口名、位置和尺寸等信息,其中位置和尺寸能够使用Rect对象代替,从而有:

1
2
3
4
5
6
class WindowFactory {

    public Window createWindow(String title, Rect rect) {
        ...
    }
}

如果该接口属于公共接口,那么所有客户端组件都必须被动修改,否则将无法正常工作。更加合理的做法是首先保留原接口(扩展):

1
2
3
4
5
6
7
8
9
10
class WindowFactory {

    public Window createWindow(String title, int x, int y, int width, int height) {
        ...
    }

    public Window createWindow(String title, Rect rect) {
        ...
    }
}

当客户端组件迁移完成后,再移除失效的接口(收缩)。通过采用扩展—收缩模式,能够有效控制设计变更对交付价值的影响,这也是敏捷软件设计的核心目标之一。

同样的模式还被应用于演进式数据库设计[MFL16],特别是当数据模式发生破坏性修改时(修改表名、列名等操作),需要保证在迁移阶段同时支持新旧两种数据访问模式。例如当修改表名时,可以通过创建与旧表名相同的视图提供向后支持。当设计需要修改列名时,可以先创建新的列,然后通过触发器实现新旧两列的同步,直至迁移阶段结束再清理旧模式。在真实场景中应根据数据库类型、应用类型等相关上下文决定具体实现,但设计思想仍然遵循扩展—收缩模式。

团队责任:模型风暴(Model Storming)

模型风暴是一种即时建模活动,其目的是把设计责任赋予团队而非个人。理论上说模型风暴可以发生在敏捷软件开发过程的任何时间,但通常是由一名用户故事的所有者(Story owner)在进入开发阶段前发起。首先由所有者确保理解所要解决的问题,然后集合若干团队成员(通常是2~3人)进行站立会议(Stand session)。在所有者介绍完背景并确保所有人理解上下文后,团队开始在一个共享建模工具上探索设计方案,直至大家充分理解并达成一致,会议结束(通常是5~10分钟)。

模型风暴有两种应用场景——分析和设计。分析模型风暴主要是帮助团队理解需求,这时应尽可能集合相关干系人(产品负责人、业务分析师、设计师、质量分析师和开发等),然后通过绘制草图帮助所有人理解原始需求,并澄清相关问题。这一阶段的关键在于鼓励各种干系人参与建模过程,于是应尽量采用包容性建模(Inclusive modeling)及相关工具,避免过度专业和复杂的工具应用,从而促进沟通。常见的包容性工具有白板、索引卡、便利贴、白板纸等。

设计模型风暴是在编写代码前由若干开发人员共同完成的设计活动。根据开发人员的技术背景,建模过程可以采用UML类职责协作卡(CRC)、数据流程图或一般流程图等包容性工具。具体过程可以参考前文介绍的CRC及其在协作式OOD中的应用。

模型风暴能够促进设计知识在团队中进行传递,从而有效控制软件设计的单点失败风险。

快速反馈:设计监测(Design Monitoring)

快速反馈主要是指能够快速验证当前设计的完整性,并在发现设计缺陷时提供警报,这往往需要依赖专业面向软件设计的静态代码分析工具来完成。通常的做法是把相关工具集成进现有的持续集成过程,并作为某种质量检测报告输出,从而实现设计监测。设计监测工具主要通过分析软件结构中的依赖热点(Hotspot)进行,一般有两种途径分析这些热点——度量模式

度量是通过对软件结构中的实体及其依赖关系进行量化分析,从而反映软件模块化的程度。一种可量化的设计原则是包依赖原则,在此基础上的经典Java开源实现即JDepend(现基本停止维护),该工具以Java语言的包为单位,分别计算每个包的类数量(TC)、具体类数量(CC)、抽象类数量(AC)、传入耦合(Ca)、传出耦合(Ce)、抽象系数(A)、不稳定性(I)、偏离距离(D),每种度量的具体定义本文不再赘述。

模式主要是指检测依赖中的反模式,后者主要是违反设计原则的实际情况,例如违反包间无环依赖,接口隔离、Liskov替换、依赖倒置等。具体做法是把软件设计中的依赖关系用图表示,然后检测图中存在的违反设计原则的特定模式(Motif),经典开源实现即Google的GUERY(停止维护)。该工具能够根据图中的顶点、关系及其路径长度等条件识别特定模式,并且根据Tarjan算法计算强联通子图进而生成凝聚图。

免费工具除前述外,还有针对Java程序的依赖抽取和可视化工具Class Dependency Analyzer(CDA),CDA能够把相关依赖以UML的形式进行可视化,帮助用户理解并管理复杂软件结构。专业用于依赖分析的商业工具有LattixSonarGraphStructure 101JArchitect/NDepend/CppDepend等,本文不再赘述。

值得一提的是,面向软件设计领域无论是免费还是商业工具,尽管其内置的设计规则具有普遍性,同时也支持自定义规则,但通常都存在较高的学习、维护和实施成本,实际上并不能真正达到快速反馈的目的。对处于一线的中小型敏捷团队来说过重,更适用于一些已经具备较高价值的商业软件开发和大型软件组织的架构看护活动。

ArchUnit是一个基于Java语言的开源依赖检查框架,用户通过编写测试断言的形式约束软件结构依赖,并且通过现有单元测试框架如Junit实现自动运行。与前面提到的主流第三方工具相比,该工具也定义了一些具有普遍意义的依赖规则,同时还具有如下优势:

  • 直接采用原生语言并作为宿主的测试实现,支持包、类、注解、分层、分片等多种概念实体,使定义复杂的Java代码依赖规则更加容易。

  • 采用单元测试的思路,使依赖规则能够更快响应软件结构变化,降低规则维护的成本,真正实现快速反馈。

  • 允许开发人员结合价值交付、团队责任等灵活定制依赖规则,特别适用于敏捷软件设计的场景。

该工具的缺点是无法向多数GUI工具那样支持依赖分析,内置规则也不如成熟商业工具丰富,因此要求开发人员深入理解设计原则,并能够结合上下文定制恰当的规则。

结论

在一般的敏捷宣传语言中,诸如大道至简(You Aren’t Gonna Need It,YAGNI)和恰如其分(Just enough)等词汇往往被使用且被轻易误解。原因在于脱离了具体的实践,敏捷就只剩下一个以人为本的空壳,并不能反映出源自核心的根本经济动力。因此无论是从软件匠艺(Craftsmanship)还是专业主义(Professionalism)来看,敏捷对开发人员的要求都要更高。反映在软件设计领域,具体就是除了基本的分析和设计方法、原则和模式等知识外,进一步注重软件设计中的价值交付、团队责任以及快速反馈等实践。

引用

MFL00, Is Design Dead?

MFL14, Parallel Change

MFL16, Evolutionary Database Design

Comments

软件设计与架构笔记(13)

设计诊断

设计诊断(Design diagnosis)是指获取、分析、检测软件设计及其质量的活动。经济利益是驱动软件设计诊断的重要原因之一,特别是对于具有重要价值的软件设施来说,尽早发现并解决设计中存在的问题是十分必要的。然而一直以来软件设计都被认为是难以度量和评价。一方面是因为设计中包含的决策通常是涉及对领域、技术或其它上下文因素的权衡,这是任何客观标准都难以做到完全覆盖的,因此始终无法100%消除对主观参与的依赖,导致设计诊断的权威性受到限制。另一方面,已有的设计验证方法普遍落后于软件开发技术的创造和更替。尽管设计原则与代码味道具有一定的普适性,但是大部分原则本身缺少明确的定量或定性规则(即使存在这类规则一般也很难适用于新的开发技术),少量有明确的规则通常被归为静态代码分析和风格检查,尚不足以达到设计诊断的目的。

因此为了实现设计诊断,一方面需要对软件设计进行统一的形式化表示,避免对具体的软件开发技术产生依赖。在前者的基础上进一步分析当前软件设计,帮助分析人员理解现有设计、发现设计中的潜在缺陷、甚至模拟历史和未来的设计演化,从而为工程进度和技术债管理提供依据。本文的剩余部分将详细讨论这些主题。

设计分析(Design analysis)

设计分析的目标是研究设计本身,后者通常表示解决问题的方案,也可以指构建解决方案的过程。[BC00]认为,理想的解决方案可被视作具有一系列特性的集合,其中的每种特性都可以被归纳为某个维度,即设计参数(Design parameters)。不同的设计参数之间存在一定的依赖关系,即设计结构(Design structure)。设计参数的所有可能值的集合被称作设计空间(Design space)。在整个设计过程中,每个设计参数由对应的设计任务(Design tasks)决定,后者相互之间的依赖关系被称为任务结构(Task structure)。例如要设计一个马克杯,“是否包含杯盖”是一个设计参数,而“杯盖直径”则是依赖于前者的另一个设计参数,而对于“容器直径”来说,“杯盖直径”与其存在相互依赖的关系。相应地,分别负责杯盖和容器的设计任务之间也就存在依赖,因此设计结构和任务结构具有一致性。前述这些结构可以用一种邻接方阵进行表示,即接下来要讨论的设计/任务结构矩阵(Design/Task structure matrix,DSM/TSM)。

设计结构矩阵

在DSM(TSM)中,每个结点表示设计参数(或设计任务),两个设计参数之间的依赖用符号x进行表示,一个马克杯的设计结构例子如下图所示:

Full DSM

虽然该DSM只有10x10,但每个结点间依赖都可能包含了丰富的物理和工程属性,真实场景中也许会非常复杂。根据设计参数之间的依赖关系类型,在DSM中可以进一步发掘出一些微结构,例如:

Micro DSM

上图描述了两种微观的设计结构,(a)表示层次结构,(b)表示无层次的相互依赖结构,这两种设计结构实质上体现了不同的关系强度,显然(b)体现了更强的相互关系。

另外,真实案例中的矩阵规模通常要比马克杯大得多,例如一个设计笔记本电脑的TSM如下图所示:

A laptop computer TSM

上图中的TSM相比于马克杯的例子有几个新的元素。首先矩阵中的设计任务呈现出特定排序(或呈现为下三角矩阵),即相互之间存在强关联的设计任务被放置的更近,越接近对角线的依赖密度就越高。其次整个矩阵上产生了若干相互关联的区块,这些区块直接反映了系统中的独立组件,例如驱动系统、主板等,被称作原型模块(Protomodules)。原型模块通常是由领域知识或者组织结构等上下文决定,但是并非真正意义上的模块,因为其本身不具有接下来要讨论的模块性(Modularity)。

模块性与设计规则

一般而言,高复杂度的问题会导致同样高复杂度的设计,而一个“好”的设计能够有效地管理其自身复杂性。[BC00]认为,模块化(Modularization)是系统管理自身复杂性的核心,也是二十世纪以来计算机乃至更多其它领域得以飞速发展的重要原因。系统的模块化程度体现为模块性(Modularity),其中包含两个重要概念:

模块,即内部元素间的关系比与外部元素间更强的系统单元,这些关系的相对强弱决定了模块的粒度。

抽象信息隐藏接口,即当一个系统达到一定复杂度时,需要将其拆分成不同部分,抽象的目的在于隐藏其内部复杂性,且通过接口与系统的其它部分进行交互。

在设计笔记本电脑的TSM中,我们知道主板和显示屏之间的设计任务多存在循环依赖,一个例子如下图所示:

Cycling in a laptop computer TSM

该例中的多个设计任务因为相应设计参数而存在互相依赖,例如主板要决定CPU的规格和所采用的中断协议,而显示屏需要确定详细规格。当主板中具有独立的图形控制器时,显示屏的规格就会发生改变。否则CPU就要根据显示屏的规格提供更多的计算能力,并且采用不同的中断协议。由此可见,“主板是否包含独立的图形控制器”就成为其它设计参数的关键依赖参数。从系统的角度看,其整体复杂性通常是由一系列关键依赖参数决定的,一旦其中某个设计参数确定,则许多依赖的设计参数也就相应确定。这些关键依赖参数被称作设计规则(Design rule)。一个完整的系统设计规则集合应至少包含如下信息:

  • 模块及其在系统中扮演的角色。

  • 模块间通信的接口。

  • 系统集成协议以及测试某个模块是否遵循设计规则。

通过抽取设计规则可以消除原型模块间的相互依赖,从而形成真正意义的模块。其中,设计规则被称为显性模块(Explicit modules),而其他相互独立的部分被称为隐性模块(Implicit modules)。一个模块化后拥有完整设计规则集合的DSM/TSM所下图所示:

Modularization

DSM/TSM对于计划设计过程同样具有意义,在上例中,首先进行的是设计规则阶段,然后进入可并行进行的隐性模块设计阶段,最后是系统集成和测试阶段。其中,设计规则作为所有阶段的输入,隐性模块则作为集成和测试阶段的输入。

模块演化及其模拟

模块性反映了系统的结构状况。如果一个系统具有嵌套层级结构,每个结构单元对内强关联,对外则相互独立,并且具有良好的功能角色定义——那么该系统就被称作模块化系统。值得注意的是,系统结构并非一成不变,一方面是因为某些设计参数间的依赖并不容易在初期就显现出来,另一方面,由于复杂的结构往往导致更高的经济成本,因此在真实场景中更加倾向于寻求结构和经济之间的平衡。为了描述模块的动态特征,可以采用模块操作符(Modular operators),[BC00]提出了六种最基本的模块操作符,后者能够用于表示动态结构的所有可能演化路径:

  • 分解(Splitting),把现有设计或任务划分成多个模块,在层次结构中这往往意味着产生了新层,例如以下模块化层级设计:

Two-level modular design hierarchy

上例中描述了A~D四个隐性模块以及一个集成和测试阶段,从模块化的角度来看它们都属于相同层级。当更多设计参数及其依赖显现,并且上下文满足模块化设计需求时,新的设计规则以及相应的接口、测试就会出现,于是就诞生了新的层级,如下图所示:

Three-level modular design hierarchy

在进行分解操作后,新的层级应当只对其所依赖的设计规则负责,而对全局设计规则以及上层的集成和测试部分保持透明,这对设计任务和阶段执行具有重要意义。

  • 替换(Substituting),指替换现有模块设计。替换通常是因为多种设计路径之间存在竞争关系,于是更多受到经济系统因素的驱动。模块的可替换性通常是由分解所决定的,因此分解在此扮演了非常重要的角色。

  • 增强(Augmenting)和排除(Excluding),即添加或删除模块,与分解与替换不同的是,增强和排除是针对已经模块化的系统来说的。排除体现了模块化设计的可配置性,也就是说用户可以按需选择模块,这与替换的特性是相当的。增强通常是由于系统中需要引入新特性,为了保证可增强性,需要在设计规则阶段就要考虑这种能力。

  • 反转(Inverting),指创建新的设计规则。我们知道设计规则来自于设计参数,后者广泛存在于隐性模块中。因此有时需要把隐性模块从当前的设计层级中“拉取”上来,使其对更多模块保持可见。

  • 移植(Porting),即把当前模块移植到新系统。某些隐性模块支持从当前系统移植到新系统,那么该模块至少应满足以下条件之一:

    • 所依赖的设计规则在新系统中存在且不变。

    • 模块本身不受设计规则的影响。

采用上述模块操作符可以模拟任何过去、现在和未来所发生的设计变化。例如可以抽取设计演化历史中的连续片段,然后用模块操作符描述每一步的变化。对于进行中乃至未来的设计来说,模块演化则是非确定的,采用公式(j6 X 2) - 1即可计算模块演化的所有可能路径,例如当系统中包含6个模块时,就有93311种演化可能。

应用DSM分析软件设计

[NEVD05]首次把DSM用于管理复杂软件系统的依赖模型(Dependency model),具体方法是通过静态分析提取代码的依赖关系,然后在DSM中进行层次结构展示,支持人工选取设计规则,并且检测出违反相关规则的依赖关系。

通过静态分析提取到的大多属于语法依赖,即字面引用所体现的依赖关系。不同编程语言的语法依赖类型存在一定区别,并且语言自身的模块化特性也不尽相同,因此存在多种表示软件依赖模型的方式。一种简单的做法是忽略依赖类型间的差异,选择统一的模块化元素作为DSM的设计参数,例如Java中的类,并且按照元素间存在的引用数量定义依赖强度。下例展示了jEdit v4.2的DSM:

DSM for jEdit v4.2

当DSM规模较大时,需要支持进一步显示矩阵中的层次结构。尽管许多现代编程语言都在语法上提供了层次化结构的特性(例如包、类、方法等),这些信息可被直接用于DSM分层。但是,多数情况下软件的层次结构无法满足[BC00]的模块性标准,这种在实际中十分普遍的情况被称作软件结构的技术债。为了方便理解和改进现有系统的模块性,业界开发了许多针对DSM的聚类算法,即从DSM中的元素及其依赖出发,通过重新排列元素顺序实现自动聚类,其中有代表性的方法有:

  • [JNW73]采用矩阵分区算法把初始矩阵划分成若干子矩阵,使后者满足下三角矩阵的特征,从而消除循环依赖。

    该算法的基本思路是针对每个元素,首先构建可达性(Reachability)集合R(s)与先导(Antecedent)集合A(s),前者指从该元素出发能到达的所有元素集合,后者指从非当前元素出发能到达或经过该元素的路径的所有元素集合,以及两者交集R(s)A(s)。算法每次迭代选择满足R(s)A(s) = R(s)的元素集合作为当前矩阵的top-levels,然后将其从剩余元素的集合中删除并重复这一过程,直到剩余元素个数为0。矩阵分区算法的优点是实现简单,能够快速筛选出不存在循环依赖的子矩阵,对DSM分层具有一定意义。但是该方法无法满足更多的模块化特性,例如[BC00]中指出的隐性模块间的相互独立性。

  • 聚类分析中常用的启发式算法同样被用于构建DSM的元素聚类。如果某个系统内存在一系列规模合理且相互独立的子模块,那么这些子模块内的依赖关系一定趋近于DSM对角线,以此推论为基础设计距离惩罚函数作为启发式算法的目标函数[TS94]。与分区算法相比,启发式算法能够实现模块间独立条件下的更优结果,而且实现也比较简单,例如聚类部分采用现有的遗传算法框架[RAC08]。但是,设计软件模块性的目标函数是一项挑战。另外,软件的模块性往往还体现在层次结构方面,这是一般的聚类方法难以同时考虑的。

  • 由于DSM本质上是有向图,因此可以采用图算法进行DSM分层。[SYG09]是一种基于图算法的DSM层次聚类方法,首先计算DSM的凝聚图(Condensation graph),然后找出所有出度为0的结点的所有依赖关系路径,再从拥有最长路径的结点出发构建DSM的层次结构。该方法构建出的层次结构一定满足下三角矩阵,同一层的模块间保持相互独立且允许并行开发。其优点在于使用DSM实际反映出软件的层次结构,从而能够进一步诊断软件的设计问题[RYR15]。

    如果要根据DSM中依赖关系的强弱寻找更优化的层次结构,则可以采用图聚类方法[SS07],特别是针对有向图聚类[FM13]。[SA14]采用谱聚类方法对DSM进行重新聚类,该方法建立在DSM中具有较大特征值的特征向量、特征值、模块层次数以及每层模块数等数量之间的相关性基础上,通过对原始DSM进行奇异值分解、分析和降维,计算每个结点在k维空间的线性表示,最后以结点在k维空间中的距离进行聚类。尽管该方法需要指定k值,但是聚类结果依然能正确反映DSM的层次结构。例如:

Spectral clustering

其中(a)是原始DSM,(b)©(d)分别表示k=2,k=4,k=8时的谱聚类结果,可以看到随着k值的变化,聚类结果始终能表现出实际DSM的层次结构。

设计度量(Design metrics)

设计度量涉及一系列面向软件设计的度量指标,包括针对整体模块性的度量、接口强度和优先级、扇入/扇出、联通度以及可见度等等。值得注意的是,设计的度量结果并不能直接等价于设计质量,通常可以作为支持设计分析结果的辅助证据,帮助定位具体问题并结合具体上下文制定改进计划。

模块度是一种度量整体模块性的指标。[MAC06]认为可以通过计算DSM中元素间的依赖成本,例如依赖的数量和分布模式等,从而实现模块度的间接计算,并且其中存在两种可能的应用场景:

  • 比较软件A和软件B的模块性。

  • 比较软件A在T时刻和T + N时刻的模块性。

假设DSM的元素数量为n,其中传播成本(Propagation cost,Pc)忽略元素所在的位置,假设直接依赖和间接依赖具有同等成本,然后计算所有元素的扇入或扇出数M,则Pc = M / n2。对于整个系统而言,扇入和扇出数是相等的,因此M可以任选其中一种进行计算。聚集成本(Clustered cost)把模块内和模块间的依赖进行区别计算,首先指定一个依赖阈值(通常是10%~100%间的数),并将DSM中被依赖次数超过该阈值的元素计入主控元素,然后根据以下条件计算每项依赖所包含的成本:

  • DependencyCost(i -> j | j is a vertical bus) = d
  • DependencyCost(i -> j | in same cluster) = d * n^λ
  • DependencyCost(i -> j | not in same cluster) = d * N^λ 其中d是表示是否存在i -> j依赖的二进制值,n指模块规模,N指DSM规模。λ是自定义参数。

除了通过依赖成本计算模块度,另一类方法是直接计算模块度。根据模块从内及外且依赖由强变弱的定义,[GG04]提出了一种通用的模块度计算方法,该方法的前提是DSM中已经包含了精确的模块化信息。当DSM中不包含模块化信息,或者需要直接计算系统的实际依赖复杂度时,可采用奇异值模块度指数(Singular Value Modularity Index,SMI)[KO11]。该方法通过对DSM进行奇异值分解,然后计算奇异值的下降率从而表示系统模块度。以下面三种典型的结构模式为例:

Typical structural patterns

从模块性来看,单块(Integral)系统的模块性较差,总线(Bus-modular)系统也比较差,模块化系统则相对较好。对这些模式对应的DSM进行奇异值分解,从而得到上面三种结构的奇异值下降模式:

Singular value decay pattern

可以看出,单块系统的下降趋势非常陡峭,总线型系统比较陡峭,而模块化系统的下降趋势则相对平滑。基于上述关联关系,可以认为当系统的模块性较差时,奇异值会出现迅速下降的情况(SMI较低),而模块性较好的系统,奇异值下降则通常比较缓慢(SMI较高),这种相关性也是上文讨论的谱聚类方法的基本假设。

结论

设计诊断包括分析和度量两个方面,其中设计分析主要负责设计的形式化表示,例如本文讨论的DSM工具。在DSM的基础上可以进一步分析和模拟设计演化过程,发现设计缺陷以及优化系统模块性。DSM同样可以用于设计度量,设计度量指标不直接等价于设计质量,但可以指导设计及其改进。除了基本的度量指标外,模块度是度量系统整体模块性的核心,可以用于不同软件之间和相同软件的不同版本之间的模块性评价。

引用

BC00, Design Rules, Vol. 1: The Power of Modularity

NEVD05, Using Dependency Models to Manage Complex Software Architecture

JNW73, Binary Matrices in System Modeling

TS94, Integration analysis of product decompositions

RAC08, Systematic module and interface definition using component design structure matrix

SS07, Graph clustering

FM13, Clustering and Community Detection in Directed Networks: A Survey

SYG09, Design Rule Hierarchies and Parallelism in Software Development Tasks

RYR15, Hotspot Patterns: The Formal Definition and Automatic Detection of Architecture Smells

SA14, A Spectral Analysis Software to Detect Modules in a DSM

MAC06, Exploring the Structure of Complex Software Designs: An Empirical Study of Open Source and Proprietary Code

GG04, A Comparison of Modular Product Design Methods on Improvement and Iteration

KO11, Degree of Modularity in Engineering Systems and Products with Technical and Business Constraints

Comments

软件设计与架构笔记(12)

设计原则与代码味道

在此之前我们已经讨论了面向对象分析与设计领域分析及其模式以及设计模式。其中朴素的分析和设计方法具有通用性,但需要长期的实际经验积累,在这一过程中不可避免会付出一定代价。虽然模式提供了可复用的设计元素,但基本都存在特定上下文的限制。尽管仍有新的模式不断被提出,但并不意味着其适用性和局限性已被明确认识。同样是来源于经验,有一些设计知识能适用于绝大多数场景,从而具备更广泛的适用性,这就是本文首先要讨论的设计原则。设计原则是来源于实际经验且能够指导一般软件设计的法则,其根本动机是发现并解决软件设计问题。

一般而言,发现软件设计问题的终极方法是以真实需求为基础构建软件,然后收集并分析该软件的开发和运行反馈——显然这种方式的代价过于昂贵。一种退而求其次的办法是构建原型系统,在原型阶段只考虑待验证的核心功能,尽早交付给用户使用并收集相关反馈,该方法使软件设计能够更快响应变化。但是我们知道设计复杂度与问题的复杂度是正相关的,而易变性又是软件设计的一个重要特征,因此从原型获得一次反馈的效用会随着时间推移和问题复杂度的增加而逐步降低,于是需要缩短反馈周期以实现频繁反馈。高反馈频率意味着更高的交付效率,然而交付效率的提升又有赖于恰当的工程方法和可扩展的设计。因此,“黑盒”式的问题反馈方法虽然为设计问题发现提供了事实依据,但其效率受软件自身设计问题所制约。另一方面,从最初采集得到反馈到定位具体设计问题,对问题根因可能存在不同解读方式,导致最终结论的有效性也可能面临挑战。

幸运的是软件开发并非孤立问题,软件设计实践中遇到的问题及其解决方案往往具有普遍性。在这些知识的基础上诞生了一系列被普遍认可的、“白盒”式的设计原则,使软件设计中的潜在问题能够被更早发现和解决。应注意,某些设计原则是针对特定上下文,例如Liscov替换原则之于OO,更多则适用于广泛的上下文。本文剩余部分首先介绍设计原则背后的核心设计属性,然后按所适应的场景分组并讨论经典的设计原则,最后讨论相比于设计原则更轻量、更贴近日常编程活动、且涵盖更广泛的经验知识——代码味道及其与设计原则之间由表及里的内在联系。

设计属性和通用设计原则

每提起设计原则就会出现许多经典的名字和概念,但诸多原则都表现了相对稳定的设计属性,这些属性往往也是软件设计领域中的核心概念,且在前文大多已经讨论过:

  • 耦合性(Coupling),即模块间依赖的程度,耦合越高则意味着该模块将难以被维护,详见结构化设计方法

  • 内聚性(Cohesion),即模块具有单一目的性的程度,内聚越高则意味着更好的可理解性和可重用性,详见结构化设计方法

  • 正交性(Orthogonality),即模块能够独立发生变化的程度,具有正交性的模块意味着更容易应对变化。正交性最初被用于描述一种针对关系数据库的设计原则[DC93],即对于任意两个相互独立的表,其无损分解后的子集不存在相互重叠的情况,该原则能够帮助发现关系数据库设计存在的数据冗余问题。[AD00]详细解释了正交性在更广泛的软件设计问题中的意义,特别是其在模块化、组件化、分层设计等不同设计方法中的一致性体现。

  • 信息隐藏性(Information hiding),即模块尽力隐藏其实现细节的程度,具有信息隐藏的模块通常意味着更低的耦合性,详见模块化编程

设计属性为评估设计质量建立了基础,但由于更加强调概念完整性,使其在形式上很难直接与具体的设计问题相关联,于是就出现了数量更多且更具实践意义的设计原则。对于早期提出的、通用的设计原则来说,其可能借鉴自其它领域,例如:

  • 关注点分离(Separation of concerns, SoC),即把注意力集中在某个方面,而非与其它无关方面相混淆。该原则最初来源于Dijkstra对计算领域中科学性思维属性的探索[EWD74],后来被引入软件设计领域,用于强调软件模块之间应具有尽可能少的特性重叠。

  • 一次且仅一次(Once and only once),也称Don’t repeat yourself,DRY。指任何知识都应在系统中有唯一、清晰和权威的表示。该原则适用于许多软件设计领域。例如单一数据源(Single source of truth, SSOT),指系统中的任何数据元素都只有一份,任何其它具有相同定义的数据都是该唯一元素的引用,目的是保证数据的完整性和规范性。

  • 保持简洁(Keep it simple stupid, KISS),简洁意味着易于理解、维护和扩展。KISS旨在强调简洁性对于系统设计的重要性。实际上简洁性还普遍适用于设计、建筑和哲学等其它领域,例如Simplicity is the ultimate sophistication,Brevity is the soul of wit,Less is more,Make simple tasks simple以及Simplify, then add lightness等。

实体设计原则

实体通常指软件中表示模块的单位,例如存在于许多编程范式中的类、模块等元素。针对实体的代表性设计原则如下:

  • 单一职责(Single responsibility),指任意实体应只有一个使其产生变更的原因。这里“产生变更的原因”等价于实体的职责,即要求实体具有尽可能少的变化维度。单一职责原则是表述最简单的设计原则之一,也是最难被遵循的原则。这是因为职责的定位和分离会随着上下文变化而不同,这需要一定的实践经验和分析过程,且缺少直观的量化手段。尽管如此,单一职责原则仍有可能通过遵循其它设计原则而间接实现。

  • 开放-封闭(Open closed),指任意实体应对扩展开放,对修改封闭[BM88]。当程序需要发生变更时,应尽可能通过添加新的代码而非修改已有代码来完成,即增量扩展。频繁发生修改的实体通常是难以被预测和重用的。值得注意的是,对大多数软件设计来说,保证100%对修改封闭是难以实现的。因此实际中通常采用一些策略性封闭方法,例如:

    • 采用抽象加强显式封闭。如果新的需求导致无法满足对修改封闭,首先应考虑当前的抽象设计,是否需要调整或引入新的抽象从而加强显式封闭特性。

    • 采用数据驱动实现封闭。当修改可能影响同一抽象层级下的许多实体时,可以考虑采用配置数据驱动代码的方式限制修改的影响范围。该方法能够避免引入额外依赖,同时把修改封闭在尽可能小的范围。

    无论是采用抽象还是数据驱动方法,都有可能引入新的封闭性问题。因此实践中往往需要不断考虑并扩展实体对修改的封闭性。

  • Liskov替换(Liskov substitution),如果S是T的子类型,那么对T的任意对象的引用可以被直接替换为S的对象,且毋须修改已有代码。Liskov原则中的“替换”不仅是指语法上父子类型相互兼容,进一步子类型应当保留父类型中的不变量(见设计契约),从而实现在语义层面的兼容。在类型系统中,前述这种更趋严格的子类型定义被称作行为子类型(Behavior subtyping)[LB87]。

  • 接口隔离(Interface segregation),指客户端不应被强迫依赖于它们不用的接口。当实体中需要引入一个新的公共方法时,一般会在其接口中声明具有相同签名的方法。如果该抽象层级下对应了多个子类型,但并非所有子类型都需要新声明的方法时,就意味着发生了接口污染,这种接口也被称作”胖接口”。依赖于胖接口的客户端代码被迫依赖于许多对它们来说无意义的接口,从而大幅增加了级联变更发生的概率。

实体依赖原则

软件的不同实体之间通常存在着依赖关系,针对实体间依赖的代表性设计原则如下:

  • 依赖倒置(Dependency inversion),指高层实体不应依赖低层实体,两者都应该依赖于抽象;抽象不应依赖细节,细节应依赖抽象。当发生直接依赖的实体之间同时存在层级关系时,应当使其依赖共同的抽象。

  • 控制反转(Inversion of control),指通过框架实现程序的控制流,从而操作客户端代码以实现自定义扩展[MFR05]。传统上软件由客户端代码和所依赖的代码库组成,其中客户端代码扮演了负责控制流的角色。为了实现可扩展性,需要首先建立抽象,框架就是集合了众多抽象设计的代码骨架,其中提供了客户端代码的接口,但控制流就从客户端移交到框架端。该原则也被称作好莱坞原则(Hollywood principle),即Don’t call us, we’ll call you

  • 最少知识(Least knowledge),也称迪米特法则(Law of demeter)[LHR88]。对于任何类C以及C中的方法M,M中发生直接调用的对象的类应符合以下两种情形之一:

    • 方法M的参数对象所属的类(参数对象可以是M中创建的对象、M中发生的函数调用所创建的对象、或者是M中引用的全局变量对象,包括C)。

    • 类C中任何实例变量对象所属的类。

    应用最少知识原则能够降低系统本身和对其修改的复杂度。该原则的另一个名字“迪米特”是最初应用该项原则所设计的OO系统[LHR88]。

包设计原则

与Java的package和C++的namespace等关键字不同,包在设计原则的上下文中是指独立的可交付物(有时也被称作组件),例如jar包和dll文件。包是常见于大规模的软件系统中的概念,这里的“规模”没有具体的量化指标,可能是指代码行数、团队大小以及系统复杂度等。针对包的代表性设计原则如下:

  • 重用-发布等价(Reuse-Release equivalency),指可重用代码的粒度不应小于代码的可发布粒度。我们知道可重用性是OOD的一个重要属性,可重用的代码应遵循如下原则:

    • 可以被独立开发、维护、测试、分发。

    • 具体实现对外部隐藏,只通过发布接口(Published Interface)对外公开[MFR02]。

    违反上述原则的代码重用通常都具有副作用,例如代码复制(Code clone)、破坏代码封装导致的强耦合等。针对这些问题,在可发布粒度上实现代码重用是一种有效的解决办法,该方法通过封装和可追踪使代码具有更好的可重用性,包就是实现这种可发布粒度的有效途径。

  • 共同封闭(Common closure),指包中的不同实体应当封闭于相似的修改原因。根据前文对开放-封闭原则的讨论,实际中始终存在无法令实体对其封闭的修改。而如果不同实体具有共同的修改,那么应使它们属于同一个包。也就是说,同类型的修改应尽可能被限制于最少数量的包中。

  • 共同重用(Common reuse),指包中的不同实体应当具有被共同使用的倾向。一般情况下,如果某些实体之间存在抽象层级的协作关系,那么它们应属于同一个包。否则,仅针对个别实体的修改可能引起跨包修改,从而存在较高风险。与共同封闭原则类似,该原则有利于加强包的可维护性,这在大多数上下文中比可重用性更加重要。

包依赖原则

与实体间存在依赖关系类似,包之间也存在依赖关系,针对包之间依赖的代表性设计原则如下:

  • 无环依赖(Acyclic dependencies),指包之间的依赖关系图应是一个有向无环图(DAG)。作为可发布的软件单元,不同包之间不可避免着存在着依赖关系。如果软件系统中存在包的循环依赖关系,即环形依赖,则可能导致以下问题:

    • 依赖环中的所有包存在共同修改的可能,破坏了可独立发布的属性。

    • 包可能间接依赖于大量其它包,从而降低可维护性。

    一种解决循环依赖的方法是应用依赖倒置原则,提取依赖的共同抽象。另一种方法是把现有包中被依赖的部分抽取出来组成新的包。

  • 稳定依赖(Stable dependencies),指包之间应遵循更加稳定的依赖方向。如果一个模块是易变的,那么对该模块的依赖在很大程度上也是易变的,这种易变性(Volatility)会沿着依赖的方向传递,从而影响整个系统的可维护性。然而由于软件的易变性可能存在许多影响因子,不同影响因子所导致的后果也不尽相同。其中稳定性(Stability)被用于描述模块修改的难易程度,即当模块越难以被修改即越稳定。Uncle Bob提出了一种稳定性的度量指标,可以用如下形式计算:

    Ca: 传入耦合(Afferent coupling),依赖于当前包内实体的外部实体数量。

    Ce: 传出耦合(Efferent coupling),依赖于外部实体的包内实体数量。

    I: 不稳定系数(Instability),且I = Ce ÷ (Ca + Ce),则I范围是[0,1],当I=0时当前包最稳定,I=1时则最不稳定。

    因此,被依赖的包应具有比依赖包更大的I值,即满足稳定依赖原则。

  • 稳定抽象(Stable abstractions),指稳定性越高的包也应越抽象,反之则越具体。由于高度稳定的包往往难以被修改,因此其应尽可能抽象,从而使系统的易变部分始终保持在不稳定包的具体实现中。包的抽象程度被称作抽象性,与稳定性相同,Uncle Bob提出了抽象性A的计算方式:

    A = 抽象实体数 ÷ 实体总数。

根据稳定性和抽象性的定义,理想情况下的软件系统应呈现类似如下线性关系:

Abstraction-Instability graph

也就是说,当已知某个包的稳定性和抽象性度量时,我们就可以进一步计算它们在上述坐标中偏离理想值的程度,用距离D表示这种程度,从而有:

D = |(A + I - 1) ÷ √2|

在实际中能够通过计算包的D值,从而决定包的设计合理性,D值越大的包应优先被关注和改进。

代码味道

相比于设计原则,代码味道是一种更加轻量的、被用于识别设计问题的方法。如果把具有良好设计的代码视作是干净无味的话,那么代码味道则可被用于发现那些“显而易见”的“异常”代码。“味道”一词并不是一个正式的概念,Martin Fowler认为代码味道应具备三个基本特征[MFR06]:

  • 代码味道应是易于被察觉的。

  • 代码味道不一定表示代码中存在设计问题,即使存在“问题”,也有可能是因为设计权衡的结果。

  • 代码味道非常易于被程序员理解和掌握,基于代码异味的重构使新手程序员也有机会持续改进设计,即使在缺少对深层次设计原则理解和相关经验的情况下。

与设计原则类似,按照作用层级可以把代码异味划分为方法、实体(类和模块)以及通用三个类别。

  • 具有代表性的方法级代码味道包括:

    • 长方法(Long function)和长参数列表(Long parameter list),不仅导致代码难于理解,还可能违反单一职责原则。
    • 重复switch(Repeated switches),尽管switch语句具有易于理解的结构,但可能违反开放-封闭原则,特别是当相似结构的switch语句重复出现时,会相应存在多处需要同时被修改的代码。
    • 循环(Loops),随着内循环和管道式编程的普及,常规的外循环语句由于相对复杂的结构已经成为循环计算的备选方案。
    • 特性依恋(Feature envy),指实体中的某个方法过度依赖了其它实体中的数据或方法,进而可能违反了关注点分离原则。
  • 具有代表性的实体级代码味道包括:

    • 临时值域(Temporary field),指实体中仅在部分情况下有效的属性,这可能违反单一职责原则和关注点分离原则。
    • 消息链(Message chains),如果存在对象的方法的链式调用,且每个阶段的调用都作用于不同对象,这种消息链可能违反了最少知识原则。
    • 中间人(Middle man),指缺少实际意义的代理方法。尽管封装被作为是OO的重要特征,但某些时候存在不合理的封装,例如一些实体中存在的代理方法,其作用仅是分离了真正的调用对象和被调用对象,这可能违反了单一职责原则和关注点分离原则。
    • 内幕交易(Insider trading),指实体间发生数据处理和相互传递的现象。数据交易可能会引起过度耦合,但有时很难完全避免,因此需要尽可能减少此类现象出现的频率。
    • 过大的类(Large class),指一些包含了大量属性的类。这可能违反了单一职责原则和关注点分离原则。
    • 异曲同工的类(Alternative classes with different interfaces),指某些具有相同类型特性的类,因其具有不同接口而无法利用OO的多态性。
    • 数据类(Data class),指只包含可被外部读写的属性的类,导致有关该类的操作散布在不同的类中,这可能违反单一职责原则。
    • 被拒绝的馈赠(Refused bequest),指在继承关系中,子类拒绝或忽略了父类中的某些方法或数据,这可能违反接口隔离原则。一种解决办法是创建父类的兄弟类,使方法和数据相分离——这只在具有良好抽象意义的情况下有效,否则可能会引入更多复杂度,反而得不偿失。如果现有抽象更加稳定,而“被拒绝”的元素又足以影响可维护性,更有效的办法是抽取新的类,并采用对象代理关系替换原有继承关系。
  • 具有代表性的通用代码味道包括:

    • 数据泥团(Data clumps),指某些经常同时出现的数据组合,其出现场景可能包括不同类中的属性、许多方法签名的参数等。通常可以采用创建新的类表示这些数据组合。
    • 重复代码(Duplicated code),指相同或相似的代码结构在程序中多次出现的现象。最易被识别的重复代码通常发生在同一个类或拥有继承关系的多个类中,也比较容易被消除。除此之外,对重复代码的识别和解决都可能需要进一步的设计权衡。
    • 全局数据(Global data),特别是可变的全局数据,即全局变量。我们已经多次强调了这种共享可变状态的代码可能导致潜在的质量和可维护性问题。实际上,即使是非共享状态的可变数据(Mutable data),依然可能导致代码质量问题。
    • 发散式变化(Divergent change),即某个模块可能会由于不同原因而导致不同方式的修改,违反了单一职责原则。如果修改原因只有一种,但引起了其它模块发生多次级联修改,则称作霰弹式修改(Shotgun surgery)。
    • 基本类型偏执(Primitive obsession),指采用基本类型表示某些复杂数据类型,而非创建独立的类,这可能导致大量的重复代码。
    • 懒元素(Lazy element),指某些设计元素(例如类、实体)只有非常简单的功能,甚至使用一、两行代码就能清楚表现该元素的特性,那么他们就没有单独存在的必要。与之表现形式相反,但具有统一思想的夸夸其谈未来性(Speculative generality),或者称作大设计先行(Big design up front, BDUF),则表示发生了过度设计。
    • 过高的圈复杂度(High cyclomatic complexity),指代码中存在过度复杂的控制流图。圈复杂度通常用代码中的线性逻辑路径数进行表示。假设用N表示代码中的基本区块(指不包含任何控制分支的连续代码片段)数,E表示连接基本区块的边数,P表示连通子图数,那么圈复杂度M可用公式M = E - N + 2P进行计算。过高的圈复杂度可能意味着过度复杂的逻辑或缺少结构性的代码。
    • 神秘命名(Mysterious name)和注释(Comments),这两种代码味道经常同时出现,因为合理的命名更加表意,也就降低了额外注释的必要性。当然在某些时候再合理的命名也无法表达某些上下文时,注释则是必要的补充。

尽管前述大部分的代码味道都有“程度”的概念,使其具体的应用仍然依赖实际经验,但仍然有一些可以遵循的规则。例如,针对重复代码的事不过三规则(Rule of Three),这里虽然中文成语中的数量是虚指,但具体应用时可以作为实际阈值,也就是说当重复代码出现三次时,就应考虑采取相应解决方案了。当然这种规则只能作为初步判断条件,进一步仍然需要结合设计原则进行恰当分析。

结论

为了发现和定位软件设计中存在的问题,人们在实践中总结出了一系列具有普遍意义的设计原则。看似纷繁复杂的设计原则其实体现了一致的设计属性,并在长期的设计分析和验证过程中不断得到认可。为了进一步缩短软件设计的反馈周期,在代码编写活动中就可以通过识别代码味道尽早发现潜在的设计问题,并通过持续重构保证软件的设计质量。

引用

DC93, The Principle of Orthogonal Design

AD00, The Pragmatic Programmer: From Journeyman to Master

EWD74, On the role of scientific thought

BM88, Object Oriented Software Construction

LB87, Data Abstraction and Hierarchy

LHR88, Object-Oriented Programming: An Objective Sense of Style

MFR00, Refactoring: Improving the Design of Existing Code

MFR02, Published Interface

MFR05, Inversion Of Control

MFR06, CodeSmell

Comments

软件设计与架构笔记(11)

设计模式——动机与陷阱

作为软件设计领域在过去三十多年里最重要的议题之一,时至今日,新的设计模式仍不断被提出和采用。软件设计模式的根本目的是为特定上下文提供经受验证的、可复用的元素,从而提高软件工业的生产效率。该领域在早期是伴随着OO的流行逐渐发展起来的[GHJV95],前文所讨论的领域分析模式就是OOA相关的模式,此类模式侧重分析和描述问题域本身。OOD/OOP等活动中存在的模式被称为设计模式,后者用于描述通用代码设计过程中经常重现的组件结构。

为了便于交流和传播,每种设计模式最为人所知的部分就是名字和典型结构。实际上这是一把双刃剑。一方面它确实促进了模式在业界的普及,起到了良好的教育作用;另一方面,对设计模式的描述往往只表现出其中一面,背后其实隐藏了的许多问题,例如:

  • 根本动机,即模式要解决的原始问题,这对于理解模式的适用性非常重要。在不适用场景中应用模式实际上破坏了模式原本的经济效益。

  • 复杂性,例如具有较高的实现复杂性,带缺陷的模式实现会引入更加隐蔽的问题。有时候这种复杂性与具体语言相关,因此语言特定的惯用法(idioms)也成为一种较底层的模式[BMRSS96]。

  • 非适用场景,例如缺少明确的非适用场景的描述,而这部分信息有助于快速排除候选模式。

  • 替代方案,例如缺少对已知的非适用场景的替代解决方案。

上述问题的存在使应用设计模式面临许多挑战。对于这些经典设计模式的“动机与陷阱”,本文将在剩余部分逐一展开讨论。

单例模式(Singleton Pattern)

在OOD中经常会遇到整个系统要求某个类只产生唯一实例的情况,例如Printer spooler、A/D converter等。单例模式通过限制访问构造方法,并向全局提供统一的实例获取接口,从而保证所生产实例的唯一性,如下图所示:

Singleton pattern

系统也可能允许某个类创建特定数量的实例,此时可以用Map结构的instances存储对应多组实例,即多例(Multiton)模式。虽然单例模式利用OO语言的特性实现了对任意创建实例的限制,但实际上可能引入更多问题。以基于Java语言的单例模式实现为例:

1
2
3
4
5
6
7
8
9
public final class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

与上述采用普通类的实现相比,Java的单元素枚举模式具有更加简洁的实现,例如:

1
2
3
4
5
public enum Singleton {
    INSTANCE;

    Singleton();
}

依据JVM规范中的类加载机制,作为静态常量的INSTANCE初始化会在Singleton类初始化过程中进行,而后者的发生需要满足且不限于以下条件之一:通过new运算符初始化实例;对类的非常静态变量进行读写操作;调用类的静态方法;通过反射调用类;类包含main函数。可见,由于类初始化条件的复杂性,INSTANCE初始化时机是无法得到有效控制的。一种结合懒求值模式的实现能够把INSTANCE初始化从类初始化的过程中分离出来:

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
    private static Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

在多线程场景下,上述实现无法保证Singleton只初始化一次。一种解决办法是把getInstance方法声明为synchronized,但会显著影响程序运行效率。另一种办法是采用双重检查锁(Double-checked locking)模式,这时需要把instance变量声明为volatile以保证可见性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class Singleton {
    private static volatile Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

事实上,如果充分利用Java的类初始化的原理,则可以借助按需初始化持有者(Initialization-on-demand holder)模式实现更高效的懒求值单例模式:

1
2
3
4
5
6
7
8
9
10
11
public final class Singleton {
    private Singleton() {}

    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

上述结合多种模式的实现,看似解决了单例模式在Java并发程序中存在的问题,但因其较复杂的代码实现而影响了程序的可理解性。

单例模式的另一个复杂性在于其向系统中引入了全局状态特性,后者被公认为是影响软件质量和可维护性的重要反模式之一。因此,具有可变特性的单例模式实现应受到特别关注。而如果单例模式具有不可变特性,那么可以考虑采用无状态的工具类(Utility class)模式,即其携带的所有方法均为静态方法,且无法被用于创建任何实例,例如经典的工具类java.lang.Math。与单例类模式通常会引入额外复杂性相反,工具类模式有时会存在过度简化的问题,因为后者消除了抽象层级存在的可能,使得每次对工具类的引用都造成强依赖关系,即“不够OO”。因此,在采用工具类模式前,应充分理解所在领域与系统核心领域之间的关系及其作用。此外,无论是单例模式还是工具类模式,都可能会影响具体实现代码的可测试性,这点对于Java语言尤为如此。

虽然单例模式使OO在行为上更加接近真实世界,但其可能会引入一系列负面影响。尽管有针对性的模式试图消除这些影响,但始终难以避免引发新的问题,最终仍有可能得不偿失。因此,采用普通类始终是解决前述问题的首选替代方案。

工厂模式(Factory Patterns)

工厂模式是指一系列模式,这些模式被用于把实例的创建过程从现有应用逻辑中分离出来,从而更好地管理复杂性。例如,一个Color类可能包含如下构造函数:

1
2
3
Color(String rgb) {//...}
Color(int red, int green, int blue) {//...}
Color(int hex) {//...}

注意上述构造函数的参数其实相当于Color的不同表示形式,这种构造过程更像是在进行类型转换,采用静态工厂方法(Static factory method)可以令其更加表意:

1
2
3
static from(String rgb) {//...}
static from(int red, int green, int blue) {//...}
static from(int hex) {//...}

上述类型转换也可以支持聚合类型,例如java.util.EnumSet的of方法:

1
static <E extends Enum<E>> EnumSet<E> of(E first, E... rest) {//...}

静态工厂方法也支持创建不属于当前类的实例,例如java.util.Arrays和java.nio.Files等工具类:

1
2
static <T> List<T> asList(T... a) {//...}
static BufferedReader newBufferedReader(Path path) {//...}

除了上述语法糖特性外,静态工厂方法还允许对实例进行缓存,从而控制新实例创建,例如单例模式中的静态方法getInstance。另外,还可以通过设置输入参数返回不同子类型的实例,例如java.util.EnumSet,该抽象类并不提供公共构造函数,而是通过静态工厂方法对输入数据进行分类,按照输入的元素数量自动选择RegularEnumSet或是JumboEnumSet的子类实现。

静态工厂方法是对Java语言中new运算符的替代方案,由于其更好的表意性,该方法通常被用于为编程框架提供统一界面。但是如果某个类只提供静态工厂方法,也就意味着该类无法被正确继承,从而限制OO的抽象类型特性。Java语言中采用静态方法也会影响代码的可测试性。

基于OO的代码框架设计通常采用抽象类或接口表示对象及其相互关系,有时需要负责提供对象创建的功能,但在抽象层级无法确定具体类型。因此先定义抽象的对象创建方法,然后在子类中进行具体实现,该模式被称作工厂方法(Factory method):

Factory method pattern

虽然工厂方法通过采用抽象类型延后了对象的具体类型确定。但是这也意味着始终要配合ConcreteProduct衍生出对应的ConcreteCreator,这种平行类层级结构能起到分离职责的作用,但也可能会引入过多的复杂性。

当相关的对象属性或行为随上下文变化时,Client需要根据条件创建不同的对象,于是就产生了对所有相关对象所属类的依赖。这时Client的复杂性会同时受到两个维度的影响:

  • 对象创建过程的复杂性。

  • 对象所属的抽象类型数量。

通过独立的工厂类对相关对象的创建过程进行封装,并从中抽取统一的抽象接口,这就是抽象工厂(Abstract factory)模式。其基本结构如下图所示:

Abstract factory pattern

借助抽象工厂模式,Client只需要依赖于Product和Factory的抽象类型,从而具有更好的可扩展性。当面临更复杂的场景时,抽象工厂模式就显得不够灵活,这里所说的复杂性具体存在两种情况:

  • 创建Product依赖于复杂的初始化参数列表,且具有复杂的实现过程。

  • Product具有很多类别且经常发生变化,那么AbstractFactory及其子类也会相应增多且面临频繁修改。

一种能够避免创建工厂类且同样能够分离对象创建过程的途径是采用原型(Prototype)模式,后者允许通过复制一个已存在的对象从而快速创建新对象。原型模式的基本结构如下图所示:

Prototype pattern

当已经存在一个原型对象时,Client就能通过调用原型对象的clone方法快速创建新对象。这里的原型对象可以采用任意方式创建,例如new运算符或者其它工厂模式。原型模式主要解决以下两个问题:

  • 要创建的对象具有复杂的初始化参数列表。

  • 在运行时才能确定要创建的对象所属类型。

Java语言中的Object类提供了默认的clone方法(浅拷贝),但对象所属的类需要包含Cloneable标记接口才能调用和覆盖Object中的clone方法(例如实现深拷贝)。然而无论是实现浅拷贝还是深拷贝,clone方法都会为对象实现引入额外的复杂度,特别是当对象拥有较深的类层级结构或嵌套引用时,如何保证clone方法的正确性和一致性会遭遇挑战。

原型模式可以看作是对其它工厂模式的补充,其有效性依赖于已经存在的原型对象,因此存在较严格的适用场景。相比之下,建造者(Builder)模式则是一种通用性更强、更加灵活的对象创建模式。与抽象工厂通过实例方法直接创建对象不同,建造者模式把复杂对象的创建过程划分为相互独立的子过程,最后通过调用build方法生成所需的完整对象。该模式具有如下结构:

Builder pattern

建造者模式允许更加可控的对象创建过程,而非传统上通过构造函数或setter方法准备对象所需的输入,因此具有更加表意、灵活且一致的优点。然而与抽象工厂类似,当具有许多不同类型的Product时,就需要创建对应的ConcreteBuilder,从而可能引入额外的复杂性。

依赖注入模式(Dependency Injection Pattern)

借助工厂模式,Client能够方便地创建所需的对象。但无论是工厂方法还是抽象工厂,目标对象始终是由Client通过直接或间接方法调用而创建的,因此两者之间依然存在较强的依赖关系。而在多数情况下,Client只关心依赖对象所提供的相关特性,而非对象的创建过程。另一方面,由于语言自身条件的制约(例如Java),单元测试难以利用stub/mock技术隔离目标代码内部动态创建的对象,导致单元测试的高实现成本和低运行效率。为了解决前述问题,可以把创建对象的职责从Client彻底分离出来,即在外部完成对象创建,然后依照Client的实际需求“注入”相关依赖,从而使Client能够更聚焦于自身功能特性,这种模式被称作依赖注入(DI)。下图进一步解释了该模式的结构和原理:

Dependency injection pattern

构建依赖对象的过程被称作装配(Assembling),通常是由注入器(Injector)负责装配过程,其中包括向Client注入依赖。一种注入器的实现模式是服务定位器(Service locator),在该模式中Client获取任何外部依赖都需要通过服务定位器进行查询,后者相当于保存相关依赖的注册表服务。其基本结构如下图所示:

Service locator pattern

虽然采用服务定位器的Client不再关心依赖对象的创建,但前两者之间仍存在强依赖关系,同时由于服务定位器的实现通常是基于单例和静态工厂方法,因此可能成为并发条件下的系统瓶颈,也欠缺可测试性。彻底实现Client与依赖获取分离的方式是依赖注入容器模式,该方法通过Client无关的代码或配置文件实现对象装配,然后将其与Client中声明的依赖相互关联以便注入。Client声明所需依赖和实现注入点的方式有三种:

  • 构造函数注入,即在Client进行初始化的过程中注入依赖。该方法有助于维护Client内部状态的一致性,也让Client的依赖项更易于被理解,这是最常见的一种DI实现方式。

  • setter注入,即当Client完成初始化后,通过其提供的setter方法注入依赖。当Client存在很多依赖项时,单纯依赖构造函数注入会面临长参数列表的问题,同时如果Client存在多种依赖组合状态,就需要相应数量的构造函数,相比之下setter注入就更加灵活。

  • 接口注入,与setter注入类似,区别在于setter方法来自于独立的接口,并由Client实现相关接口的方法。该方法的优势在于外部的注入代码可以完全忽略Client的具体类型,仅通过相关接口类型的引用向其注入所需依赖。

DI能够最大化分离依赖的创建和实际应用,从而实现显著的模块解耦。由于对象装配完全由注入器提供的机制负责,因此这里往往是DI最核心和最复杂的部分。许多基于动态特性的DI实现也使静态对象追踪更加困难,进而可能增加软件的维护成本。

组件依赖模式(Component Dependency Patterns)

软件开发的过程中经常存在独立的软件单元,例如被独立开发的类(如类库),或是包含一组类的子系统,亦或是相互独立的对象。组件通常具有独立的设计和演化规则,因此不同组件对外提供的接口也不尽相同。实现组件间依赖的一般做法是引用目标组件的原始接口,但这会造成组件间的强耦合,当需要替换某些组件、或者对依赖过程实现扩展时会产生较高代价。组件依赖模式就是为了解决此类问题的一组设计模式。

一种解决组件间依赖的模式是适配器(Adapter),也称包装(Wrapper)。该模式会额外创建一个Wrapper类包装目标组件,同时遵循面向Client的一致性接口。具体的包装手段有类适配器和对象适配器两种,其基本结构分别如下图所示:

Adapter pattern

类适配器具有更加简洁和OO的外观,但也存在一些潜在的限制。首先类适配器只能包装具体类而非接口,因为后者没有可供复用的具体实现。其次继承通常具有侵入性,仅以代码复用为目的的继承可能会破坏原有类的封装能力,从而损害了OO的内核。对象适配器则与之相反,虽然可能需要额外的对象创建和连接操作,但不存在类适配器的问题,因此更容易应对复杂性。

适配器模式主要解决依赖已有类的问题,但类似的结构也可被用于正在开发的类,有时因为希望保持接口和实现相对独立。传统上接口是作为与Client间的契约先于实现而确定的,但是当接口本身也具有一定复杂性时,就可能需要接口和实现分别独立演化,以便必要时还能切换其它替代实现,这种模式被称作桥接(Bridge)。其基本结构如下图所示:

Bridge pattern

桥接模式具有和对象适配器相似的结构,但要注意其根本动机的不同。桥接模式支持从原有具体类中分离出承担接口职责的部分,从而应对接口和实现同时可能发生快速变更的情况。而适配器是在目标类稳定的情况下利用额外的接口包装以实现代码快速复用。

当目标类的接口满足Client需求,但在具体应用时需要引入额外的面向切面(Aspect oriented)功能时(例如访问控制、日志记录等),可以创建与其拥有相同接口的代理类,通过类似对象适配器的结构对目标类的对象进行包装,这就是代理(Proxy)模式。该模式的基本结构如下:

Proxy pattern

如果一个已有的子系统由若干接口和类组成,且其内部具有复杂的结构关系。为了提高子系统的易用性,可以额外维护一个统一的对外接口类,将系统特性通过简单的接口定义向外部发布,即外观(Facade)模式。该模式的基本结构如下:

Facade pattern

与其它模式关注类之间的依赖问题不同,外观模式主要解决子系统间的依赖问题,但其解决问题的基本思路是一致的。有时对象间的依赖也会存在问题,例如可能会生成庞大数量的目标对象,使系统资源过度消耗。当大量目标对象中包含许多重复信息时,一种解决方法是通过创建共享对象以减少资源总消耗,这种模式称作享元(Flyweight)。享元模式具有如下基本结构:

Flyweight pattern

为了实现目标对象共享,可以采用抽象工厂或工厂方法生产被Client依赖的享元对象,当已有的享元对象满足共享条件时,系统会直接返回该对象而非重新创建。享元对象相当于对目标对象的重新包装,但这并不意味着享元对象是一定可以被复用的,例如当目标对象包含特定的外部状态信息时,就需要专门再创建一个非共享享元对象保存这些外部状态信息,并且在对应的共享享元对象中保存对其引用。

增量扩展模式(Incremental Extension Patterns)

OO的重要特性之一是通过继承实现增量式扩展,这种特性能帮助我们有效管理系统复杂性。但是正如面向对象——概念与建模一文中提到的,继承除了提供模块能力外还兼有类型概念,这种概念兼有利弊,特别是令继承的适用场景受到一定限制,不合适的继承非但不能有效管理复杂性,反而会带来更多问题,此时就需要考虑用组合替代继承实现模块扩展,这就是组合优于继承(Composition over inheritance)长期占据主流观点的原因。接下来要讨论的设计模式或采用继承或采用组合的方法来解决增量扩展的问题。

当系统需要同时维护很多对象时,维护增量式扩展的核心问题之一是如何管理每个对象的定义及其相互间的关系。当所有对象间关系呈现为树形或层级结构、并且Client与这些对象交互存在趋向一致的外部接口时,可以采用组合(Composite)模式。该模式为系统中的对象提供了统一的外部接口,作为非叶节点的对象可以包含并将同类型的对象作为其子节点。组合模式的基本结构如下图所示:

Composite pattern

组合模式所提供的增量扩展能力在于可以通过实现Component接口任意添加新的节点类型,虽然是等同于继承的扩展方式,但除了具有树形或层级结构这种特殊的对象间关系外,多数时候组合模式并不能适用。相比之下,聚合是一种更加普遍的对象间关系,其中聚合对象可以包含若干个不同类型的局部对象,在实践中聚合关系往往是通过对象间的组合实现的,这被称作整体和局部(Whole-Part)模式。下图给出了该模式的基本结构:

Whole-part pattern

与组合模式最大的区别在于,该模式并不要求“整体”和“局部”对象具有统一的访问接口,通常只有整体对象对外提供服务,而局部对象具有多种类型且一般不会向外界暴露。类似的结构也存在于主从(Master-slave)模式,该模式描述的对象间关系一般只有两层——主对象和从对象,其基本结构如下图所示:

Master-slave pattern

其中主对象可以按需把接收到的工作分解并指派给若干从对象,最终再汇总从对象的计算结果并产生最终结果,主从模式有助于提高系统容错性、并行性和计算准确性。

有时需要实现对象的动态扩展,为了保持扩展对Client透明,需要同时借助继承的抽象类型特性保证扩展结果具有与原对象一致的接口,这就是装饰器(Decorator)模式。该模式的基本结构如下:

Decorator pattern

为了描述具体的扩展内容,首先需要创建与目标对象相同接口的装饰器对象,后者只需关心各自要扩展的功能;在对象创建阶段,用装饰器对象包装目标对象,如果存在多个装饰器对象,则继续用其包装装饰后的对象;最终生成的装饰后对象具有与目标对象完全一致的对外接口,因此装饰器模式能够最大限度地保持Client不变。

如果一个上下文需要在不同状态中表现出不同行为,可以把这些状态对应的行为单独抽取出来,利用组合关系使其行为能够被动态替换,这就是状态(State)模式。与装饰器模式类似,状态模式也是一种同时结合了继承和组合的增量扩展模式,该模式的基本结构如下:

State pattern

除了内部状态发生改变之外,上下文行为还可能会因为其它因素产生变化,例如需要选择不同的算法策略,此时可以采用与状态模式具有几乎相同结构的策略(Strategy)模式:

Strategy pattern

状态模式和策略模式都属于纵向增量扩展模式,即每次扩展相当于对原有功能进行完整替换,这种扩展方式具有较高的灵活度。在某些场景中,上下文行为存在相对固定的套路,延续这种套路虽然可能会引入某些限制,但使后续扩展的关注点更加明确,从而提高增量式扩展的效率。模板方法(Template method)模式就是这类横向增量扩展模式的典型代表,其基本结构如下:

Template method pattern

该模式中提供的模板方法就是一个预置的算法框架,任何扩展只需替换其中预设的子步骤。另一种预置了算法框架的模式是迭代器(Iterator),该模式封装了数据结构的基本操作,从而令Client更加关注在具体元素的操作。其基本结构如下:

Iterator pattern

在前述模式的基础上,可以进一步抽象出元素操作,从而满足数据结构及其算法、数据结构中的元素、元素对应的操作的独立扩展,这就是访问者(Visitor)模式。该模式的基本结构如下:

Visitor pattern

由于实现了最大限度的抽象化,访问者模式同时在上述三个维度上提供了增量扩展能力,但同时也引入了较高的复杂度,特别是当元素及其操作分别属于不同的类层级结构,但其相互之间仍然存在着强耦合关系,导致代码的可维护性受到损害。

消息模式(Messaging Patterns)

OO中的对象间通信主要通过消息传递进行,最直接的消息传递方式就是调用目标对象的方法。消息模式主要用于解决下述几个方面的复杂性:

  • 消息的发送者和接收者。

  • 消息的表示和传递。

  • 消息的存储和管理。

当接收者的消息处理过程比较复杂,例如具有层次式的处理结构时(类似多层嵌套的条件分支),可以把其中每一层的处理逻辑抽取出来构建独立对象,然后将原始的消息接收者替换为一组对象链,即责任链(Chain of responsibility)模式。该模式的基本结构如下:

Chain of responsibility pattern

在责任链模式中,原来的消息接收者被划分成多个职责独立的对象,消息由一次处理变为沿着责任链进行传递并被多个对象分别处理。这种变化降低了原始代码的圈复杂度,而且方便复用现有对象组建新的责任链。

对于作为消息发送者的对象,把待发送的消息和接收者从中抽取出来构建独立对象,一方面可以降低发送者的复杂度,满足消息类型的增量扩展;另一方面可以灵活控制消息的发送时机。这种模式被称作命令(Command)模式。其基本结构如下所示:

Command pattern

上图中的Invoker扮演消息发送者的角色,Receiver则是消息的接收者,Client负责创建命令对象并将其和接收者关联。命令模式中的命令对象是不可变的,这种特性使命令对象能够被重复使用,但无法保存状态。另一种相似的模式——命令处理器(Command processor)允许创建具有可变状态的命令对象,其代价是扮演Invoker的命令处理器需要每次从Client获取新建的命令对象,其基本结构如下所示:

Command processor pattern

如果一次消息发送对应了多个接收者,并且接收者还可能会发生动态增加或减少,那么在每个接收者上实现观察者接口,然后将其动态注册至消息的发送者上,当消息发送时会依次发送至每个已注册的接收者。这就是观察者(Observer)模式。该模式的基本结构如下:

Observer pattern

观察者是一种消息传递模式,特别是当存在一对多的消息传递关系时,应用观察者模式能够实现消息发送者和接收者的解耦,并且支持动态的接收者增加和减少特性。观察者模式有时也被称作发布者-订阅者(Publisher-subscriber)模式,但后者有时存在更多变种。例如传统的观察者模式主要依赖消息推送(Push),所有观察者都被动接收消息。但是发布者-订阅者模式中还支持消息拉取(Pull),这时发布者只会发送很简单的变更通知,由订阅者决定是否读取该消息。消息拉取是一种更加灵活的消息传递方式,特别是当消息量可能超出了接收者的承受能力时,拉取实际上对接收者起到了保护作用。如果不存在消息过载的情况,那么采用推送则更加简单且实时。

如果系统中存在许多对象,并且这些对象间大都存在着某种消息传递关系时,可以创建一个中介对象负责接收并转发对象发送的消息,即中介者(Mediator)模式。该模式的基本结构如下:

Mediator pattern

与观察者模式类似,中介者模式能够解决多对多的对象间消息传递和动态增减问题。但是该模式会增加获取消息传递路径和参与双方信息的难度,使某些重要的领域逻辑无法在代码中得到清晰呈现,进而间接提高维护成本。因此当消息传递包含关键领域逻辑时,应避免采用中介者模式,当然最终的设计和实现可能会更加复杂,以视图处理器(View handler)模式为例:

View handler pattern

该模式用于解决多文档窗口管理的问题。多文档窗口通常存在于Word等文本编辑工具,其特点是系统可以同时打开多个窗口,而且每个窗口可以指向任意文档的内容。该问题的复杂性在于每个窗口中的操作可能会影响其它窗口的显示结果,同时当用户点击退出时系统需要针对每个打开的文档向其询问是否要保存已修改的内容。视图处理器模式定义了系统中存在的三种对象:

  • 视图处理器,负责管理所有视图,以及对外提供针对视图的操作。

  • 视图,负责保存当前视图的内部状态,向视图处理器提供基本操作。

  • 供应者,负责保存数据,并且向其观察者发送数据更新消息。

该模式的对象间的消息传递存在两种情况:

  • 当视图内的数据发生修改,该修改被反馈给供应者,然后把更新后的状态通过观察者模式发送给视图处理器和其它相关视图。

  • 外部触发系统调用视图控制器提供的接口,然后把相关更新依次传递到每个视图。

当多个对象间消息传递发生在跨进程的对等网络中时,消息传递需要先后经过序列化和反序列化,这时可以采用转发者-接收者(Forwarder-receiver)模式。

Forwarder-receiver pattern

在该模式下,消息发送者需要首先把消息传递给转发者;后者进行序列化和寻址,然后把消息传递至接收者;接收者接到消息后先进行反序列化,然后把消息返回给接收对象。有时需要保持网络地址对消息双方透明,从而实现信道自动调度,此时可以采用客户端-调度器-服务器模式。该模式的基本结构如下:

Client dispatcher server pattern

在处理相关消息时,有时会导致接收者的内部状态发生改变,但消息的发送者可能要求撤销某些消息处理,其实质是恢复消息接收者的状态到历史的某个时刻。一种简单的做法是由消息接收者返回当前内部状态,以备忘录对象的形式在外部进行保存。当回退需求发生时,发送者向接收者发送备忘录对象并要求恢复至指定状态。这种模式被称为备忘录(Memento),其基本结构如下:

Memento pattern

结论

作为软件设计的最佳实践,设计模式从诞生之初就受到了工业界和学术界的热捧,并把OO进一步推向了金字塔顶端。近年来随着软件开发框架和工具链的日益完善,设计模式在日常应用开发中逐渐退居幕后,成为新技术背后的驱动力。但是,作为软件工业化的必经之路,新模式的发现及传播不可能由此中断。对于任何一种模式,了解其场景和结构固然重要,但深入理解模式的动机和陷阱才意味着做到了正确理解,才能真正将其作为软件设计的最佳实践。

引用

GHJV95, Design Patterns: Elements of Reusable Object-Oriented Software

BMRSS96, Pattern-Oriented Software Architecture Volume 1: A System of Patterns

Comments

软件设计与架构笔记(10)

面向模式的领域分析与建模

前文讨论了OOA/OOD的基本概念、工具和方法。我们已经知道,当面临稍复杂的问题域或特殊的上下文时,朴素方法会面临许多挑战,使软件开发风险难以得到有效控制。模式的发现和应用为工业界带来了高效的解决方案,也因此成为甚至比朴素方法更易流行的方法论,即面向模式(Pattern oriented)。本文是对前文OOA部分的进一步补充和扩展,首先讨论领域分析(Domain analysis),及其与数据建模和OOA的关系,然后深入讨论面向对象分析模式,这些模式大多采用领域特定的概念模型(也称分析模型)进行描述,与纯粹抽象的表达方式相比更易理解,然而其模型结构和分析过程可被应用至更广泛的领域。

领域分析

领域分析是指为了满足跨组件的可复用性,对软件开发信息进行识别、收集、组织的过程[RPD90]。当相似的问题域重现时,期待所构建的软件能够尽可能被复用,从而提高软件的经济效益,这是领域分析的根本动机。因此,相对于可复用的代码,领域分析主要关注于分析和设计的可复用性,这通常反映为组件级的可复用性[JMN80]。

在软件工程的语境下,领域是指所构建软件系统的实际应用场景。具体来说,一个领域既可以大至银行业务,也能小至算术运算。一个领域还能被分解成多个子领域,因此复杂的领域可被看成是一种层级的网状结构,层级越高则意味着领域的复杂度也就越高。领域边界用于描述领域的范围,定义了每个领域中的对象、操作以及相互之间的关系。组件是指一个独立的软件单位,这种独立性可以指独立开发、独立部署或独立运维等特性,并且组件之间还能通过某种协议进行通信。领域是业务单位(例如对应不同的业务部门),组件则是工程单位(例如对应不同的项目或开发团队),但二者最终存在一定的映射关系。虽然领域分析有比较明确的目标,但具体的实现方法属于非平凡问题。例如如何确定领域层级、如何确定领域边界以及如何建立与组件之间的映射关系等。

领域分析的框架模型

[RPD90]描述了领域分析的结构化上下文视图(SADT Context View),如下图所示:

Domain analysis

在上述模型看来,领域分析实际上是一种依赖多种输入且高度参数化的框架,其最终产出也可根据参数进行定制。例如,当核心是某个产品而非技术时,领域分析主要关注战略一致性、市场策略、产品定位、风险分析、日常外观等一系列产品特性,这时需要用到通用术语调研、逻辑架构、可靠性标准等方法——这种领域分析活动被称为产品定义领域分析(Product definition domain analysis)。当有明确的证据支持当前产品流时,即可进一步就具体产品展开分析,这时往往是概念性领域分析,该活动被称为需求领域分析(Requirement domain analysis)。当表示产品线的领域趋于稳定时,就可以考虑构建特定领域的生成器,从而满足高效构建新产品的需求,此过程被称作生成器领域分析(Generator domain analysis)。

领域分析、数据建模、OOA

我们在数据模型与数据建模一文曾讨论过数据建模。从上世纪80年代初期开始,由于关系模型和关系数据库系统的流行,信息系统的构建往往都强依赖于数据库系统。为了保证概念模型能直接用于后续设计和实现,数据建模、特别是概念数据建模成为需求领域分析活动的主要内容之一。其中,实体-关系建模(ER modeling)是数据建模中最具代表性的方法,其分析结果即ER图,后者往往被直接用于数据库设计甚至应用构建[DCH96]。虽然数据建模也重视对真实世界的反映,但其更侧重于表现状态而非过程,而事实上两者对于软件开发同样重要,这也是兼具二者的OOA后来居上的原因。而OO天然具有的抽象层级抽象边界等概念,与前文提到的领域和领域边界的概念能够一一对应,且其最终得到的领域概念和对象,亦即需求领域分析的目标。

因此,数据建模和OOA都是领域分析采用相应建模技术的具体实现。在领域分析方法的发展过程中,二者都扮演了重要角色。虽然至今领域分析方法都在不断演进,但从已有经验总结得出的分析模式,能够为常规的领域分析活动提供可靠的候选参考。

面向对象分析模式

概念模型是理解和模拟真实世界的基础,但由于后者的复杂性通常都超乎想象,导致概念模型基本不存在正确与否的问题。对此Martin Fowler用模拟斯诺克的例子解释:当需要对球的运动进行建模时,经典牛顿力学和爱因斯坦的狭义相对论都可以作为候选模型,前者非常直观且易于实现,后者实现复杂但具备更好的适应性和精确度。显然,最终生成软件的灵活性和可重用性强烈依赖于所采用的概念模型。但除了个别质量属性外,软件工程中还存在更多的权衡因子,一个典型的例子是可维护性和成本之间的矛盾。一种平衡前述因子的有效办法是构建极简概念模型(Simplest conceptual model)——这并非一个容易达成的目标,因为“极简”不是指问题域中最简单的一部分,而是指在更高抽象层级构建概念模型,其内核虽然是“极简”的,却因为良好的伸缩性得以适应更复杂的问题域。在[MFR96]中,Martin用分析模式表示用于构建概念模型的模式,随之用支持模式表示将概念模型转化为OOD的模式。前者是下面要讨论的重点,后者属于OOD的范畴,本文不做专门讨论。

  • 职责模式(Accountability),该模式用于表示个人或者组织之间的职责。在一些问题域中经常需要表示相关职责,例如组织结构、合约、雇佣等,这些都无一例外体现为某种真实对象之间的关系。这里我们把个人、组织这种具体领域的词汇统称为更加抽象的词——实体。当不同的实体表现出趋同的职责时,应考虑构建一个更高抽象层级的实体。如下图所示:

Generalized party

当不同实体之间存在灵活的组织层级关系时,可以从中提取抽象实体,通过递归引用构建这种层级关系,从而易于应对层级变化。如下图所示:

Organization hierarchy

当实体的子类型间的层级关系存在某种约束时,可以把其添加至相应的子类型内部,从而避免改变模型结构。此外,实体间还可能存在多个层级关系,可以把上述显式引用的关系替换为关系类型(Typed relationship),后者被用于表示实体间组织结构的对象类型,实体通过关联的多个结构对象实现不同的层级关系。同样,实体的结构类型也可以关联相应的约束规则,如下图所示:

Multiple organization hierarchies

上述方法的优势是如果实体的关系类型比较复杂,约束规则可以对应至每种关系类型进行管理。问题在于,如果实体的子类型变化比关系类型更多,那么任何子类型的增删改都会影响现有规则,这时可以把规则关联到每个实体的子类型,而非关系类型。

显然此时模型的复杂度已经有了明显增加,为了方便分析这种复杂性,把当前模型划分成运行等级(Operational level)和知识等级(Knowledge level)两个部分,如下图所示:

Knowledge and operational levels of accountability

其中运行等级包含职责、实体和两者的关系,知识等级包含职责类型、实体类型和两者的关系。前者用于记录领域中发生的日常事件,后者记录用于监管前者的规则。

另外,实体类型也可能具有抽象层级,这样就能描述更加复杂的场景。例如对于全科医生和专科医生来说,两种类型既具有共性也具有特殊性,因此可以创建一个更加抽象的层级——医生来管理其共性。

职责类型也具有抽象层级,随着职责类型不断增加,相应规则数量也会随之增加。但是,如果职责类型表现为固定且简单的等级关系,例如常见的办公室、部门、区域等,那么可以采用分级职责类型,即直接在职责类型对象内存储静态等级列表和相应规则。后者比传统按抽象层级创建新的职责类型相比更加简单,但缺少灵活性。

职责类型描述职责的种类,但一个具体的职责通常包含更多细节信息,例如地点、数量、内容等,这里称为运行范围(Operating scope)。运行范围也可能具有复杂的抽象层级和关联关系,如下图所示:

Operating scope

运行范围的一个典型用例是职位描述(Job description),也就意味着职位描述是与工作职责相互关联的,而非具体的员工实体,这也更加符合真实世界的场景。假设具有某项职责的员工离职,那么其承担的职责应被移交给接替者,可以在此基础上创建一个新的职位(Post)实体,于是就避免了职责与员工的直接关联,而是让两者通过职位间接产生关联,从而实现更好的灵活性。

  • 观察与测量模式(Observations and measurements),该模式用于表示真实世界对象的信息。以数值类属性为例,体重是人的基本属性之一,一般用数值类型存储。然而如果领域要求更精确一些,数值类属性还需要额外的单位信息,这时就需要构建抽象数据类型,这里称为Quantity。几乎一定会用到Quantity类型的领域包括财务(货币单位)、医疗(剂量单位)等。

Quantity通常意味着单位转换的问题。一个简单的单位转换率模型如下图所示:

Unit conversion

如果问题域中包含大量的转换率,可以把单位用量纲表示,这样就可以动态计算单位之间的转换率。对于更复杂的单位转换需求,例如摄氏和华氏这两个温度单位,就需要引入携带计算行为的单位转换模型。在真实世界中,一种物理量的单位经常需要用一些基本单位组合表示,例如平方英尺、米每秒等,这时就需要引入复合单位,即引用其它单位的单位。

测量是一种表示实体中数值属性的抽象数据类型。当一个实体具有大量属性、且这些属性分别用于不同操作时,实体中就可能存在大量针对这些属性的操作。测量的目的就是抽出这些操作,并在此基础上分离出位于知识等级层面的现象类型(Phenomenon type)。如下图所示:

Measurement

实体还可能包含非数值属性,与测量类似,可以采用独立的抽象数据类型表示非数值属性,二者可以共同提取出更高的抽象层级——观察。观察中所包含的规则、解释和创建方式等可以进一步提取出来,构成协议。观察往往具有一定的时效性,即观察值仅当满足特定时间段时才有效,可以通过关联时间对象解决。在一些问题域中,观察值可能被发现是无效的,但基于可追溯性无法删除已有的观察值,因此通常是给原有观察关联一个新的不合格观察。例如在医疗领域中,患者的检测报告有可能因为各种原因被识别为无效,但仍需要保留历史记录以便追踪治疗历史。医生会根据检测结果诊断病因,有时会因为某种测量结果而判断病因,也可能因后续测量结果调整治疗方案。为了表示观察值所带来的影响,可以将其划分为三种子类型:假设、投影和活动观察。其中活动观察指当前最被信任的观察,假设指需要进一步测试才能确定的观察,投影指医生认为未来可能出现的观察。

观察是可以相互关联并产生影响的,这种情况被称为关联观察。如下图所示:

Associated observation

  • 引用对象(Referring to objects),这是一种表示对象间引用关系的模式。真实世界的对象大都存在交互,OO首先要解决的问题就是引用对象,这通常是基于一个显式的身份标识。最简单的对象引用方式就是按名字引用其它对象,具体可以是对象名,也可以是对象id。

当对象可能在不同场景中具有多种的标识时,简单的名字引用就难以应对各类场景中的特定对象。例如一个学生可能同时有身份证号、学号、甚至准考证号等标识,此时可以采用标识对象表示引用关系,描述场景的对象被称作标识模式,这是一种具有上下文信息的人工标识方法。如下图所示:

Identification scheme

  • 库存与会计(Inventory and accounting),一种跟踪企业资金流动的模式,主要用于财务会计、库存和资源管理等领域。通常用账户表示具有实际价值及其历史记录的实体,例如银行账户、库存账户等。对于账户中的每两条历史记录(指包含存和取的“两腿交易”),都能够对应一个交易,后者用于记录价值流动的细节。如果问题域存在多腿交易,例如一次交易超过了两个参与方,则允许一个交易引用多条历史记录。从模型的角度看,两腿交易属于多腿交易的一种特殊情况,因此后者的灵活性更好。如下图所示:

Multilegged transaction

如果同时拥有多个账户,那么通常会需要额外创建一种总结账户,后者被用于汇总其它细节账户,因此这里可以抽取出账户的一个更高抽象层级表示。在税务领域中还存在另一种账户类型——备忘录账户,这种账户并非记录当前的实际价值,而是额外存放一份“应税价值”,优点是能实时监测某种价值的累积和变更情况,从而为领域添加更好的可预测性,例如方便进行年度报税。为了保证备忘录账户能够被及时更新,通常在账户中会创建一个触发器,每当一个新的记录被创建,则根据触发规则(Posting rule)创建另一条记录,后者即应税价值。如果创建新记录需要比较复杂的计算,则需要给触发规则关联一个额外的计算对象。值得注意的是,触发规则通常是可逆的,同时由于规则本身包含了交易细节,备忘录账户中的历史记录也就无须保存相应的交易记录。

不同的账户可能具有不同的触发规则,一种方法是把触发规则与账户类型相关联,如下图所示:

Posting rule

该模型体现了良好的概念表示,实则兼有利弊。因为其基本假设是账户类型与触发规则应存在某种强关联性,否则反而会使情况更加复杂。一个替代做法是直接把触发规则关联至某个总结账户,这样灵活性就更好,但在概念表现上则比较隐晦。

会计的实质就是在触发规则的基础上,构建一个多账户网络。这就需要能随时获取指定类型的账户和指定类型的触发规则记录。一般情况下,如果实体具有多个账户,那么该实体会附带一个相应的会计操作。如果问题域中具有极其复杂的会计操作,例如不同类型的触发规则对应不同的会计操作,那么可以通过添加会计操作类型的概念对逻辑进行合理分配。如下图所示:

Accounting practice

基于上述模型的历史记录具有更强的追溯性,例如每条记录可以查找对应的源——交易,然后根据交易查询其触发规则。同时还能根据问题域创建对应的资产负债表(Balance sheet)账户和损益表(Income statement)账户,前者记录当前实际价值,例如资产总额;后者则记录每笔交易的具体流向。这两种账户虽然具有不完全相同的历史记录,但实际是一个概念的不同方面,被称作相关账户。相关性可能存在于多个账户之间,且具有对称性和传递性的特征。

针对一条记录可能存在于多个账户的场景,还需要注意账户间关联应遵循有向无环图(Directed acyclic graph, DAG),从而保证正确性。如果问题域要求更多的记录类型,可以采用前文讨论的备忘录账户,也可以创建子类型账户。前者具有简单的模型,适合满足简单的报表类需求,对于更加复杂的场景,就需要采用后者负责针对特定记录类型的操作。

  • 计划(Planning),该模式用于表示计划,包括制定和追踪两部分内容。任何计划都可以被看作由若干基本动作(Action)组成,与计划强调整体性不同的是,动作可以拥有不同的粒度,一个简单的动作可以包含人物、时间和地点等信息。

动作通常是状态化的,也可能因为不同状态表现出不同的行为,其中最重要的两个状态是建议实施,前者是指动作已经被创建,并且可能已经被赋予了某些资源;后者指动作已经开始执行。虽然同时指向一个真实世界的动作,但是建议和实施实际上可能具有很大的区别,因此,通常不使用关联的动作值/对象表示这两种状态,而是通过创建动作的两个子类型做彻底区分,同时同一个动作的两个子类型之间保持关联,如下图所示:

Proposed and implemented action

类似地,如果某个动作处于完成状态,但由于完成的动作可能仍需要追溯性,通常可以将其作为实施动作的一个特例。在某些问题域中需要丢弃已经完成的动作,于是需要丢弃状态,丢弃也可能是在实施之前发生,因此丢弃动作应当是独立于建议和实施的动作。如果问题域允许暂停一个动作,那么就需要给动作关联一个暂停对象,该对象携带一个有效期信息,当时间位于该有效期内时,动作将被挂起直到暂停有效期结束。

最基本的计划即一组建议动作的有序集合,这种顺序需要给动作添加依赖保证。当具有多个计划时,一个动作也可能同时位于多个计划中。如果动作允许嵌套,那么计划也可以被看作是一种特殊的动作。

通常企业的运行过程可以用通用的动作进行表示,其中动作和计划位于运行等级,我们把位于知识等级的部分称作协议,其结构如下图所示:

Structure for protocol

动作通常是基于协议创建的,最简单的方法是根据协议创建关联的建议动作,也可以在更高层级创建等同于计划的建议动作。如下图所示:

Relationship of plan, action and protocol

我们知道动作在实施前需要分配资源。资源可以被划分为两种基本类型,例如药品、针头、源材料等属于消费型资源,又如设备、房间、人等属于资产型资源。每种资源类型都具备对应的分配方式,这些分配方式具有统一的资源分配接口。

计划是通过观察启动的,后者是基于假设和投影的结果。因此计划的输出结果也应该是观察的形式。与计划类似,观察也是动作的一种子类型,并且可以作为计划的一部分。前述两种观察在知识等级可以采用启动函数和输出函数表示,前者用于触发协议,后者用于把输入的协议和观察组合进行转换并以两个观察概念集合输出:一个表示协议的使用目标,另一个记录边际效应。

  • 交易(Trading),指在变化的市场条件下销售和购买商品的模式。合约是最基本的交易形式,具体的实现手段包括股票、商品、外汇等。例如大多数市场都以货币为交易手段,于是交易价格就以货币方式表示。交易的两种类型做多(Long)和做空(Short)分别代表买入和卖出,一个基本的交易模型如下图所示:

Contract

银行的风控系统通常以交易组合(Portfolio)为单位监控异常交易,交易组合一般拥有采用合约构建其自身的方法,该方法通过合约选择器筛选出合约,并保存在自身记录中。交易组合可以是短期或长期的,短期交易组合是按需创建,使用完成后丢弃;而长期交易组合能存在较长一段时间,因此当新的交易产生时,系统会将其与所有的长期交易组合进行匹配,并添加至满足条件的长期交易组合。

任何金融市场的交易都有报价(Quote),多数情况下“报价”其实包含两个值——买入价和卖出价,这被称为双向价格(Two way pricing)。相应地,在某些问题域中也存在单向价格(One way pricing),后者可以被看作是双向价格的一种特殊情况。为了同时支持这两种报价类型,需要构建一个更高抽象层次的报价类型,如下图所示:

Quote

如果问题域中单向价格和双向价格的差别非常大,那么抽象类型反而会造成很多局限性,此时就需要对两种价格类型分别建模。

市场中的报价是瞬息万变的,因此报价通常会携带时刻信息。如果想查询市场上所有股票的价格,需要抽取每只股票截至某个时刻的最后一次报价,然后把这些报价添加至一个集合——场景(Scenario),即表示市场在某一时刻的状态。从满足需求的角度来说,直接从全部报价中根据时刻抽取相应报价,即可方便获得市场上的公开价格。但场景是对某个时刻市场价格的抽象表示,可以方便后续的价格查询、分析和比较等。问题域在外汇市场变得更加复杂,由于汇率不再是由单一来源发布,因此需要额外添加一个实体作为价格发布者,如下图所示:

Party and scenario

  • 交易包(Trading package),一种把较大的领域模型拆分为更多小型领域模型的模式。通常指一个独立且具有一定规模的软件模块,例如在OO中包可以是一些类的集合,其内部可见性也表明了包的内聚性。包之间一般存在多重访问等级,例如在交易领域中,交易组合会把市场指标作为其描述,场景则为市场指标提供价格计算,交易组合与合约通过场景对自身进行定价,但反过来场景并不需要依赖交易组合或合约。这种包之间的可见性如下图所示:

Package visibility

进一步,交易组合需要从市场指标中获取价格,但并不需要了解场景是如何被创建的。因此虽然之前我们讨论了包之间存在可见性的必要性,但这种可见性必须是可控的。一种解决办法是通过额外的接口实现包之间的依赖,然后针对这些接口创建新的包,从而实现更细粒度的包可见性依赖。这种方法的缺点是包的概念和结构变得更加复杂,进而引发可维护性问题。另一种办法是在现有概念模型的基础上引入表示应用逻辑和表现层的组件,例如创建一个新的风险管理应用包和场景管理应用包,前者负责沟通交易组合与场景,后者专门负责创建场景,如下图所示:

Application package

注意在上图中还有一个新的包“场景结构”,其目的是为了保证场景包对外提供统一的接口,从而将部分职责从原始场景包中进行了分离。

如果一个包是另一个包的子类型,那么不应出现后者依赖前者的情况,否则会损害子类型的可扩展性。包有时还会出现互相依赖的问题,进而导致过高的耦合性,这时需要考虑把这种双向依赖转变为单向依赖,因为后者的耦合度更低,但也可能会增加使用上的复杂度。例如对于实体和合约来说,实体有时需要知道与之相关的合约,反之合约也需要知道参与的实体。对此如果要消除双向依赖,就必须消除某个方向的依赖关系。但是无论是消除任一种关系,都会造成某些客户端应用的复杂度增加的问题。除此之外还可以选择把两个包合并,但也可能引入不恰当的可见性问题。因此对这种互相依赖的解决需要考虑更多上下文进行综合判断。

结论

模式是对实践经验的积累和发散,真实世界的复杂性导致了模式只能被发现而不能被发明出来,也因此很难避免其在面临复杂问题域时的局限性。然而,与后续会讨论的其它类型的模式相比,分析模式其实具备更高的灵活性和适用性,特别是比形式化的模式表述更加接近真实的思维方式。

引用

RPD90, Domain analysis: introduction

JMN80, Software Construction Using Components

DCH96, Data model patterns: conventions of thought

MFR96, Analysis patterns

Comments

软件设计与架构笔记(9)

面向对象——分析与设计

分析(Analysis)与设计(Design)是软件开发过程中的两种重要活动。分析研究需求和问题本身,决定“做正确的事”;设计则侧重于提出概念性解决方案,决定“正确地做事”[CLA01]。在软件工程实践中,分析主要围绕需求进行,这里既包括功能需求,也包括非功能需求;设计则围绕具体实现进行,例如计划设计、技术设计、用户体验设计、测试设计、运维设计等。面向对象建模技术在上世纪90年代中期的兴起,使其被广泛应用于软件的分析和设计过程,逐渐成为企业级开发领域的主宰范式。

面向对象分析(OOA)

OOA的目的是识别和描述问题域中的概念和对象。问题域通常以软件需求为主要的呈现形式,其中包括了功能需求和非功能需求。前者描述面向用户的软件行为,后者则包括了其它类型的需求,例如可用性、可靠性、性能和可支持性(可维护性)等,此类软件属性也被称作软件的质量属性(Quality attributes)。功能需求可通过用例模型描绘出更多细节,非功能需求则作为前者的补充性规格说明进行呈现。如何恰当地描述用例可以进一步参考[ACB99],本文后续部分假设用例和非功能需求已经被有效地描述,因为这是进行面向对象分析的重要前提。

用例建模(Use case modeling)

当具有初步的用例描述时,可以采用用例模型对问题域进一步分析,从而整理出系统事件(System event)及其相应的输入和输出,为之后的逻辑设计建立基础。构建用例模型的过程被称为用例建模,可用的工具和方法包括用例图系统时序图系统契约等。

  • 用例图实现了对基于文本描述用例的可视化表示,使其在概念级别更易于理解和沟通,通常采用UML用例图作为工具。

  • 系统时序图与UML的时序图类似,不同之处在于前者把整个系统作为黑盒,主要描述系统的外部交互过程。这些交互可以是位于用户与系统间,也可以位于不同系统之间。在系统时序图中,系统事件是系统与外部交互的唯一方式,而为了描述系统事件则需首先确定系统边界(System boundary),即系统的功能集合。系统时序图着重于描述系统事件类型、参数、发生顺序和其它关键特性。

  • 系统契约能够进一步描述系统事件所引起的领域模型中对象的状态变化规则,相当于系统级别的设计契约,针对后者的讨论可参考下文面向对象设计一节的设计契约部分。

用例模型是需求分析的重要交付物,其实质是针对领域的过程化描述,但这种形式不足以启发后续的面向对象设计,因此需要进一步建立接近面向对象概念的领域模型,针对后者的构建方法就是接下来要讨论的领域建模

领域建模(Domain modeling)

领域模型描述了问题域中的概念类(Conceptual class),概念类与面向对象中的类有一定相似性,但与设计和实现阶段创建的类没有必然的对应关系。领域模型有时也被称作概念模型、领域对象模型或分析对象模型,其通常应包含如下信息:

  • 领域对象或概念类。

  • 概念类之间的关联关系。

  • 概念类的属性。

一个采用UML类图描述的领域模型例子如下图所示:

Domain model

领域建模首先需要识别概念类。[MO95]提出了概念类的三要素:1.符号(Symbol),用于表示概念类的文字或图像信息;2.含义(Intension),概念类的定义;3.扩展(Extension),描述所有同属于该概念类的案例集合。例如,概念类Sale应包含以下信息:1.表示概念类的符号“Sale”;2.其含义是一次购买交易的事件,包括了date和time两个附加属性;3.扩展即是所有销售的案例集合。对概念类Sale的三要素进行可视化的例子如下图所示:

Conceptual classes

在复杂的问题域中,为了识别出所有概念类,需要对领域进行实体分解。一般方法是从用例描述中的名词短语直接提取概念类,但是受限于自然语言的随意性,把任何提取出的词汇都映射为概念类极有可能出错。除了进一步澄清所提取的词汇外,还可以借助概念类分类表(Conceptual class category list),后者是对特定领域中常用概念类的一种分类形式,可用于对照用例描述中的词汇获取预设的概念类。例如在商场中,一次交易可以包含Sale或者Payment等概念,而在机场可能还涉及Reservation等概念。通过上述方法可以获取候选的概念类列表,例如在表示交易的领域模型中,我们可以提取出Store、Register和Sale等作为候选概念类列表。需知候选概念类列表并非是唯一确定的,其具体的覆盖范围应结合上下文综合考虑。

下面讨论领域建模的一般步骤。

  1. 结合上下文列出候选概念类列表。

  2. 采用领域模型对概念类进行可视化表示,即UML类图的最基本形式。

  3. 添加概念类之间的关系记录。作为领域模型的重要信息之一,关联关系有助于深入理解领域模型,下文会做进一步讨论。

  4. 添加概念类内部的属性记录。领域模型中的属性表示概念类中需要被记录的信息,其既可以是由用例描述中显式给出的,也可以是间接获取的。属性通常包含属性名和类型两个部分,其中属性类型可以是原始类型(Primitive type)或值对象(Value object)。此外,领域模型的属性应当排除外键属性的情况,后者应在设计阶段再予以考虑。

关联是指概念类之间的、持续一定时长的关系,这里主要是为了强调关联的时间概念,用于和瞬间性关系进行区别。领域模型中的关系主要包含以下两类:1.须知型关系,即用例中明确指出的关联关系,属于领域模型的核心内容;2.理解型关系,即用例未显式说明但实际有助于理解领域模型的关联关系。

与概念类的三要素类似,关联也具有三个重要组成部分,分别是关联名字(Name)、多重性(Multiplicity)和导航性(Navigability)。其中关系命名可以遵照TypeName-VerbPhrase-TypeName的规则,在UML类图中体现为由上至下、由左至右的顺序;多重性与UML类图的概念基本一致;导航性是指关联关系在其两端概念类各自的可见性,例如可以是单向关联或是双向关联。

与提取概念类相似,关联关系可以通过从用例描述中提取动词短语发现,也可以对照通用关联关系表(Common Associations List)——一种领域特定的常见关联关系分类表。虽然在真实场景中可能存在非常多的概念类间关系,领域模型中仅需要表示重要关系,具体应依用例描述和上下文而定。

一个完整的领域模型例子如下图所示:

A complete domain model

基于彩色UML的通用领域建模模式

当面对复杂问题域或特殊上下文时,朴素的领域建模方法会面临挑战。为了控制风险,常见解决办法是在分析过程中应用模式(Pattern)。模式是一种经过验证的经验性方法,且能够满足大多数的软件开发需求。领域建模的模式可以是基于特定领域的,也可以不限领域——即所谓的通用模式。本文不会就模式本身或其它类型的领域建模模式展开,下面仅讨论一种较为通用的领域建模模式。

[CDL99]描述了一种基于彩色UML的通用领域建模模式。该模式的作者从数百种领域模型中总结出了一种领域中立组件(Domain-neutral component)模型,该模型可以被应用至大部分的领域建模过程,从而得到理想的领域模型。领域中立组件模型由下面四种最基本的原型(Archetype)组成:

  • 时刻或时段(Moment-interval)原型,表示在某个时刻或时段内出于业务或合法性原因需要追踪的事物。例如一次销售在某个时刻发生,那么这次销售就有日期和时间;一次租赁发生在某个时间段,于是有起始时间和终止时间;一次销售甚至可以发生在某个时间段,从而允许对整个销售过程的效率进行评估。采用UML类图表示如下:

Moment-interval

  • 角色(Role)原型,表示参与者、地点或事物(统称实体或Player)的参与方式。同一个实体可能扮演了多个角色,同一个角色也可能由多个实体扮演。实体自身拥有与角色无关的核心属性和行为,但有时也会具有跨角色的接口方法,用UML表示如下:

Role

  • 参与者、地点或事物(Party, place or thing)原型,表示问题域中的人、组织、地点或者事物,即实体。

  • 描述(Description)原型,这种原型与目录项类似(Catalog-entry-like),表示反复出现的值的集合以及这些集合项所共有的行为。例如一辆汽车拥有序列号、购买日期、颜色、里程表等多种信息,其中与目录项类似的描述主要是指车辆描述,例如制造商、型号、生产日期以及可选颜色等。

为了在UML中描述前述原型,一般可以采用UML中的构造型(Stereotype)工具,但是当复杂性进一步增加时这种方式就不够直观,进而妨碍理解和后续建模。[CDL99]给UML添加了一个新的视觉维度,即采用粉-黄-绿-蓝四种颜色分别代表上述四种原型,颜色体现了原型的重要性程度,下图描述了原型、颜色及其相互关联:

Archetypes and their colors

每个原型在属性、链接、方法、插入点和交互等方面具有相应特征:

时刻或时段原型是领域模型的核心,该原型的对象通常用于封装最有价值的内容,包含序号、日期、时刻或时段、优先级、总数和状态等属性。该对象方法包括创建支持业务流程的对象、添加细节、计算总数、完成或取消时刻或时段对象、以及访问其它同类对象,例如获取同类对象列表、计算平均时刻或时段等。当某些业务流程较复杂时,需要引入插入点使其更加适应变化。

角色原型是第二重要的部分,该原型的对象包含序号、状态等属性。角色原型对象通常会链接到对应的时刻或时段对象。该对象方法包括确定相应实体的可用性、提供业务值或性能估算,且都需要和对应的时刻或时段对象交互。

接下来是实体原型,该原型的对象通常作为角色对象的容器,包含序号、地址和自定义值等属性。实体对象通常会链接到角色对象。该对象方法包括查询可用性(自身状态或角色对象上的状态)、获取自定义值(当其不可用时则从对应的描述对象获取默认值)以及提供业务值或性能估算(与对应的角色对象交互)。

最后是描述原型,该原型的对象包含类型、描述、编号和默认值等属性。描述对象通常会链接到实体对象,但某些时候也会直接链接到时刻或时段对象。该对象方法包括查询可用的实体、计算可用实体的数量,这些方法都需要和对应的实体对象交互。当对象具备复杂算法的行为时,需要引入一个支持提供替代行为的插入点。

在领域建模中,根据所属原型的特征定义概念类的职责,从而使原本偏向静态表示的UML类图进一步表达了类似时序图或协作图的动态交互信息,这也是彩色UML工具的一大优势。此外,[CDL99]借助领域中立组件模式构建了12种领域特定组件(Domain-specific component),经验表明这些领域特定组件能够直接应用于大多数领域建模过程。在面临复杂系统时,可以通过恰当的领域特定模型描述系统中不同的子概念,然后基于是否需要可扩展性,采用直接连接或插入点连接实现组件间联通,从而组装成最终的领域模型。

面向对象设计(OOD)

OOA通常要求领域专家、需求分析和技术专家共同参与,体现的是团队对问题域的一致性描述,也就是本文最开始提到的“做正确的软件”。为了“正确的做软件”,OOD更多需要技术团队的更多参与,进一步描述实现中所需对象及其协作的定义。与OOA不同的是,OOD更关注实现细节,例如创建与实际编码中对应的对象、定义对象行为以及对象间的交互等。在过去数十年中,一系列工具、方法、原则、模式等被提出和应用至OOD,限于篇幅本文不可能覆盖前述所有主题,下面仅讨论具有代表性的工具和方法。

GRASP:基于通用职责分配软件模式的面向对象设计

[CLA01]提出了一种基于通用职责分配软件模式(General Responsibility Assignment Software Patterns, GRASP)的OOD方法,该方法被用于生成可供参考实现的设计模型(Design Model)。其基本思路是从用例模型和领域模型出发创建交互图,采用GRASP模式对交互图进行不断优化,并随时补全所需的类图,最终得到的交互图和类图就是设计模型的主要内容。前文已经介绍过时序图和通信图,这里不再赘述。下面讨论GRASP模式及其在设计中的应用。

在UML中,职责是指契约或者义务,这些通常是通过对象的行为体现出来的。职责包含以下两种类型:

  • 行为型(Doing)责任,例如自身行为、驱动其它对象的行为以及在其它对象中起到控制和协调作用的行为。

  • 知识型(Knowing)责任,例如自身封装的数据和功能以及与自身相关的其它对象。

在领域模型中,概念类的职责通常是显式的。但在实际设计中,来自领域模型的职责需要被分配至不同粒度的类或方法。例如“创建一个销售”这类职责可能只需要一两个方法,但是“提供关系数据库的连接”这类职责就可能需要更多类和方法了。为了完成某项职责,对象可以仅通过自身方法完成,也可能依赖其它对象的方法,因此可以认为OOD的本质就是把职责分配到不同的对象中,这也是GRASP的核心思想。

由职责的定义可知,最能完整体现职责的UML工具是交互图,因为后者同时包含了对象、方法和交互信息,这也是从交互图出发进行OOD的原因。GRASP为职责分配提供了一系列指南,以下用其中5个代表性条目为例:

  • 信息专家(Information Expert),指如果某个对象具有满足职责的必要信息,那么职责就分配给该对象。同时满足该职责所需的信息可能保存在不同对象中,那么就需要把职责分配至不同的对象,并通过对象协作完成整个职责。

  • 创造者(Creator),指如果对象A的职责是创建对象B,那么需要满足A对B的紧密包含关系,例如组合、聚合、强使用关联、强数据关联等场景。

  • 高内聚(High Cohesion),指职责分配应保证高内聚,低内聚意味着对象包含了许多无关或者过多的职责,从而影响可理解性、可复用性、可维护性等方面,同时极易被外部影响而发生改动。

  • 低耦合(Low Coupling),指职责分配应保证低耦合,强耦合意味着对象更易受到外部变的影响,低可理解性和低可复用性。

  • 控制器(Controller),指接收和处理系统消息的职责应分配给具备以下条件的对象:1.代表整个子系统;2.代表某个用例场景。前者相当于系统的外观控制器(Facade Controller),后者则只表示一个事件处理器(Handler)或协调器(Coordinator)。

基于GRASP的设计过程相当于把职责从领域模型转移到设计模型,优先从设计模型中选择已有的类,如果没有该类再从领域模型中抽取概念类,直到满足全部用例。

类职责协作卡(Class-responsibility-collaboration card, CRC)

目前为止,UML在OOA和OOD中都占据了主流地位,但是我们仅采用了其中不到5%的内容。同时,UML及相关的计算机辅助软件工程(Computer-aided software engineering, CASE)工具,使OOD在纸面上花费了大量时间,却难以应付软件工程中出现的各种变化。强调团队协作是应对变化的重要手段之一,因此OOD需要与之相适应的轻量级工具,其中有代表性的就是本节将讨论的类职责协作卡(CRC)。

CRC是一种专注于描述类的职责分配和对象协作的轻量级OOD工具,其最初被用于针对团队的OOD培训并取得了良好效果[BC89],后来因其面向团队协作的特点,被广泛应用至协作式的OOD活动。下图展示了CRC的基本格式:

CRC

CRC的形式很容易理解,也比UML的交互图更加轻便,但其可表达的信息类型数和精确性不如后者,其优势在于团队协作设计。基于CRC的团队协作设计流程大致如下:

  1. 集合团队,首先解释当前用例及其具体场景描述。

  2. 从场景中提取相应职责,并将其分配给合适的CRC卡(即类)。

  3. 确定该职责是否需要协作类,并且移动卡片使相互关联的类挨在一起。

  4. 如果存在替代方案,可以快速创建新的卡片,并将其和原方案放在一起比较,经过团队讨论做出进一步设计决定。

应当注意,上述活动中除了步骤1之外,都离不开OOD原则和模式的指导。经过准确解释和高效协作,CRC能够帮助团队快速理解上下文并就OOD达成一致的方案。当然,CRC的成功应用离不开一系列敏捷实践的共同作用,否则很容易导致“欲速则不达”的后果。

设计契约(Design by contract)

随着OOD/OOP在软件设计和开发中流行,如何保证对象间协作的可靠性逐渐受到重视。软件的可靠性包含了正确性和鲁棒性(或者可直观表达为bug的数量)。我们知道,检验软件本身及其设计的正确性是非常困难的。因此,在设计和开发阶段采取措施是加强可靠性的重要手段。例如采用防御式编程(Defensive programming),即意味着在对象方法的执行过程中做大量检查,例如参数合法性、状态合法性、返回结果合法性等。但是,防御式编程会引入大量冗余且复杂的代码,进而降低软件的可维护性,也不利于维持可靠性。[BM92]提出了设计契约的概念,通过在面向对象的类和方法级别定义合适的契约,从而在设计阶段就引入可靠性保障,该思想同时被引入到实现过程中,成为软件开发的重要技巧之一。

断言(Assertion)是设计契约的核心,断言要求对应的布尔表达式返回值必须为True,否则即意味着异常和潜在bug并且立即报错。同时,断言应当仅被用于表示OOD的契约,而非具体用例场景,因此其只能被用于debug和测试场景。OOD的设计契约包含三种类型的断言:

  • 前提条件(Pre-condition),指某个方法执行前的期望,例如检查输入参数是否合法。采用断言作为前置条件,即意味着输入合法性应实际由方法的调用者保证。而断言的引入则确保在debug和测试阶段验证调用者是否实现了正确的参数检查。

  • 后置条件(Post-condition),指某个方法执行后的期望。与方法体本身不同的是,后置条件语句侧重于描述执行结果,而非前者的过程。

  • 不变量(Invariant),指某个类本身的断言。不变量应当对类的所有对象都始终为True,也就是说,不变量实际上是所有公共方法的默认前提条件和后置条件。

面向对象中的继承给断言带来了一定的复杂性。对于不变量和后置条件来说,子类只能选择强化父类中的断言或维持不变;但是对前提条件而言,子类只能选择弱化父类断言或维持不变。前述约束是由继承引入的动态绑定特性所带来的[MFR03]。

结论

本文所讨论的OOA和OOD分别对应了不同的动机、方法论和评价方式。工具和方法离不开原则和模式的指导,原则和模式在工具和方法的帮助下实现了解释和落地,这些设计元素已然形成了有机整体。前述这些方法论极大促进了软件分析和设计朝着“两个正确”的方向前进。另一方面,以80年代末SEI发起的CMM(Capability Maturity Model)和90年代中期UML的标准化为标志,软件设计乃至整个软件工程开始迈向标准化时代。但是,看似科学的设计和过程随即受到互联网时代巨大变化的冲击,敏捷的思想就是在这种背景下诞生和发展起来的。例如,敏捷强调良好的沟通和协作胜于功能强大的工具,在OOD中正如UML这类大杀器,但也有更轻量的CRC,因此后者被更多用于敏捷软件设计活动。无论如何,在可以预见的未来,业界在“两个正确”的方向上仍有很长一段路要走。

引用

CLA01, Applying UML and Patterns: An Introduction to Object-Oriented Analysis and Design and the Unified Process

ACB99, Writing Effective Use Cases

MO95, Object-oriented methods: a foundation

CDL99, Java Modeling in Color with UML

BC89, A Laboratory For Teaching Object-Oriented Thinking

BM92, Applying “Design by Contract”

MFR03, UML Distilled

Comments