前言
为了保证软件实现得简洁并且与模型保持一致,不管实际情况如何复杂,必须运用建模和设计的最佳实践。
本书中的软件设计风格主要遵循"职责驱动设计"的原则,这个原则是Wirfs-Brock等人在1990年中提出的,并在2003年进行了更新。同时本书也大量利用了Meyer在1988所提出的"契约式设计"思想。它与其他被广泛采用的面向对象设计最佳实践有着基本相同的背景,这些最佳实践在Larman等书中给出了描述。
当项目遇到或大或小的困难时,开发人员可能会发现这些原则都无法适用于项目当前的状况。为了使领域驱动设计过程更灵活,开发人员需要理解上面这些众所周知的基本原理是如何支持MODEL-DRIVEN DESIGN的,这样才能在设计过程中做出一些折中选择,而又不脱离正确的轨道。
下面的简图是一张导航图,它描述的是本部分所要讲解的模式以及这些模式彼此关联的方式。
共用这些标准模式可以使设计有序进行,也使项目组成员能够更方便地了解彼此的工作内容。同时,使用标准模式也使UBIQUITOUS LANGUAGE更加丰富,所有的项目组成员都可以使用UBIQUITOUS LANGUAGE来讨论模型和设计决策。
开发一个好的领域模型是一门艺术。而模型中各个元素的实际设计和实现则相对系统化。将领域设计与软件系统中的其他关注点分离会使设计与模型之间的关系非常清晰。根据不同的特征来定义模型元素则会使元素的意义更加鲜明。对每个元素使用已验证的模式有助于创建出更易于实现的模型。
只有在充分考虑这些基本原理之后,精心设计的模型才能化繁为简,创建出项目组成员可以放心地进行组合使用的详细元素。
分离领域
软件中,虽然专门用于解决领域问题的部分通常只占整个软件系统的很小一部分,但其却出乎意料的重要。要想实现分离领域的想法,我们需要着眼于模型中的元素并且将它们视为一个系统。绝不能像在夜空中辨认星座一样,被迫从一大堆混杂的对象中将领域对象挑选出来。我们需要将领域对象与系统中的其他功能分离,这样就能够避免将领域概念和其他只与软件技术相关的概念搞混了,也不会在纷繁芜杂的系统中完全迷失了领域。
分离领域的复杂技术早已出现,而且都是我们耳熟能详的,但是它对于能否成功运用领域建模原则起着非常关键的作用,所以我们要从领域驱动的视角对它进行简要的回顾。
模式:LAYERED ARCHITECTURE(分层架构)
LAYERED ARCHITECTURE(分层架构)——一种用于分离软件系统关注点的技术,它把领域层与其他层分开。
软件程序需要通过设计和编码来执行许多不同类型的任务。它们接收用户输入,执行业务逻辑,访问数据库,进行网络通信,向用户显示信息,等等。因此程序中的每个功能都可能需要大量的代码来实现。
在面向对象的程序中,常常会在业务对象中直接写入用户界面、数据库访问等支持代码。而一些业务逻辑则会被嵌入到用户界面组件和数据库脚本中。这么做是为了以最简单的方式在短期内完成开发工作。
如果与领域有关的代码分散在大量的其他代码之中,那么查看和分析领域代码就会变得异常困难。对用户界面的简单修改实际上很可能会改变业务逻辑,而要想调整业务规则也很可能需要对用户界面代码、数据库操作代码或者其他的程序元素进行仔细的筛查。这样就不太可能实现一致的、模型驱动的对象了,同时也会给自动化测试带来困难。考虑到程序中各个活动所涉及的大量逻辑和技术,程序本身必须简单明了,否则就会让人无法理解。
要想创建出能够处理复杂任务的程序,需要做到关注点分离——使设计中的每个部分都得到单独的关注。在分离的同时,也需要维持系统内部复杂的交互关系。
软件系统有各种各样的划分方式,但是根据软件行业的经验和惯例,普遍采用LAYERED ARCHITECTURE,特别是有几个层基本上已成了标准层。分层这种隐喻被广泛采用,大多数开发人员都对其有着直观的认识。许多文献对LAYERED ARCHITECTURE也进行了充分的讨论。**LAYERED ARCHITECTURE的基本原则是层中的任何元素都仅依赖于本层的其他元素或其下层的元素。**向上的通信必须通过间接的方式进行,这些将在后面讨论。
分层的价值在于每一层都只代表程序中的某一特定方面。这种限制使每个方面的设计都更具内聚性,更容易解释。当然,要分离出内聚设计中最重要的方面,选择恰当的分层方式是至关重要的。在这里,经验和惯例又一次为我们指明了方向。尽管LAYERED ARCHITECTURE的种类繁多,但是大多数成功的架构使用的都是下面这4个概念层的某种变体。
层级 | 描述 |
---|---|
用户界面层(或表示层) | 负责向用户显示信息和解释用户指令。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人 |
应用层 | 定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其他系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。它没有反映业务情况的状态,但是却可以具有另外一种状态,为用户或程序显示某个任务的进度 |
领域层(或模型层) | 负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心 |
基础设施层 | 为上面各层提供通用的技术能力:为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件,等等。基础设施层还能够通过架构框架来支持4个层次间的交互模式 |
有些项目没有明显划分出用户界面层和应用层,而有些项目则有多个基础设施层。但是将领域层分离出来才是实现MODEL-DRIVEN DESIGN的关键。因此:
给复杂的应用程序划分层次。在每一层内分别进行设计,使其具有内聚性并且只依赖于它的下层。采用标准的架构模式,只与上层进行松散的耦合。将所有与领域模型相关的代码放在一个层中,并把它与用户界面层、应用层以及基础设施层的代码分开。领域对象应该将重点放在如何表达领域模型上,而不需要考虑自己的显示和存储问题,也无需管理应用任务等内容。这使得模型的含义足够丰富,结构足够清晰,可以捕捉到基本的业务知识,并有效地使用这些知识。
将领域层与基础设施层以及用户界面层分离,可以使每层的设计更加清晰。彼此独立的层更容易维护,因为它们往往以不同的速度发展并且满足不同的需求。层与层的分离也有助于在分布式系统中部署程序,不同的层可以灵活地放在不同服务器或者客户端中,这样可以减少通信开销,并优化程序性能。
将各层关联起来
到目前为止,我们的讨论主要集中在层次划分以及如何分层才能改进程序各个方面的设计上,特别是集中在领域层上。但是显然,各层之间也需要互相连接。在连接各层的同时不影响分离带来的好处,这是很多模式的目的所在。
各层之间是松散连接的,层与层的依赖关系只能是单向的。上层可以直接使用或操作下层元素,方法是通过调用下层元素的公共接口,保持对下层元素的引用(至少是暂时的),以及采用常规的交互手段。而如果下层元素需要与上层元素进行通信(不只是回应直接查询),则需要采用另一种通信机制,使用架构模式来连接上下层,如回调模式或OBSERVERS模式。
最早将用户界面层与应用层和领域层相连的模式是MODEL-VIEW-CONTROLLER(MVC,模型—视图—控制器)框架。它是为Smalltalk语言发明的一种设计模式,创建于20世纪70年代。随后出现的许多用户界面架构都是受到它的启发而产生的。Fowler在[Fowler 2002]中讨论了这种模式以及几个实用的变体。Larman也在MODEL-VIEW SEPARATION模式中探讨了这些问题,他提出的APPLICATION COORDINATOR(应用协调器)是连接应用层的一种方法[Larman 1998]。还有许多其他连接用户界面层和应用层的方式。对我们而言,只要连接方式能够维持领域层的独立性,保证在设计领域对象时不需要同时考虑可能与其交互的用户界面,那么这些连接方式就都是可用的。
通常,基础设施层不会发起领域层中的操作,它处于领域层"之下",不包含其所服务的领域中的知识。事实上这种技术能力最常以SERVICE的形式提供。例如,如果一个应用程序需要发送电子邮件,那么一些消息发送的接口可以放在基础设施层中,这样,应用层中的元素就可以请求发送消息了。这种解耦使程序的功能更加丰富。消息发送接口可以连接到电子邮件发送服务、传真发送服务或任何其他可用的服务。但是这种方式最主要的好处是简化了应用层,使其只专注于自己所负责的工作:知道何时该发送消息,而不用操心怎么发送。
SERVICE(服务)——一种作为接口提供的操作,它在模型中是独立的,没有封装的状态。
应用层和领域层可以调用基础设施层所提供的SERVICE。如果SERVICE的范围选择合理,接口设计完善,那么通过把详细行为封装到服务接口中,调用程序就可以保持与SERVICE的松散连接,并且自身也会很简单。
然而,并不是所有的基础设施都是以可供上层调用的SERVICE的形式出现的。有些技术组件被设计成直接支持其他层的基本功能(如为所有的领域对象提供抽象基类),并且提供关联机制(如MVC及类似框架的实现)。这种"架构框架"对于程序其他部分的设计有着更大的影响。
架构框架
如果基础设施通过接口调用SERVICE的形式来实现,那么如何分层以及如何保持层与层之间的松散连接就是相当显而易见的。但是有些技术问题要求更具侵入性的基础设施。整合了大量基础设施需求的框架通常会要求其他层以某种特定的方式实现,如以框架类的子类形式或者带有结构化的方法签名。(子类在父类的上层似乎是违反常理的,但是要记住哪个类反映了另一个类的更多知识。)最好的架构框架既能解决复杂技术问题,也能让领域开发人员集中精力去表达模型,而不考虑其他问题。然而使用框架很容易为项目制造障碍:要么是设定了太多的假设,减小了领域设计的可选范围;要么是需要实现太多的东西,影响开发进度。
项目中一般都需要某种形式的架构框架(尽管有时项目团队选择了不太合适的框架)。当使用框架时,项目团队应该明确其使用目的:建立一种可以表达领域模型的实现并且用它来解决重要问题。项目团队必须想方设法让框架满足这些需求,即使这意味着抛弃框架中的一些功能。例如,早期的J2EE应用程序通常都会将所有的领域对象实现为"实体bean"。这种实现方式不但影响程序性能,还会减慢开发速度。现在,取而代之的最佳实践是利用J2EE框架来实现大粒度对象,而用普通Java对象来实现大部分的业务逻辑。不妄求万全之策,只要有选择性地运用框架来解决难点问题,就可以避开框架的很多不足之处。明智而审慎地选择框架中最具价值的功能能够减少程序实现和框架之间的耦合,使随后的设计决策更加灵活。更重要的是,现在许多框架的用法都极其复杂,这种简化方式有助于保持业务对象的可读性,使其更富有表达力。
架构框架和其他工具都在不断的发展。新框架将越来越多的应用技术问题变得自动化,或者为其提供了预先设定好的解决方案。如果框架使用得当,那么程序开发人员将可以更加专注于核心业务问题的建模工作,这会大大提高开发效率和程序质量。但与此同时,我们必须要保持克制,不要总是想着要寻找框架,因为精细的框架也可能会束缚住程序开发人员。
领域层是模型的精髓
现在,大部分软件系统都采用了LAYERED ARCHITECTURE,只是采用的分层方案存在不同而已。许多类型的开发工作都能从分层中受益。然而,领域驱动设计只需要一个特定的层存在即可。
领域层(domain layer)——在分层架构中负责领域逻辑的那部分设计和实现。领域层是在软件中用来表示领域模型的地方。
领域模型是一系列概念的集合。"领域层"则是领域模型以及所有与其直接相关的设计元素的表现,它由业务逻辑的设计和实现组成。在MODEL-DRIVEN DESIGN中,领域层的软件构造反映出了模型概念。
如果领域逻辑与程序中的其他关注点混在一起,就不可能实现这种一致性。将领域实现独立出来是领域驱动设计的前提。
模式:THE SMART UI–反例
上面总结了面向对象程序中广泛采用的LAYERED ARCHITECTURE模式。在项目中,人们经常会尝试分离用户界面、应用和领域,但是成功分离的却不多见,因此,分层模式的反面就很值得一谈。
许多软件项目都采用并且应该会继续采用一种不那么复杂的设计方法,本文将其称为SMART UI(智能用户界面)。但是SMART UI是另一种设计方法,与领域驱动设计方法迥然不同且互不兼容。如果你选择了SMART UI,那么本书中所讲的大部分内容都不适合你。本文感兴趣的是那些不应该使用SMART UI的情况,这也是本文将称其为"反模式"的原因。本节讨论SMART UI是为了提供一种有益的对比,其将帮助我们认清在本书后面章节中的哪些情况下需要选择相对而言更难于实现的领域驱动设计模式。
假设一个项目只需要提供简单的功能,以数据输入和显示为主,涉及业务规则很少。项目团队也没有高级对象建模师。
如果一个经验并不丰富的项目团队要完成一个简单的项目,却决定使用MODEL-DRIVEN DESIGN以及LAYERED ARCHITECTURE,那么这个项目组将会经历一个艰难的学习过程。团队成员不得不去掌握复杂的新技术,艰难地学习对象建模。(即使有这本书的帮助,这也依然是一个具有挑战性的任务!)对基础设施和各层的管理工作使得原本简单的任务却要花费很长的时间来完成。简单项目的开发周期较短,期望值也不是很高。所以,早在项目团队完成任务之前,该项目就会被取消,更谈不上去论证有关这种方法的许多种令人激动的可行性了。
即使项目有更充裕的时间,如果没有专家的帮助,团队成员也不太可能掌握这些技术。最后,假如他们确实能够克服这些困难,恐怕也只会开发出一套简单的系统。因为这个项目本来就不需要丰富的功能。
经验丰富的团队则不会做出这样的选择。身经百战的开发人员能够更容易学习,进而减少管理各层所需要的时间。领域驱动设计只有应用在大型项目上才能产生最大的收益,而这也确实需要高超的技巧。不是所有的项目都是大型项目;也不是所有的项目团队都能掌握那些技巧。因此,当情况需要时:
在用户界面中实现所有的业务逻辑。将应用程序分成小的功能模块,分别将它们实现成用户界面,并在其中嵌入业务规则。用关系数据库作为共享的数据存储库。使用自动化程度最高的用户界面创建工具和可用的可视化编程工具。(这就是SMART UI的实现过程)
这真是异端邪说啊!正确的做法应该是领域和UI彼此独立。事实上,不将领域和用户界面分离,则很难运用本书后面所要讨论的方法,因此在领域驱动设计中,可以将SMART UI看作是"反模式"。然而在其他情况下,它也是完全可行的。其实,SMART UI也有其自身的优势,在某些情况下它能发挥最佳的作用——这也是它如此普及的原因之一。在这里介绍SMART UI能够帮助我们理解为什么需要将应用程序与领域分离,而且更重要的是,还能让我们知道什么时候不需要这样做。
优点
效率高,能在短时间内实现简单的应用程序。
能力较差的开发人员可以几乎不经过培训就采用它。
甚至可以克服需求分析上的不足,只要把原型发布给用户,然后根据用户反馈快速修改软件产品即可。
程序之间彼此独立,这样,可以相对准确地安排小模块交付的日期。额外扩展简单的功能也很容易。
可以很顺利地使用关系数据库,能够提供数据级的整合。
可以使用第四代语言工具。
移交应用程序后,维护程序员可以迅速重写他们不明白的代码段,因为修改代码只会影响到代码所在的用户界面。
缺点
不通过数据库很难集成应用模块。
没有对行为的重用,也没有对业务问题的抽象。每当操作用到业务规则时,都必须重复这些规则。
快速的原型建立和迭代很快会达到其极限,因为抽象的缺乏限制了重构的选择。
复杂的功能很快会让你无所适从,所以程序的扩展只能是增加简单的应用模块,没有很好的办法来实现更丰富的功能。
如果项目团队有意识地应用这个模式,那么就可以避免其他方法所需要的大量开销。项目团队常犯的错误是采用了一种复杂的设计方法,却无法保证项目从头到尾始终使用它。另一种常见的也是代价高昂的错误则是为项目构建一种复杂的基础设施以及使用工业级的工具,而这样的项目根本不需要它们。
大部分灵活的编程语言(如Java)对于小型应用程序来说是大材小用了,并且使用它们的开销很大。第四代语言风格的工具就足以满足这种需要了。
记住,在项目中使用SMART-UI(智能用户界面)后,除非重写全部的应用模块,否则不能改用其他的设计方法。使用诸如Java这类的通用语言并不能让你在随后的开发过程中放弃使用SMART UI,因此,如果你选择了这条路线,就应该采用与之匹配的开发工具。不要浪费时间去同时采用多种选择。只使用灵活的编程语言并不一定会创建出灵活的软件系统,反而有可能会开发出一个维护代价十分高昂的系统。
同样道理,采用MODEL-DRIVEN DESIGN的项目团队从项目初始就应该采用模型驱动的设计。当然,即使是经验丰富的项目团队在开发大型软件系统时,也不得不从简单的功能着手,然后在整个开发过程中使用连续的迭代开发。但是最初试探性的工作也应该是由模型驱动的,而且要分离出独立的领域层,否则很有可能项目进行到最后就变成SMART-UI(智能用户界面)模式了。
这里讨论SMART UI只是为了让你认清为什么以及何时需要采用诸如LAYERED ARCHITECTURE这样的模式来分离出领域层。
除SMART UI和LAYERED ARCHITECTURE之外,还有一些其他的设计方案。例如,Fowler在 [Fowler 2002]中描述了TRANSACTION SCRIDT(事务脚本),它将用户界面从应用中分离出来,但却并不提供对象模型。总而言之:如果一个架构能够把那些与领域相关的代码隔离出来,得到一个内聚的领域设计,同时又使领域与系统其他部分保持松散耦合,那么这种架构也许可以支持领域驱动设计。
其他的开发风格也有各自的用武之地,但是必须要考虑到各种对于复杂度和灵活性的限制。在某些条件下,将领域设计与其他部分混在一起会产生灾难性的后果。如果你要开发复杂应用软件并且决定使用MODEL-DRIVEN DESIGN,那么做好准备,咬紧牙关,雇用必不可少的专家,并且不要使用SMART UI。
其他分离方式
遗憾的是,除了基础设施和用户界面之外,还有一些其他的因素也会破坏你精心设计的领域模型。你必须要考虑那些没有完全集成到模型中的领域元素。你不得不与同一领域中使用不同模型的其他开发团队合作。还有其他的因素会让你的模型结构不再清晰,并且影响模型的使用效率。后面会讨论这方面的问题,同时会介绍其他模式,如BOUNDED CONTEXT和ANTICORRUPTION LAYER。非常复杂的领域模型本身是难以使用的,所以,还将会说明如何在领域层内进行进一步区分,以便从次要细节中突显出领域的核心概念。
但是,这些都是后话。接下来,我们将会讨论一些具体细节,即如何让一个有效的领域模型和一个富有表达力的实现同时演进。毕竟,把领域隔离出来的最大好处就是可以真正专注于领域设计,而不用考虑其他的方面。
软件中所表示的模型
要想在不削弱模型驱动设计能力的前提下对实现做出一些折中,需要重新组织基本元素。我们需要将模型与实现的各个细节一一联系起来。本章主要讨论这些基本模型元素并理解它们,以便为后面章节的讨论打好基础。本章的讨论从如何设计和简化关联开始。对象之间的关联很容易想出来,也很容易画出来,但实现它们却存在很多潜在的麻烦。关联也表明了具体的实现决策在MODEL-DRIVEN DESIGN中的重要性。
本章的讨论将侧重于模型本身,但仍继续仔细考查具体模型选择与实现问题之间的关系,我们将着重区分用于表示模型的3种模型元素模式:ENTITY、VALUE OBJECT和SERVICE。
从表面上看,定义那些用来捕获领域概念的对象很容易,但要想反映其含义却很困难。这要求我们明确区分各种模型元素的含义,并与一系列设计实践结合起来,从而开发出特定类型的对象。
一个对象是用来表示某种具有连续性和标识的事物的呢(可以跟踪它所经历的不同状态,甚至可以跨不同的实现跟踪它),还是用于描述某种状态的属性呢?这是ENTITY与VALUE OBJECT之间的根本区别。明确地选择这两种模式中的一个来定义对象,有利于减少歧义,并帮助我们做出特定的选择,这样才能得到健壮的设计。
领域中还有一些方面适合用动作或操作来表示,这比用对象表示更加清楚。这些方面最好用SERVICE来表示,而不应把操作的责任强加到ENTITY或VALUE OBJECT上,尽管这样做稍微违背了面向对象的建模传统。SERVICE是应客户端请求来完成某事。在软件的技术层中有很多SERVICE。在领域中也可以使用SERVICE,当对软件要做的某项无状态的活动进行建模时,就可以将该活动作为一项SERVICE。
无状态(stateless)——设计元素的一种属性,客户在使用任何无状态的操作时,都不需要关心它的历史。无状态的元素可以使用甚至修改全局信息(即它可以产生副作用),但它不保存影响其行为的私有状态。
在有些情况下(例如,为了将对象存储在关系数据库中)我们不得不对对象模型做一些折中改变,虽然这会影响对象模型的纯度。本章将给出一些指导原则,以便在被迫处理这种复杂局面时保持正确的方向。
最后,MODULE的讨论将有助于理解这样一个要点——每个设计决策都应该是在深入理解领域中的某些深层知识之后做出的。高内聚、低耦合这种思想(通常被认为是一种技术指标)可应用于概念本身。在MODEL-DRIVEN DESIGN中,MODULE是模型的一部分,它们应该反映领域中的概念。
本章将所有这些体现软件模型的构造块组织到一起。这些都是一些传统思想,而且一些书籍中已经介绍过从中产生的建模和设计思想。但将这些思想组织到模型驱动开发的上下文中,可以帮助开发人员创建符合领域驱动设计主要原则的具体组件,从而有助于解决更大的模型和设计问题。此外,掌握这些基本原则可以帮助开发人员在被迫做出折中设计时把握好正确的方向。
对象的关联
对象之间的关联使得建模与实现之间的交互更为复杂。模型中每个可遍历的关联,软件中都要有同样属性的机制。
一个显示了顾客与销售代表之间关联的模型有两个含义。一方面,它把开发人员所认为的两个真实的人之间的关系抽象出来。另一方面,它相当于两个Java对象之间的对象指针,或者相当于数据库查询(或类似实现)的一种封装。
例如,一对多关联可以用一个集合类型的实例变量来实现。但设计无需如此直接。有时可能没有集合,这时可以使用一个访问方法(accessor method)来查询数据库,找到相应的记录,并用这些记录来实例化对象。这两种设计方法反映了同一个模型。设计必须指定一种具体的遍历机制,这种遍历的行为应该与模型中的关联一致。
现实生活中有大量"多对多"关联,其中有很多关联天生就是双向的。我们在模型开发的早期进行头脑风暴活动并探索领域时,也会得到很多这样的关联。但这些普遍的关联会使实现和维护变得很复杂。此外,它们也很少能表示出关系的本质。
至少有3种方法可以使得关联更易于控制。
(1) 规定一个遍历方向。
(2) 添加一个限定符,以便有效地减少多重关联。
(3) 消除不必要的关联。
尽可能地对关系进行约束是非常重要的。双向关联意味着只有将这两个对象放在一起考虑才能理解它们。当应用程序不要求双向遍历时,可以指定一个遍历方向,以便减少相互依赖,并简化设计。理解了领域之后就可以自然地确定一个方向。
像很多国家一样,美国有过很多位总统。这是一种双向的、一对多的关系。然而,在提到"乔治-华盛顿"这个名字时,我们很少会问,他是哪个国家的总统?从实用的角度讲,我们可以将这种关系简化为从国家到总统的单向关联。如下图所示:
这种精化实际上反映了对领域的深入理解,而且也是一个更实用的设计。它表明一个方向的关联比另一个方向的关联更有意义且更重要。也使得Person类不受非基本概念President的束缚。
通常,通过更深入的理解可以得到一个"限定的"关系。进一步研究总统的例子就可以知道,一个国家在一段时期内只能有一位总统(内战期间或许有例外)。这个限定条件把多重关系简化为一对一关系,并且在模型中植入了一条明确的规则。如下图所示:
限定多对多关联的遍历方向可以有效地将其实现简化为一对多关联,从而得到一个简单得多的设计。
坚持将关联限定为领域所倾向的方向,不仅可以提高这些关联的表达力并简化其实现,而且还可以突出剩下的双向关联的重要性。当双向关联是领域的一个语义特征时,或者当应用程序的功能要求双向关联时,就需要保留它,以便表达出这些需求。
当然,最终的简化是清除那些对当前工作或模型对象的基本含义来说不重要的关联。
模式:ENTITY(又称为REFERENCE OBJECT)
很多对象不是通过它们的属性定义的,而是通过连续性和标识定义的。
很多事物是由它们的标识定义的,而不是由任何属性定义的。我们一般会认为,一个人(继续使用非技术示例)有一个标识,这个标识会陪伴他走完一生(甚至死后)。这个人的物理属性会发生变化,最后消失。他的名字可能改变,财务关系也会发生变化,没有哪个属性是一生不变的,但标识却是永久的。我跟我5岁时是同一个人吗?这种听上去像是纯哲学的问题在探索有效的领域模型时非常重要。稍微变换一下问题的角度:应用程序的用户是否关心现在的我和5岁时的我是不是同一个人?
在一个跟踪到期应收账款的软件系统中,即便最普通的客户对象也可能具有丰富多彩的一面。如果按时付款的话客户信用就会提高,如果未能付款则将其移交给账单清缴机构。当销售人员将客户数据提取出来,并放到联系人管理软件中时,客户对象在这个系统中就开始了另一种生活。无论是哪种情况,它都会被扁平化存储在数据库表中。当业务最终停摆的时候,客户对象就退休了,变成归档状态,成为先前自己的一个影子。
客户对象的这些形式都是基于不同编程语言和技术的不同实现。但当接到订单电话时,知道以下事情是很重要的:这个客户是不是那个拖欠了账务的客户?这个客户是不是那个已经与Jack(一位销售代表)保持联络达好几个星期的客户?还是说他完全是一个新客户?
在对象的多个实现、存储形式和真实世界的参与者(如打电话的人)之间,概念性标识必须是匹配的。属性可以不匹配,例如,销售代表可能已经在联系软件中更新了地址,而这个更新正在传送给到期应收账款软件。两个客户可能同名。在分布式软件中,多个用户可能从不同地点输入数据,这需要在不同的数据库中异步地协调这些更新事务,使它们传播到整个系统。
对象建模有可能把我们的注意力引到对象的属性上,但实体的基本概念是一种贯穿整个生命周期(甚至会经历多种形式)的抽象的连续性。
生命周期(life cycle)——一个对象从创建到删除中间所经历的一个状态序列,通常具有一些约束,以确保从一种状态变为另一种状态时的完整性。它可能包括ENTITY在不同的系统和BOUNDED CONTEXT之间的迁移。
一些对象主要不是由它们的属性定义的。它们实际上表示了一条"标识线"(A Thread of Identity),这条线跨越时间,而且常常经历多种不同的表示。有时,这样的对象必须与另一个具有不同属性的对象相匹配。而有时一个对象必须与具有相同属性的另一个对象区分开。错误的标识可能会破坏数据。
主要由标识定义的对象被称作ENTITY。模型ENTITY与Java的实体bean并不是一回事。实体bean本打算成为一种用于实现ENTITY的框架,但它实际上并没有做到。大多数ENTITY都被实现为普通对象。不管它们是如何实现的,ENTITY都是领域模型中的一个根本特征。
ENTITY(实体)有特殊的建模和设计思路。它们具有生命周期,这期间它们的形式和内容可能发生根本改变,但必须保持一种内在的连续性。为了有效地跟踪这些对象,必须定义它们的标识。它们的类定义、职责、属性和关联必须由其标识来决定,而不依赖于其所具有的属性。即使对于那些不发生根本变化或者生命周期不太复杂的ENTITY,也应该在语义上把它们作为ENTITY来对待,这样可以得到更清晰的模型和更健壮的实现。
当然,软件系统中的大多数ENTITY并不是人,也不是其通常意义上所指的实体或存在。**ENTITY可以是任何事物,只要满足两个条件即可,一是它在整个生命周期中具有连续性,二是它的区别并不是由那些对用户非常重要的属性决定的。**ENTITY可以是一个人、一座城市、一辆汽车、一张彩票或一次银行交易。
另一方面,在一个模型中,并不是所有对象都是具有有意义标识的ENTITY。但是,由于面向对象语言在每个对象中都构建了一些与标识有关的操作(如Java中的==操作符),这个问题变得有点让人困惑。这些操作通过比较两个引用在内存中的位置(或通过其他机制)来确定这两个引用是否指向同一个对象。从这个角度讲,每个对象实例都有标识。比方说,当创建一个用于将远程对象缓存到本地的Java运行时环境或技术框架时,这个领域中的每个对象可能确实都是一个ENTITY。但这种标识机制在其他应用领域中却没什么意义。标识是ENTITY的一个微妙的、有意义的属性,我们是不能把它交给语言的自动特性来处理的。
让我们考虑一下银行应用程序中的交易。同一天、同一个账户的两笔数额相同的存款实际上是两次不同的交易,因此它们是具有各自标识的ENTITY。另一方面,这两笔交易的金额属性可能是某个货币对象的实例。这些值没有标识,因为没有必要区分它们。事实上,两个对象可能有相同的标识,但属性可能不同,在需要的情况下甚至可能不属于同一个类。当银行客户拿银行结算单与支票记录簿进行交易对账时,这项任务就是匹配具有相同标识的交易,尽管它们是由不同的人在不同的日期记录的(银行清算日期比支票上的日期晚)。支票号码就是用于对账的唯一标识符,无论这个问题是由计算机程序处理还是手工处理。存款和取款没有标识号码,因此可能更复杂,但同样的原则也是适用的——每笔交易都是一个ENTITY,至少出现在两张业务表格中。
标识的重要性并不仅仅体现在特定的软件系统中,在软件系统之外它通常也是非常重要的,银行交易和公寓租客的例子中就是如此。但有时标识只有在系统上下文中才重要,如一个计算机进程的标识。因此:
当一个对象由其标识(而不是属性)区分时,那么在模型中应该主要通过标识来确定该对象的定义。使类定义变得简单,并集中关注生命周期的连续性和标识。定义一种区分每个对象的方式,这种方式应该与其形式和历史无关。要格外注意那些需要通过属性来匹配对象的需求。在定义标识操作时,要确保这种操作为每个对象生成唯一的结果,这可以通过附加一个保证唯一性的符号来实现。这种定义标识的方法可能来自外部,也可能是由系统创建的任意标识符,但它在模型中必须是唯一的标识。模型必须定义出"符合什么条件才算是相同的事物"。
在现实世界中,并不是每一个事物都必须有一个标识,标识重不重要,完全取决于它是否有用。实际上,现实世界中的同一个事物在领域模型中可能需要表示为ENTITY,也可能不需要表示为ENTITY。
体育场座位预订程序可能会将座位和观众当作ENTITY来处理。在分配座位时,每张票都有一个座位号,座位是ENTITY。其标识符就是座位号,它在体育场中是唯一的。座位可能还有很多其他属性,如位置、视野是否开阔、价格等,但只有座位号(或者说某一排的一个位置)才用于识别和区分座位。
另一方面,如果活动采用入场卷的方式,那么观众可以寻找任意的空座位来坐,这样就不需要对座位加以区分。在这种情况下,只有座位总数才是重要的。尽管座位上仍然印有座位号,但软件已经不需要跟踪它们。事实上,这时如果模型仍然将座位号与门票关联起来,那么它就是错误的,因为采用入场卷的活动并没有这样的约束。在这种情况下,座位不是ENTITY,因此不需要标识符。
ENTITY建模
当对一个对象进行建模时,我们自然而然会考虑它的属性,而且考虑它的行为也显得非常重要。但ENTITY最基本的职责是确保连续性,以便使其行为更清楚且可预测。保持实体的简练是实现这一责任的关键。不要将注意力集中在属性或行为上,应该摆脱这些细枝末节,抓住ENTITY对象定义的最基本特征,尤其是那些用于识别、查找或匹配对象的特征。只添加那些对概念至关重要的行为和这些行为所必需的属性。此外,应该将行为和属性转移到与核心实体关联的其他对象中。这些对象中,有些可能是ENTITY,有些可能是VALUE OBJECT(这是本章接下来要讨论的模式)。除了标识问题之外,实体往往通过协调其关联对象的操作来完成自己的职责。
设计标识操作
每个ENTITY都必须有一种建立标识的操作方式,以便与其他对象区分开,即使这些对象与它具有相同的描述属性。不管系统是如何定义的,都必须确保标识属性在系统中是唯一的,即使是在分布式系统中,或者对象已被归档,也必须确保标识的唯一性。
如前所述,面向对象语言有一些标识操作,它们通过比较对象在内存中的位置来确定两个引用是否指向同一个对象。这种标识跟踪机制过于简单,无法满足我们的目的。在大多数对象持久存储技术中,每次从数据库检索出一个对象时,都会创建一个新实例,这样原来的标识就丢失了。每次在网络上传输对象时,在目的地也会创建一个新实例,这也会导致标识的丢失。当系统中存在同一对象的多个版本时(例如,通过分布式数据库来传播更新的时候),问题将会更复杂。
尽管有一些用于简化这些技术问题的框架,但基本问题仍然存在。如何才能判定两个对象是否表示同一个概念ENTITY?标识是在模型中定义的。定义标识要求理解领域。
有时,某些数据属性或属性组合可以确保它们在系统中具有唯一性,或者在这些属性上加一些简单约束可以使其具有唯一性。这种方法为ENTITY提供了唯一键。例如,日报可以通过名称、城市和出版日期来识别。(但要注意临时增刊和名称变更!)
当对象属性没办法形成真正唯一键时,另一种经常用到的解决方案是为每个实例附加一个在类中唯一的符号(如一个数字或字符串)。一旦这个ID符号被创建并存储为ENTITY的一个属性,必须将它指定为不可变的。它必须永远不变,即使开发系统无法直接强制这条规则。例如,当对象被扁平化到数据库中或从数据库中重新创建时,ID属性应该保持不变。有时可以利用技术框架来实现此目的,但如果没有这样的框架,就需要通过工程纪律来约束。
ID通常是由系统自动生成的。生成算法必须确保ID在系统中是唯一的。在并行处理系统和分布式系统中,这可能是一个难题。生成这种ID的技术超出了本书的范围。这里的目的是指出何时需要考虑这些问题,以便使开发人员能够意识到有一个问题等待他们去解决,并知道如何将注意力集中到关键问题上。关键是要认识到标识问题取决于模型的特定方面。通常,要想找到解决标识问题的方法,必须对领域进行仔细的研究。
当自动生成ID时,用户可能永远不需要看到它。ID可能只是在内部需要,例如,在一个可以按人名查找记录的联系人管理应用程序中。这个程序需要用一种简单、明确的方式来区分两个同名联系人,这就可以通过唯一的内部ID来实现。在检索出两个不同的条目后,系统将显示这两个不同的联系人,但可能不会显示ID。用户可以通过这两个人的公司、地点等属性来区分他们。
最后,在有些情况下用户会对生成的ID感兴趣。当我委托一个包裹运送服务寄包裹时,我会得到一个跟踪号,它是由运送公司的软件生成的,我可以用这个号码来识别和跟踪我的包裹。当我预订机票或酒店时,会得到一个确认号码,它是预订交易的唯一标识符。
在某些情况下,需要确保ID在多个计算机系统之间具有唯一性。例如,如果需要在两家具有不同计算机系统的医院之间交换医疗记录,那么理想情况下每个系统对同一个病人应该使用同一个ID,但如果这两个系统各自生成自己的ID,这就很难实现。这样的系统通常使用由另外一家机构(一般是政府机构)发放的标识符。在美国,医院通常使用社会保险号码作为病人的标识符。但这样的方法也不是万无一失的,因为并不是每个人都有社会保险号码(特别是儿童和非美国居民),而且很多人会出于个人隐私原因而反对这种做法。
在一些非正式的场合(比方说,音像出租),可以使用电话号码作为标识符。但电话可能是共用的,号码也可能会更改,甚至一个旧的电话号码可能会重新分配给一个不同的人。
由于这些原因,我们一般使用特别指定的标识符(如经常乘飞机的乘客编号),并使用其他属性(如电话号码和社会保险号码)进行匹配和验证。在任何情况下,当应用程序需要一个外部ID时,都由系统的用户负责提供唯一的ID,而系统必须为用户提供适当的工具来处理异常情况。
在这些技术问题的干扰下,人们很容易忽略基本的概念问题:两个对象是同一事物时意味着什么?我们很容易为每个对象分配一个ID,或是编写一个用于比较两个实例的操作,但如果这些ID或操作没有对应领域中有意义的区别,那只会使问题更混乱。这就是分配标识的操作通常需要人工输入的原因。例如,支票簿对账软件可以提供一些有可能匹配的账目,但它们是否真的匹配则要由用户最终决定。
模式:VALUE OBJECT
VALUE OBJECT(值对象)——一种描述了某种特征或属性但没有概念标识的对象。很多对象没有概念上的标识,它们描述了一个事务的某种特征。
当一个小孩画画的时候,他注意的是画笔的颜色和笔尖的粗细。但如果有两只颜色和粗细相同的画笔,他可能不会在意使用哪一支。如果有一支笔弄丢了,他可以从一套新笔中拿出一支同样颜色的笔来继续画,根本不会在意已经换了一支笔。
问问孩子冰箱上的画都是谁画的,他会很快辨认出哪些是他画的,哪些是他姐姐画的。姐弟俩有一些实用的标识来区分自己,与此类似,他们完成的作品也有。但设想一下,如果孩子必须记住哪些线条是用哪支笔画的,情况该有多么复杂?如果这样的话,画画将不再是小孩子的游戏了。
由于模型中最引人注意的对象往往是ENTITY,而且跟踪每个ENTITY的标识是极为重要的,因此我们很自然地会想到为每个领域对象都分配一个标识。实际上,一些框架确实为每个对象分配了一个唯一的ID。
这样一来,系统就必须处理所有这些ID的跟踪问题,从而导致许多本来可能的性能优化不得不被放弃。此外,人们还需要付出大量的分析工作来定义有意义的标识,还需要开发出一些可靠的跟踪方式,以便在分布式系统或在数据库存储中跟踪对象。同样重要的是,盲目添加无实际意义的标识可能会产生误导。它会使模型变得混乱,并使所有对象看起来千篇一律。
跟踪ENTITY的标识是非常重要的,但为其他对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都是相同的。
软件设计要时刻与复杂性做斗争。我们必须区别对待问题,仅在真正需要的地方进行特殊处理。
然而,如果仅仅把这类对象当作没有标识的对象,那么就忽略了它们的工具价值或术语价值。事实上,这些对象有其自己的特征,对模型也有着自己的重要意义。这些是用来描述事物的对象。
用于描述领域的某个方面而本身没有概念标识的对象称为VALUE OBJECT(值对象)。VALUE OBJECT被实例化之后用来表示一些设计元素,对于这些设计元素,我们只关心它们是什么,而不关心它们是谁。
颜色是很多现代开发系统的基础库所提供的VALUE OBJECT的一个例子,字符串和数字也是这样的VALUE OBJECT(我们不会关心所使用的是哪一个4或哪一个Q)。这些基本的例子非常简单,但VALUE OBJECT并不都这样简单。例如,调色程序可能有一个功能丰富的模型,在这个模型中,可以把功能更强的颜色对象组合起来产生其他颜色。这些颜色可能具有很复杂的算法,通过这些算法的共同计算得到新的VALUE OBJECT。
VALUE OBJECT可以是其他对象的集合。在房屋设计软件中,可以为每种窗户样式创建一个对象。我们可以将"窗户样式"连同它的高度、宽度以及修改和组合这些属性的规则一起放到"窗户"对象中。这些窗户就是由其他VALUE OBJECT组成的复杂VALUE OBJECT。它们进而又被合并到更大的设计元素中,如"墙"对象。
VALUE OBJECT甚至可以引用ENTITY。例如,如果我请在线地图服务为我提供一个从旧金山到洛杉矶的驾车风景游路线,它可能会得出一个"路线"对象,此对象通过太平洋海岸公路连接旧金山和洛杉矶。这个"路线"对象是一个VALUE,尽管它所引用的3个对象(两座城市和一条公路)都是ENTITY。
VALUE OBJECT经常作为参数在对象之间传递消息。它们常常是临时对象,在一次操作中被创建,然后丢弃。VALUE OBJECT可以用作ENTITY(以及其他VALUE)的属性。我们可以把一个人建模为一个具有标识的ENTITY,但这个人的名字是一个VALUE。
当我们只关心一个模型元素的属性时,应把它归类为VALUE OBJECT。我们应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。**VALUE OBJECT应该是不可变的。**不要为它分配任何标识,而且不要把它设计成像ENTITY那么复杂。
VALUE OBJECT所包含的属性应该形成一个概念整体。例如,street(街道)、city(城市)和 postal code(邮政编码)不应是Person(人)对象的单独的属性。它们是整个地址的一部分,这样可以使得Person对象更简单,并使地址成为一个更一致的VALUE OBJECT。
设计VALUE OBJECT
我们并不关心使用的是VALUE OBJECT的哪个实例。由于不受这方面的约束,设计可以获得更大的自由,因此可以简化设计或优化性能。在设计VALUE OBJECT时有多种选择,包括复制、共享或保持VALUE OBJECT不变。
两个人同名并不意味着他们是同一个人,也不意味着他们是可互换的。但表示名字的对象是可以互换的,因为它们只涉及名字的拼写。一个Name对象可以从第一个Person对象复制给第二个Person对象。
事实上,这两个Person对象可能不需要自己的名字实例,它们可以共享同一个Name对象(其中每个Person对象都有一个指向同一个名字实例的指针),而无需改变它们的行为或标识。如此一来,当修改其中一个人名字时就会产生问题,这时另一个人的名字也将改变!为了防止这种错误发生,以便安全地共享一个对象,必须确保Name对象是不变的——它不能改变,除非将其整个替换掉。
当一个对象将它的一个属性作为参数或返回值传递给另一个对象时,也会发生同样的问题。一个脱离了其所有者控制的"流浪"对象可能会发生任何事情。VALUE的改变可能会破坏所有者的约束条件。这个问题可以通过传递一个不变对象或传递一个副本来解决。
VALUE OBJECT为性能优化提供了更多选择,这一点可能很重要,因为VALUE OBJECT往往为数众多。房屋设计软件的示例就说明了这一点。如果每个电源插座都是一个单独的VALUE OBJECT,那么在一所房屋的一个设计版本中可能就会有上百个这种VALUE OBJECT。但如果把电源插座看成是可互换的,就只需共享一个电源插座实例,并让所有电源插座都指向这个实例(FLYWEIGHT,[Gamma et al. 1995]中的一个示例)。在大型系统中,这种效果可能会被放大数千倍,而且这样的优化可能决定一个系统是可用的,还是由于数百万个多余对象而变得异常缓慢。这只是无法应用于ENTITY的优化技巧中的一个。
复制和共享哪个更划算取决于实现环境。虽然复制有可能导致系统被大量的对象阻塞,但共享可能会减慢分布式系统的速度。当在两个机器之间传递一个副本时,只需发送一条消息,而且副本到达接收端后是独立存在的。但如果共享一个实例,那么只会传递一个引用,这要求每次交互都要向发送方返回一条消息。
以下几种情况最好使用共享,这样可以发挥共享的最大价值并最大限度地减少麻烦:
节省数据库空间或减少对象数量是一个关键要求时;
通信开销很低时(如在中央服务器中);
共享的对象被严格限定为不可变时。
在有些语言和环境中,可以将属性或对象声明为不可变的,但有些却不具备这种能力。这种声明能够体现出设计决策,但它们并不是十分重要。我们在模型中所做的很多区别都无法用当前工具和编程语言在实现中显式地声明出来。例如,我们无法声明ENTITY并自动确保其具有一个标识操作。但是,编程语言没有直接支持这些概念上的区别并不说明这些区别没有用处。这只是说明我们需要更多的约束机制来确保满足一些重要的规则(这些规则只有在实现中才是隐式的)。命名规则、精心准备的文档和大量讨论都可以强化这些需求。
只要VALUE OBJECT是不可变的,变更管理就会很简单,因为除了整体替换之外没有其他的更改。不变的对象可以自由地共享,像在电源插座的例子中一样。如果垃圾回收是可靠的,那么删除操作就只是将所有指向对象的引用删除。当在设计中将一个VALUE OBJECT指定为不可变时,开发人员就可以完全根据技术需求来决定是使用复制,还是使用共享,因为他们没有后顾之忧——应用程序不依赖于对象的特殊实例。
特殊情况:何时允许可变性
保持VALUE OBJECT不变可以极大地简化实现,并确保共享和引用传递的安全性。而且这样做也符合值的意义。如果属性的值发生改变,我们应该使用一个不同的VALUE OBJECT,而不是修改现有的VALUE OBJECT。尽管如此,在有些情况下出于性能考虑,仍需要让VALUE OBJECT是可变的。这包括以下因素:
如果VALUE频繁改变;
如果创建或删除对象的开销很大;
如果替换(而不是修改)将打乱集群;
如果VALUE的共享不多,或者共享不会提高集群性能,或其他某种技术原因。
再次强调:如果一个VALUE的实现是可变的,那么就不能共享它。无论是否共享VALUE OBJECT,在可能的情况下都要将它们设计为不可变的。如Java语言中的String类、包装器类(如Integer、Long等),都是不可变对象。
定义VALUE OBJECT并将其指定为不可变的是一条一般规则,这样做是为了避免在模型中产生不必要的约束,从而让开发人员可以单纯地从技术上优化性能。如果开发人员能够显式地定义重要约束,那么他们就可以在对设计做出必要调整时,确保不会无意更改重要的行为。这样的设计调整往往特定于具体项目所使用的技术。
示例:通过VALUE OBJECT来优化数据库
数据库——在其最底层——是将数据存储到物理磁盘的一个具体位置上,或者花时间移动物理部件将数据读取出来。高级数据库则尝试将这些物理地址聚集到一起,以便可以在一次物理操作中从磁盘读取相互关联的数据。
如果一个对象被许多对象引用,其中有些对象将不会在它附近(不在同一分页上),这就需要通过额外的物理操作来获取数据。通过复制(而不是共享对同一个实例的引用),可以将这种作为很多ENTITY属性的VALUE OBJECT存储在ENTITY所在的同一分页上。这种存储相同数据的多个副本的技术称为非规范化(denormalization),当访问时间比存储空间或维护的简单性更重要时,通常使用这种技术。
在关系数据库中,我们可能想把一个具体的值放到拥有此值的ENTITY的表中,而不是将其关联到另一个单独的表。在分布式系统中,对一个位于另一台服务器上的VALUE OBJECT的引用可能导致对消息的响应十分缓慢,在这种情况下,应该将整个对象的副本传递到另一台服务器上。我们可以随意地使用副本,因为处理的是VALUE OBJECT。
设计包含VALUE OBJECT的关联
前面讨论的与关联有关的大部分内容也适用于ENTITY和VALUE OBJECT。模型中的关联越少越好,越简单越好。
但是,如果说ENTITY之间的双向关联很难维护,那么两个VALUE OBJECT之间的双向关联则完全没有意义。当一个VALUE OBJECT指向另一个VALUE OBJECT时,由于没有标识,说一个对象指向的对象正是那个指向它的对象并没有任何意义的。我们充其量只能说,一个对象指向的对象与那个指向它的对象是等同的,但这可能要求我们必须在某个地方实施这个固定规则。而且,尽管我们可以这样做,并设置双向指针,但很难想出这种安排有什么用处。因此,我们应尽量完全清除VALUE OBJECT之间的双向关联。如果在你的模型中看起来确实需要这种关联,那么首先应重新考虑一下将对象声明为VALUE OBJECT这个决定是否正确。或许它拥有一个标识,而你还没有注意到它。
ENTITY和VALUE OBJECT是传统对象模型的主要元素,但一些注重实效的设计人员正逐渐开始使用一种新的元素——SERVICE。
模式:SERVICE
有时,对象不是一个事物。在某些情况下,最清楚、最实用的设计会包含一些特殊的操作,这些操作从概念上讲不属于任何对象。与其把它们强制地归于哪一类,不如顺其自然地在模型中引入一种新的元素,这就是SERVICE(服务)。
有些重要的领域操作无法放到ENTITY或VALUE OBJECT中。这当中有些操作从本质上讲是一些活动或动作,而不是事物,但由于我们的建模范式是对象,因此要想办法将它们划归到对象这个范畴里。
现在,一个比较常见的错误是没有努力为这类行为找到一个适当的对象,而是逐渐转为过程化的编程。但是,当我们勉强将一个操作放到不符合对象定义的对象中时,这个对象就会产生概念上的混淆,而且会变得很难理解或重构。复杂的操作很容易把一个简单对象搞乱,使对象的角色变得模糊。此外,由于这些操作常常会牵扯到很多领域对象——需要协调这些对象以便使它们工作,而这会产生对所有这些对象的依赖,将那些本来可以单独理解的概念缠杂在一起。
有时,一些SERVICE看上去就像是模型对象,它们以对象的形式出现,但除了执行一些操作之外并没有其他意义。这些"实干家"(Doer)的名字通常以"Manager"之类的名字结尾。它们没有自己的状态,而且除了所承载的操作之外在领域中也没有其他意义。尽管如此,该方法至少为这些特立独行的行为找到了一个容身之所,避免它们扰乱真正的模型对象。
一些领域概念不适合被建模为对象。如果勉强把这些重要的领域功能归为ENTITY或VALUE OBJECT的职责,那么不是歪曲了基于模型的对象的定义,就是人为地增加了一些无意义的对象。
SERVICE是作为接口提供的一种操作,它在模型中是独立的,它不像ENTITY和VALUE OBJECT那样具有封装的状态。SERVICE是技术框架中的一种常见模式,但它们也可以在领域层中使用。
所谓SERVICE,它强调的是与其他对象的关系。与ENTITY和VALUE OBJECT不同,它只是定义了能够为客户做什么。SERVICE往往是以一个活动来命名,而不是以一个ENTITY来命名,也就是说,它是动词而不是名词。SERVICE也可以有抽象而有意义的定义,只是它使用了一种与对象不同的定义风格。SERVICE也应该有定义的职责,而且这种职责以及履行它的接口也应该作为领域模型的一部分来加以定义。操作名称应来自于UBIQUITOUS LANGUAGE,如果UBIQUITOUS LANGUAGE中没有这个名称,则应该将其引入到UBIQUITOUS LANGUAGE中。参数和结果应该是领域对象。
使用SERVICE时应谨慎,它们不应该替代ENTITY和VALUE OBJECT的所有行为。但是,当一个操作实际上是一个重要的领域概念时,SERVICE很自然就会成为MODEL-DRIVEN DESIGN中的一部分。将模型中的独立操作声明为一个SERVICE,而不是声明为一个不代表任何事情的虚拟对象,可以避免对任何人产生误导。好的SERVICE有以下3个特征。
(1) 与领域概念相关的操作不是ENTITY或VALUE OBJECT的一个自然组成部分。
(2) 接口是根据领域模型的其他元素定义的。
(3) 操作是无状态的。
这里所说的无状态是指任何客户都可以使用某个SERVICE的任何实例,而不必关心该实例的历史状态。SERVICE执行时将使用可全局访问的信息,甚至会更改这些全局信息(也就是说,它可能具有副作用)。但SERVICE不保持影响其自身行为的状态,这一点与大多数领域对象不同。
当领域中的某个重要的过程或转换操作不是ENTITY或VALUE OBJECT的自然职责时,应该在模型中添加一个作为独立接口的操作,并将其声明为SERVICE。定义接口时要使用模型语言,并确保操作名称是UBIQUITOUS LANGUAGE中的术语。此外,应该使SERVICE成为无状态的。
SERVICE与孤立的领域层
这种模式只重视那些在领域中具有重要意义的SERVICE,但SERVICE并不只是在领域层中使用。我们需要注意区分属于领域层的SERVICE和那些属于其他层的SERVICE,并划分责任,以便将它们明确地区分开。
文献中所讨论的大多数SERVICE是纯技术的SERVICE,它们都属于基础设施层。领域层和应用层的SERVICE与这些基础设施层SERVICE进行协作。例如,银行可能有一个用于向客户发送电子邮件的应用程序,当客户的账户余额小于一个特定的临界值时,这个程序就向客户发送一封电子邮件。封装了电子邮件系统的接口(也可能是其他的通知方式)就是基础设施层中的SERVICE。
应用层SERVICE和领域层SERVICE可能很难区分。应用层负责通知的设置,而领域层负责确定是否满足临界值,尽管这项任务可能并不需要使用SERVICE,因为它可以作为account(账户)对象的职责中。这个银行应用程序可能还负责资金转账。如果设计一个SERVICE来处理资金转账相应的借方和贷方,那么这项功能将属于领域层。资金转账在银行领域语言中是一项有意义的操作,而且它涉及基本的业务逻辑。而纯技术的SERVICE应该没有任何业务意义。
很多领域或应用层SERVICE是在ENTITY和VALUE OBJECT的基础上建立起来的,它们的行为类似于将领域的一些潜在功能组织起来以执行某种任务的脚本。ENTITY和VALUE OBJECT往往由于粒度过细而无法提供对领域层功能的便捷访问。我们在这里会遇到领域层与应用层之间很微妙的分界线。例如,如果银行应用程序可以把我们的交易进行转换并导出到一个电子表格文件中,以便进行分析,那么这个导出操作就是应用层SERVICE。
另一方面,账户之间的转账功能属于领域层SERVICE,因为它包含重要的业务规则(如处理相应的借方账户和贷方账户),而且,"资金转账"是一个有意义的银行术语。在这种情况下,SERVICE自己并不会做太多的事情,而只是要求两个Account对象完成大部分工作。但如果将"转账"操作强加在Account对象上会很别扭,因为这个操作涉及两个账户和一些全局规则。
我们可能喜欢创建一个Funds Transfer(资金转账)对象来表示两个账户,外加一些与转账有关的规则和历史记录。但在银行间的网络中进行转账时,仍然需要使用SERVICE。此外,在大多数开发系统中,在一个领域对象和外部资源之间直接建立一个接口是很别扭的。我们可以利用一个FACADE(外观)将这样的外部SERVICE包装起来,这个外观可能以模型作为输入,并返回一个Funds Transfer对象(作为它的结果)。但无论中间涉及什么SERVICE,甚至那些超出我们掌控范围的SERVICE,这些SERVICE都是在履行资金转账的领域职责。
可以将SERVIC划分到各个层中。
层级 | 作用 |
---|---|
应用层 | 资金转账应用服务。 获取输入(如一个XML请求); 发送消息给领域层服务,要求其执行; 监听确认消息; 决定使用基础设施SERVICE来发送通知 |
领域层 | 资金转账领域服务。与必要的Account(账户)和Ledger(总账)对象进行交互,执行相应的借入和贷出操作。提供结果的确认(允许转账或拒绝转账等) |
基础设施层 | 发送通知服务。按照应用程序的指示发送电子邮件、信件和其他信息 |
Service的粒度
上述对SERVICE的讨论强调的是将一个概念建模为SERVICE的表现力,但SERVICE还有其他有用的功能,它可以控制领域层中的接口的粒度,并且避免客户端与ENTITY和VALUE OBJECT耦合。
在大型系统中,中等粒度的、无状态的SERVICE更容易被复用,因为它们在简单的接口背后封装了重要的功能。此外,细粒度的对象可能导致分布式系统的消息传递的效率低下。
如前所述,由于应用层负责对领域对象的行为进行协调,因此细粒度的领域对象可能会把领域层的知识泄漏到应用层中。这产生的结果是应用层不得不处理复杂的、细致的交互,从而使得领域知识蔓延到应用层或用户界面代码当中,而领域层会丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限。
这种模式有利于保持接口的简单性,便于客户端控制并提供了多样化的功能。它提供了一种在大型或分布式系统中便于对组件进行打包的中等粒度的功能。而且,有时SERVICE是表示领域概念的最自然的方式。
对SERVICE的访问
像J2EE和CORBA这样的分布式系统架构提供了特殊的SERVICE发布机制,这些发布机制具有一些使用上的惯例,并且增加了发布和访问功能。但是,并非所有项目都会使用这样的框架,即使在使用了它们的时候,如果只是为了在逻辑上实现关注点的分离,那么它们也是大材小用了。
与分离特定职责的设计决策相比,提供对SERVICE的访问机制的意义并不是十分重大。一个"操作"对象可能足以作为SERVICE接口的实现。我们很容易编写一个简单的SINGLETON对象[Gamma et al. 1995]来实现对SERVICE的访问。从编码惯例可以明显看出,这些对象只是SERVICE接口的提供机制,而不是有意义的领域对象。只有当真正需要实现分布式系统或充分利用框架功能的情况下才应该使用复杂的架构。
模式:MODULE(也称为PACKAGE)
MODULE是一个传统的、较成熟的设计元素。虽然使用MODULE有一些技术上的原因,但主要原因却是"认知超载"。MODULE为人们提供了两种观察模型的方式,一是可以在MODULE中查看细节,而不会被整个模型淹没,二是观察MODULE之间的关系,而不考虑其内部细节。
领域层中的MODULE应该成为模型中有意义的部分,MODULE从更大的角度描述了领域。
每个人都会使用MODULE,但却很少有人把它们当做模型中的一个成熟的组成部分。代码按照各种各样的类别进行分解,有时是按照技术架构来分割的,有时是按照开发人员的任务分工来分割的。甚至那些从事大量重构工作的开发人员也倾向于使用项目早期形成的一些MODULE。
众所周知,MODULE之间应该是低耦合的,而在MODULE的内部则是高内聚的。耦合和内聚的解释使得MODULE听上去像是一种技术指标,仿佛是根据关联和交互的分布情况来机械地判断它们。然而,MODULE并不仅仅是代码的划分,而且也是概念的划分。一个人一次考虑的事情是有限的(因此才要低耦合)。不连贯的思想和"一锅粥"似的思想同样难于理解(因此才要高内聚)。
低耦合高内聚作为通用的设计原则既适用于各种对象,也适用于MODULE,但MODULE作为一种更粗粒度的建模和设计元素,采用低耦合高内聚原则显得更为重要。这些术语由来已久,早在[Larman 1998]中就从模式角度对其进行了解释。
只要两个模型元素被划分到不同的MODULE中,它们的关系就不如原来那样直接,这会使我们更难理解它们在设计中的作用。MODULE之间的低耦合可以将这种负面作用减至最小,而且在分析一个MODULE的内容时,只需很少地参考那些与之交互的其他MODULE。
同时,在一个好的模型中,元素之间是要协同工作的,而仔细选择的MODULE可以将那些具有紧密概念关系的模型元素集中到一起。将这些具有相关职责的对象元素聚合到一起,可以把建模和设计工作集中到单一MODULE中,这会极大地降低建模和设计的复杂性,使人们可以从容应对这些工作。
MODULE和较小的元素应该共同演变,但实际上它们并不是这样。MODULE被用来组织早期对象。在这之后,对象在变化时不脱离现有MODULE定义的边界。重构MODULE需要比重构类做更多工作,也具有更大的破坏性,并且可能不会特别频繁。但就像模型对象从简单具体逐渐转变为反映更深层次的本质一样,MODULE也会变得微妙和抽象。让MODULE反映出对领域理解的不断变化,可以使MODULE中的对象能够更自由地演变。
像领域驱动设计中的其他元素一样,MODULE是一种表达机制。MODULE的选择应该取决于被划分到MODULE中的对象的意义。当你将一些类放到MODULE中时,相当于告诉下一位看到你的设计的开发人员要把这些类放在一起考虑。如果说模型讲述了一个故事,那么MODULE就是这个故事的各个章节。模块的名称表达了其意义。这些名称应该被添加到UBIQUITOUS LANGUAGE中。你可能会向一位业务专家说,现在让我们讨论一下"客户"Module,这就为你们接下来的对话设定了上下文。因此:
选择能够描述系统的MODULE,并使之包含一个内聚的概念集合。这通常会实现MODULE之间的低耦合,但如果效果不理想,则应寻找一种更改模型的方式来消除概念之间的耦合,或者找到一个可作为MODULE基础的概念(这个概念先前可能被忽视了),基于这个概念组织的MODULE可以以一种有意义的方式将元素集中到一起。找到一种低耦合的概念组织方式,从而可以相互独立地理解和分析这些概念。对模型进行精化,直到可以根据高层领域概念对模型进行划分,同时相应的代码也不会产生耦合。
MODULE的名称应该是UBIQUITOUS LANGUAGE中的术语。MODULE及其名称应反映出领域的深层知识。
仅仅研究概念关系是不够的,它并不能替代技术措施。这二者是相同问题的不同层次,都是必须要完成的。但是,只有以模型为中心进行思考,才能得到更深层次的解决方案,而不是随便找一个解决方案应付了事。当必须做出一个折中选择时,务必保证概念清晰,即使这意味着MODULE之间会产生更多引用,或者更改MODULE偶尔会产生"涟漪效应"。开发人员只要理解了模型所描述的内容,就可以应付这些问题。
敏捷的MODULE
MODULE需要与模型的其他部分一同演变。这意味着MODULE的重构必须与模型和代码一起进行。但这种重构通常不会发生。更改MODULE可能需要大范围地更新代码。这些更改可能会对团队沟通起到破坏作用,甚至会妨碍开发工具(如源代码控制系统)的使用。因此,MODULE结构和名称往往反映了模型的较早形式,而类则不是这样。
在MODULE选择的早期,有些错误是不可避免的,这些错误导致了高耦合,从而使MODULE很难进行重构。而缺乏重构又会导致问题变得更加严重。克服这一问题的唯一方法是接受挑战,仔细地分析问题的要害所在,并据此重新组织MODULE。
一些开发工具和编程系统会使问题变得更加严重。无论在实现中采用哪种开发技术,我们要想尽一切办法来减少重构MODULE的工作量,并最大限度地减少与其他开发人员沟通时出现的混乱情况。
通过基础设施打包时存在的隐患
技术框架对打包决策有着极大的影响,有些技术框架是有帮助的,有些则要坚决抵制。
一个非常有用的框架标准是LAYERED ARCHITECTURE,它将基础设施和用户界面代码放到两组不同的包中,并且从物理上把领域层隔离到它自己的一组包中。
但从另一个方面看,分层架构可能导致模型对象实现的分裂。一些框架的分层方法是把一个领域对象的职责分散到多个对象当中,然后把这些对象放到不同的包中。例如,当使用J2EE早期版本时,一种常见的做法是把数据和数据访问放到实体bean中,而把相关的业务逻辑放到会话bean中。这样做除了导致每个组件的实现变得更复杂以外,还破坏了对象模型的内聚性。对象的一个最基本的概念是将数据和操作这些数据的逻辑封装在一起。由于我们可以把这两个组件看作是一起组成一个单一模型元素的实现,因此这种分层实现还不算是致命的。但实体bean和会话bean通常被隔离到不同的包中,从而使情况变得更糟。在这种情况下,通过查看若干对象并把它们脑补成单一的概念ENTITY是非常困难的。我们失去了模型与设计之间的联系。最好的做法是在比ENTITY对象更大的粒度上应用EJB,从而减少分层的副作用。
但细粒度的对象通常也会被分层。例如,我就曾经在一个筹划得相当不错的项目上遇到过这些问题,这个项目的每个概念模型实际上被分为4层。每个层的划分都有很好的理由。第一层是数据持久层,负责处理映射和访问关系数据库。第二层负责处理对象在所有情况下的固有行为。第三层放置特定于应用程序的功能。第四层是一个公共接口,它隐藏了第一、二、三层的所有实现细节。这种分层方案有些复杂,但每层都有很好的定义,而且清楚地实现了关注点的分离。我们可以在大脑中将所有物理对象连接到一起,组成一个概念对象。有时,方面的分离也是有帮助的。具体来讲,把持久化代码移出来可以减少很多混乱。
但最重要的是,这个项目的框架要求将每个层放到单独的一组包中,并根据层的标识惯例来命名。这一下子就把我们所有的注意力都吸引到分层上来。结果,领域开发人员尽量避免创建太多的MODULE(每个模块都要乘以4),而且几乎不能更改模块,因为重构MODULE的工作量不允许这样做。更糟的是,由于很难跟踪定义了一个概念类的所有数据和行为(而且还要考虑分层产生的间接关系),因此开发人员没有多少精力思考模型了。这个应用最终交付使用了,但它使用了贫血领域模型,只是基本满足了应用程序的数据库访问需求,此外通过很少的几个SERVICE提供了一些行为。这个项目从MODEL-DRIVEN DESIGN获得的益处十分有限,因为代码并没有清晰地揭示模型,因此开发人员也无法充分地利用模型。
这种框架设计是在尝试解决两个合理的问题。一个问题是关注点的逻辑划分:一个对象负责数据库访问,另外一个对象负责处理业务逻辑,等等。这种划分方法使人们更容易(在技术层面上)理解每个层的功能,而且更容易切换各个层。这种设计的问题在于没有顾及应用程序的开发成本。本书不是讨论框架设计的书,因此不会给出此问题的替代解决方案,但它们确实存在。而且,即使别无选择,也值得牺牲一些分层的好处来换取更内聚的领域层。
这些打包方案的另一个动机是层的分布。如果代码实际上被部署到不同的服务器上,那么这会成为这种分层的有力论据。但通常并不是这样。应该在需要时才寻求灵活性。在一个希望充分利用MODEL-DRIVEN DESIGN的项目上,这种分层设计的牺牲太大了,除非它是为了解决一个紧迫的问题。
精巧的技术打包方案会产生如下两个代价。
如果框架的分层惯例把实现概念对象的元素分得很零散,那么代码将无法再清楚地表示模型。
人的大脑把划分后的东西还原成原样的能力是有限的,如果框架把人的这种能力都耗尽了,那么领域开发人员就无法再把模型还原成有意义的部分了。
最好把事情变简单。要极度简化技术分层规则,要么这些规则对技术环境特别重要,要么这些规则真正有助于开发。例如,将复杂的数据持久化代码从对象的行为方面提取出来可以使重构变得更简单。
除非真正有必要将代码分布到不同的服务器上,否则就把实现单一概念对象的所有代码放在同一个模块中(如果不能放在同一个对象中的话)。
从传统的"高内聚、低耦合"标准也可以得出相同的结论。实现业务逻辑的对象与负责数据库访问的对象之间的联系非常广泛,因此它们之间的耦合度很高。
在框架设计中,或者在公司或项目的工作惯例方面,可能还有其他一些隐患,这些隐患可能会妨碍领域模型的自然内聚性,从而破坏模型驱动的设计,但所有隐患的基本问题都是相同的。种种限制(或者只是由于所需的包太多了)使我们无法使用专门根据领域模型需要量身定做的其他打包方案。
利用打包把领域层从其他代码中分离出来。否则,就尽可能让领域开发人员自由地决定领域对象的打包方式,以便支持他们的模型和设计选择。
如果代码是基于声明式设计(第10章有这方面的讨论)生成的,则是一种例外情况。在这种情况下,开发人员无需阅读代码,因此为了不碍事最好将代码放到一个单独的包中,这样就不会搞乱开发人员实际要处理的设计元素。
随着设计规模和复杂度的增加,模块化变得更加重要。本节只是介绍了一些基本的注意事项。本书第四部分主要介绍打包方法以及分解大型模型和设计的方法,并介绍如何抓住重点以帮助理解问题。
领域模型中的每个概念都应该在实现元素中反映出来。ENTITY、VALUE OBJECT、它们之间的关联、领域SERVICE以及用于组织元素的MODULE都是实现与模型直接对应的地方。实现中的对象、指针和检索机制必须直接、清楚地映射到模型元素。如果没有做到这一点,就要重写代码,或者回头修改模型,或者同时修改代码和模型。
不要在领域对象中添加任何与领域对象所表示的概念没有紧密关系的元素。领域对象的职责是表示模型。当然,其他一些与领域有关的职责也是必须要实现的,而且为了使系统工作,也必须管理其他数据,但它们不属于领域对象。此外,还有一些支持对象,这些对象履行领域层的技术职责,如定义数据库搜索和封装复杂的对象创建。
本章介绍的4种模式为对象模型提供了构造块。但MODEL-DRIVEN DESIGN并不是说必须将每个元素都建模为对象。一些工具还支持其他的模型范式,如规则引擎。项目需要在它们之间做出契合实际的折中选择。这些其他的工具和技术是MODEL-DRIVEN DESIGN的补充,而不是要取而代之。
建模范式
MODEL-DRIVEN DESIGN要求使用一种与建模范式协调的实现技术。人们曾经尝试了大量的建模范式,但在实践中只有少数几种得到了广泛应用。目前,主流的范式是面向对象设计,而且现在的大部分复杂项目都开始使用对象。这种范式的流行有许多原因,包括对象本身的固有因素、一些环境因素,以及广泛使用所带来的一些优势。
对象范式流行的原因
一些团队选择对象范式并不是出于技术上的原因,甚至也不是出于对象本身的原因,而是从一开始,对象建模就在简单性和复杂性之间实现了一个很好的平衡。
如果一个建模范式过于深奥,那么大多数开发人员可能无法掌握它,因此也无法正确地运用它。如果团队中的非技术人员无法掌握范式的基本知识,那么他们将无法理解模型,以至于无法建立UBIQUITOUS LANGUAGE。大部分人都比较容易理解面向对象设计的基本知识。尽管一些开发人员还没有完全领悟建模的奥妙,但即使是非专业人员也可以理解对象模型图。
然而,虽然对象建模的概念很简单,但它的丰富功能足以捕获重要的领域知识。而且它从一开始就获得了开发工具的支持,使得模型可以在软件中表达出来。现在,对象范式已经发展很成熟并得到了广泛采用,这使得它具有明显的优势。项目如果没有成熟的基础设施和工具支持,可能就要在这些方面进行研发工作,这不仅会耽误应用程序的开发,分散应用程序的开发资源,还会带来技术风险。有些技术不能与其他技术很好地协同工作,而且它们可能也无法与行业标准解决方案集成,这使团队不得不重新开发一些常用的辅助工具。但近年来,很多这样的问题已经在对象领域得以解决,而且有些问题也随着对象范式的广泛采用而变得无关紧要(现在,对象技术已经成为主流,因此集成的任务已经落到其他方法的肩上)。大多数新技术都提供了与主流的面向对象平台进行集成的方式。这使得集成更容易,甚至允许将基于其他建模范式的子系统混合在一起(本章稍后将讨论)。
开发者社区和设计文化的成熟也同样重要。采用新范式的项目可能很难找到精通它的开发人员,也很难找到能够使用新范式创建有效模型的人员。要想在短时间内培训开发人员使用新范式往往是行不通的,因为能够最大限度地利用新范式和技术的模式尚未形成。或许新领域的一些开拓者已经可以有效地使用新范式,但他们尚未发布可供人们学习的知识。
而对象范式则不同,大多数开发人员、项目经理和从事项目工作的其他专家都已经很了解它。面向对象技术已经相对成熟。业内已经提供了很多现成的解决方案,它们可以满足大部分常见的基础设施需要。多数大型供应商,或者稳定的开源项目都提供了关键工具。这些基础设施本身就已经被广泛使用,因此了解它们的人很多,相关书籍也很多,等等。人们已经相当了解这些成熟技术的局限性,因此内行团队也不会过度使用它们。
其他一些令人感兴趣的建模范式并没有这么成熟。有些建模范式太难掌握了,以至于只能在很小的专业领域内使用。有些建模范式虽然有潜力,但技术基础设施仍然不够完整、可靠,而且很少有人理解为这些范式创建良好模型的诀窍。这些范式可能已经出现很长一段时间了,但仍然不适合用于大多数项目。
这就是目前大部分采用MODEL-DRIVEN DESIGN的项目很明智地使用面向对象技术作为系统核心的原因。它们不会被束缚在只有对象的系统里,因为对象已经成为内业的主流技术,人们目前使用的几乎所有的技术都有与之对应的集成工具。
然而,这并不意味着人们就应该永远只局限于对象技术。跟随主流具有一定的安全性,但这并非总是应该走的道路。对象模型可以解决很多实际的软件问题,但也有一些领域不适合用封装了行为的各种对象来建模。例如,涉及大量数学问题的领域或者受全局逻辑推理控制的领域就不适合使用面向对象的范式。
对象世界中的非对象
领域模型不一定是对象模型。例如,使用Prolog语言实现的MODEL-DRIVEN DESIGN,它的模型是由逻辑规则和事实构成的。模型范式为人们提供了思考领域的方式。这些领域的模型由范式塑造成型。结果就得到了遵守范式的模型,这样的模型可以用支持对应建模风格的工具来有效地实现。
不管在项目中使用哪种主要的模型范式,领域中都会有一些部分更容易用某种其他范式来表达。当领域中只有个别元素适合用其他范式时,开发人员可以接受一些蹩脚的对象,以使整个模型保持一致(或者,在另一种极端的情况下,如果大部分问题领域都更适合用其他范式来表达,那么可以整个改为使用那种范式,并选择一个不同的实现平台)。但是,当领域的主要部分明显属于不同的范式时,明智的做法是用适合各个部分的范式对其建模,并使用混合工具集来进行实现。当领域的各个部分之间的互相依赖性较小时,可以把用另一种范式建立的子系统封装起来,例如,只有一个对象需要调用的复杂数学计算。其他时候,不同方面之间的关系更为复杂,例如,对象的交互依赖于某些数学关系的时候。
这就是将业务规则引擎或工作流引擎这样的非对象组件集成到对象系统中的动机。混合使用不同的范式使得开发人员能够用最适当的风格对特殊概念进行建模。此外,大部分系统都必须使用一些非对象的技术基础设施,最常见的就是关系数据库。但是在使用不同的范式后,要想得到一个内聚的模型就比较难了,而且让不同的支持工具共存也较为复杂。当开发人员在软件中无法清楚地辨认出一个内聚的模型时,MODEL-DRIVEN DESIGN就会被抛诸脑后,尽管这种混合设计更需要它。
在混合范式中坚持使用MODEL-DRIVEN DESIGN
在面向对象的应用程序开发项目中,有时会混合使用一些其他的技术,规则引擎就是一个常见的例子。一个包含丰富知识的领域模型可能会含有一些显式的规则,然而对象范式却缺少用于表达规则和规则交互的具体语义。尽管可以将规则建模为对象(而且常常可以成功地做到),但对象封装却使得那些针对整个系统的全局规则很难应用。规则引擎技术非常有吸引力,因为它提供了一种更自然、声明式的规则定义方式,能够有效地将规则范式融合到对象范式中。逻辑范式已经得到了很好的发展并且功能强大,它是对象范式的很好补充,使其可以扬长避短。
但人们并不总是能够从规则引擎的使用中得到预期结果。有些产品并不能很好地工作。有些则缺少一种能够显示出衔接两种实现环境的模型概念相关性的无缝视图。一个常见的结果是应用程序被割裂成两部分:一个是使用了对象的静态数据存储系统,另一个是几乎完全与对象模型失去联系的某种规则处理应用程序。
重要的是在使用规则的同时要继续考虑模型。团队必须找到能够同时适用于两种实现范式的单一模型。虽然这并非易事,但还是可以办到的,条件是规则引擎支持富有表达力的实现方式。如果不这样,数据和规则就会失去联系。与领域模型中的概念规则相比,引擎中的规则更像是一些较小的程序。只有保持规则与对象之间紧密、清晰的关系,才能确保显示出这二者所表达的含义。
如果没有无缝的环境,就要完全靠开发人员提炼出一个由清晰的基本概念组成的模型,以便完全支撑整个设计。
将各个部分紧密结合在一起的最有效工具就是健壮的UBIQUITOUS LANGUAGE,它是构成整个异构模型的基础。坚持在两种环境中使用一致的名称,坚持用UBIQUITOUS LANGUAGE讨论这些名称,将有助于消除两种环境之间的鸿沟。
这个话题本身就值得写一本书了。本节的目的只是想说明(在使用其他范式时)没有必要放弃MODEL- DRIVEN DESIGN,而且坚持使用它是值得的。
虽然MODEL-DRIVEN DESIGN不一定是面向对象的,但它确实需要一种富有表达力的模型结构实现,无论是对象、规则还是工作流,都是如此。如果可用工具无法提高表达力,就要重新考虑选择工具。缺乏表达力的实现将削弱各种范式的优势。
当将非对象元素混合到以面向对象为主的系统中时,需要遵循以下4条经验规则。
不要和实现范式对抗。我们总是可以用别的方式来考虑领域。找到适合于范式的模型概念。
把UBIQUITOUS LANGUAGE作为依靠的基础。即使工具之间没有严格联系时,语言使用上的高度一致性也能防止各个设计部分分裂。
不要一味依赖UML。有时固定使用某种工具(如UML绘图工具)将导致人们通过歪曲模型来使它更容易画出来。例如,UML确实有一些特性很适合表达约束,但它并不是在所有情况下都适用。有时使用其他风格的图形(可能适用于其他范式)或者简单的语言描述比牵强附会地适应某种对象视图更好。
保持怀疑态度。工具是否真正有用武之地?不能因为存在一些规则,就必须使用规则引擎。规则也可以表示为对象,虽然可能不是特别优雅。多个范式会使问题变得非常复杂。
在决定使用混合范式之前,一定要确信主要范式中的各种可能性都已经尝试过了。尽管有些领域概念不是以明显的对象形式表现出来的,但它们通常可以用对象范式来建模。第9章将讨论如何使用对象技术对一些非常规类型的概念进行建模。
关系范式是范式混合的一个特例。作为一种最常用的非对象技术,关系数据库与对象模型的关系比其他技术与对象模型的关系更紧密,因为它作为一种数据持久存储机制,存储的就是对象。 第6章将讨论用关系数据库来存储对象数据,并介绍在对象生命周期中将会遇到的诸多挑战。
领域对象的生命周期
每个对象都有生命周期,如下图所示:
对象自创建后,可能会经历各种不同的状态,直至最终消亡——要么存档,要么删除。当然,很多对象是简单的临时对象,仅通过调用构造函数来创建,用来做一些计算,而后由垃圾收集器回收。这类对象没必要搞得那么复杂。但有些对象具有更长的生命周期,其中一部分时间不是在活动内存中度过的。它们与其他对象具有复杂的相互依赖性。它们会经历一些状态变化,在变化时要遵守一些固定规则。管理这些对象时面临诸多挑战,稍有不慎就会偏离MODEL-DRIVEN DESIGN的轨道。主要的挑战有以下两类。
(1) 在整个生命周期中维护完整性。
(2) 防止模型陷入管理生命周期复杂性造成的困境当中。
本章将通过3种模式解决这些问题。首先是AGGREGATE(聚合),它通过定义清晰的所属关系和边界,并避免混乱、错综复杂的对象关系网来实现模型的内聚。聚合模式对于维护生命周期各个阶段的完整性具有至关重要的作用。接下来,我们将注意力转移到生命周期的开始阶段,使用FACTORY(工厂)来创建和重建复杂对象和AGGREGATE(聚合),从而封装它们的内部结构。最后,在生命周期的中间和末尾使用REPOSITORY(存储库)来提供查找和检索持久化对象并封装庞大基础设施的手段。
尽管REPOSITORY和FACTORY本身并不是来源于领域,但它们在领域设计中扮演着重要的角色。这些结构提供了易于掌握的模型对象处理方式,使MODEL-DRIVEN DESIGN更完备。
使用AGGREGATE进行建模,并且在设计中结合使用FACTORY和REPOSITORY,这样我们就能够在模型对象的整个生命周期中,以有意义的单元、系统地操纵它们。AGGREGATE可以划分出一个范围,这个范围内的模型元素在生命周期各个阶段都应该维护其固定规则。FACTORY和REPOSITORY在AGGREGATE基础上进行操作,将特定生命周期转换的复杂性封装起来。
模式:AGGREGATE
AGGREGATE(聚合)——聚合就是一组相关对象的集合,我们把聚合作为数据修改的单元。外部对象只能引用聚合中的一个成员,我们把它称为根(Root)。在聚合的边界之内应用一组一致的规则。
减少设计中的关联有助于简化对象之间的遍历,并在某种程度上限制关系的急剧增多。但大多数业务领域中的对象都具有十分复杂的联系,以至于最终会形成很长、很深的对象引用路径,我们不得不在这个路径上追踪对象。在某种程度上,这种混乱状态反映了现实世界,因为现实世界中就很少有清晰的边界。但这却是软件设计中的一个重要问题。
假设我们从数据库中删除一个Person对象。这个人的姓名、出生日期和工作描述要一起被删除,但要如何处理地址呢?可能还有其他人住在同一地址。如果删除了地址,那些Person对象将会引用一个被删除的对象。如果保留地址,那么垃圾地址在数据库中会累积起来。虽然自动垃圾收集机制可以清除垃圾地址,但这也只是一种技术上的修复;就算数据库系统存在这种处理机制,一个基本的建模问题依然被忽略了。
即便是在考虑孤立的事务时,典型对象模型中的关系网也使我们难以断定一个修改会产生哪些潜在的影响。仅仅因为存在依赖就更新系统中的每个对象,这样做是不现实的。在多个客户对相同对象进行并发访问的系统中,这个问题更加突出。当很多用户对系统中的对象进行查询和更新时,必须防止他们同时修改互相依赖的对象。范围错误将导致严重的后果。
在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。
固定规则(invariant)——一种为某些设计元素做出的断言,除了一些特殊的临时情况(例如,方法执行的中间,或者尚未提交的数据库事务的中间)以外,它必须一直保持为真。
换句话说,我们如何知道一个由其他对象组成的对象从哪里开始,又到何处结束呢?在任何具有持久化数据存储的系统中,对数据进行修改的事务必须要有范围,而且要有保持数据一致性的方式(也就是说,保持数据遵守固定规则)。数据库支持各种锁机制,而且可以编写一些测试来验证。但这些特殊的解决方案分散了人们对模型的注意力,很快人们就会回到"走一步,看一步"的老路上来。
实际上,要想找到一种兼顾各种问题的解决方案,要求对领域有深刻的理解,例如,要了解特定类实例之间的更改频率这样的深层次因素。我们需要找到一个使对象间冲突较少而固定规则联系更紧密的模型。
尽管从表面上看这个问题是数据库事务方面的一个技术难题,但它的根源却在模型,归根结底是由于模型中缺乏明确定义的边界。从模型得到的解决方案将使模型更易于理解,并且使设计更易于沟通。当模型被修改时,它将引导我们对实现做出修改。
人们已经开发出很多模式(scheme)来定义模型中的所属关系。下面这个简单但严格的系统就提炼自这些概念,其包括一组用于实现事务(这些事务用来修改对象及其所有者)的规则。
首先,我们需要用一个抽象来封装模型中的引用。AGGREGATE就是一组相关对象的集合,我们把它作为数据修改的单元。每个AGGREGATE都有一个根(root)和一个边界(boundary)。边界定义了AGGREGATE的内部都有什么。根则是AGGREGATE所包含的一个特定ENTITY。对AGGREGATE而言,外部对象只可以引用根,而边界内部的对象之间则可以互相引用。除根以外的其他ENTITY都有本地标识,但这些标识只在AGGREGATE内部才需要加以区别,因为外部对象除了根ENTITY之外看不到其他对象。
汽车修配厂的软件可能会使用汽车模型。如下图所示:
汽车是一个具有全局标识的ENTITY: 我们需要将这部汽车与世界上所有其他汽车区分开(即使是一些非常相似的汽车)。我们可以使用车辆识别号来进行区分,车辆识别号是为每辆新汽车分配的唯一标识符。我们可能想通过4个轮子的位置跟踪轮胎的转动历史。我们可能想知道每个轮胎的里程数和磨损度。要想知道哪个轮胎在哪儿,必须将轮胎标识为ENTITY。当脱离这辆车的上下文后,我们很可能就不再关心这些轮胎的标识了。如果更换了轮胎并将旧轮胎送到回收厂,那么软件将不再需要跟踪它们,它们会成为一堆废旧轮胎中的一部分。没有人会关心它们的转动历史。更重要的是,即使轮胎被安在汽车上,也不会有人通过系统查询特定的轮胎,然后看看这个轮胎在哪辆汽车上。人们只会在数据库中查找汽车,然后临时查看一下这部汽车的轮胎情况。因此,汽车是AGGREGATE的根ENTITY,而轮胎处于这个AGGREGATE的边界之内。另一方面,发动机组上面都刻有序列号,而且有时是独立于汽车被跟踪的。在一些应用程序中,发动机可以是自己的AGGREGATE的根。
**固定规则(invariant)是指在数据变化时必须保持的一致性规则,其涉及AGGREGATE成员之间的内部关系。**而任何跨越AGGREGATE的规则将不要求每时每刻都保持最新状态。通过事件处理、批处理或其他更新机制,这些依赖会在一定的时间内得以解决。但在每个事务完成时,AGGREGATE内部所应用的固定规则必须得到满足,如下图所示:
现在,为了实现这个概念上的AGGREGATE,需要对所有事务应用一组规则。
根ENTITY具有全局标识,它最终负责检查固定规则。
根ENTITY具有全局标识。边界内的ENTITY具有本地标识,这些标识只在AGGREGATE内部才是唯一的。
AGGREGATE外部的对象不能引用除根ENTITY之外的任何内部对象。根ENTITY可以把对内部ENTITY的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个VALUE OBJECT的副本传递给另一个对象,而不必关心它发生什么变化,因为它只是一个VALUE,不再与AGGREGATE有任何关联。
作为上一条规则的推论,只有AGGREGATE的根才能直接通过数据库查询获取。所有其他对象必须通过遍历关联来发现。
AGGREGATE内部的对象可以保持对其他AGGREGATE根的引用。
删除操作必须一次删除AGGREGATE边界之内的所有对象。(利用垃圾收集机制,这很容易做到。由于除根以外的其他对象都没有外部引用,因此删除了根以后,其他对象均会被回收。)
当提交对AGGREGATE边界内部的任何对象的修改时,整个AGGREGATE的所有固定规则都必须被满足。
我们应该将ENTITY和VALUE OBJECT分门别类地聚集到AGGREGATE中,并定义每个AGGREGATE的边界。在每个AGGREGATE中,选择一个ENTITY作为根,并通过根来控制对边界内其他对象的所有访问。只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过它来修改内部对象。这种设计有利于确保AGGREGATE中的对象满足所有固定规则,也可以确保在任何状态变化时AGGREGATE作为一个整体满足固定规则。
有一个能够声明AGGREGATE的技术框架是很有帮助的,这样就可以自动实施锁机制和其他一些功能,如数据库的事务框架。如果没有这样的技术框架,团队就必须靠自我约束来使用事先商定的AGGREGATE,并按照这些AGGREGATE来编写代码。
AGGREGATE划分出一个范围,在这个范围内,生命周期的每个阶段都必须满足一些固定规则。接下来要讨论的两种模式FACTORY和REPOSITORY都是在AGGREGATE上执行操作,它们将特定生命周期转换的复杂性封装起来。
模式:FACTORY
当创建一个对象或创建整个AGGREGATE时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用FACTORY进行封装。
对象的功能主要体现在其复杂的内部配置以及关联方面。我们应该一直对对象进行提炼,直到所有与其意义或在交互中的角色无关的内容被完全剔除为止。一个对象在它的生命周期中要承担大量职责。如果再让复杂对象负责自身的创建,那么职责过载将会导致问题。
汽车发动机是一种复杂的机械装置,它由数十个零件共同协作来履行发动机的职责——使轴转动。我们可以试着设计一种发动机组,让它自己抓取一组活塞并塞到汽缸中,火花塞也可以自己找到插孔并把自己拧进去。但这样组装的复杂机器可能没有我们常见的发动机那样可靠或高效。相反,我们用其他东西来装配发动机。或许是机械师,或者是工业机器人。无论是机器人还是人,实际上都比二者要装配的发动机复杂。装配零件的工作与使轴旋转的工作完全无关。只是在生产汽车时才需要装配工,我们驾驶时并不需要机器人或机械师。由于汽车的装配和驾驶永远不会同时发生,因此将这两种功能合并到同一个机制中是毫无价值的。同理,装配复杂的复合对象的工作也最好与对象要执行的工作分开。
但将职责转交给另一个相关方——应用程序中的客户(client)对象——会产生更严重的问题。客户知道需要完成什么工作,并依靠领域对象来执行必要的计算。如果指望客户来装配它需要的领域对象,那么它必须要了解一些对象的内部结构。为了确保所有应用于领域对象各部分关系的固定规则得到满足,客户必须知道对象的一些规则。甚至调用构造函数也会使客户与所要构建的对象的具体类产生耦合。结果是,对领域对象实现所做的任何修改都要求客户做出相应修改,这使得重构变得更加困难。
当客户负责创建对象时,它会牵涉不必要的复杂性,并将其职责搞得模糊不清。这违背了领域对象及所创建的AGGREGATE的封装要求。更严重的是,如果客户是应用层的一部分,那么职责就会从领域层泄漏到应用层中。应用层与实现细节之间的这种耦合使得领域层抽象的大部分优势荡然无存,而且导致后续更改的代价变得更加高昂。
对象的创建本身可以是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能产生难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或AGGREGATE的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合。
复杂的对象创建是领域层的职责,然而这项任务并不属于那些用于表示模型的对象。在有些情况下,对象的创建和装配对应于领域中的重要事件,如"开立银行账户"。但一般情况下,对象的创建和装配在领域中并没有什么意义,它们只不过是实现的一种需要。为了解决这一问题,我们必须在领域设计中增加一种新的构造,它不是ENTITY、VALUE OBJECT,也不是SERVICE。这与前一章的论述相违背,因此把它解释清楚很重要。我们正在向设计中添加一些新元素,但它们不对应于模型中的任何事物,而确实又承担领域层的部分职责。
每种面向对象的语言都提供了一种创建对象的机制(例如,Java和C++中的构造函数,Smalltalk中创建实例的类方法),但我们仍然需要一种更加抽象且不与其他对象发生耦合的构造机制。这就是FACTORY,它是一种负责创建其他对象的程序元素。如下图所示:
正如对象的接口应该封装对象的实现一样(从而使客户无需知道对象的工作机理就可以使用对象的功能),FACTORY封装了创建复杂对象或AGGREGATE所需的知识。它提供了反映客户目标的接口,以及被创建对象的抽象视图。因此:
应该将创建复杂对象的实例和AGGREGATE的职责转移给单独的对象,这个对象本身可能没有承担领域模型中的职责,但它仍是领域设计的一部分。提供一个封装所有复杂装配操作的接口,而且这个接口不需要客户引用要被实例化的对象的具体类。在创建AGGREGATE时要把它作为一个整体,并确保它满足固定规则。
本文的重点并不是深入讨论FACTORY的设计问题,而是要表明FACTORY的重要地位——它是领域设计的重要组件。正确使用FACTORY有助于保证MODEL-DRIVEN DESIGN沿正确的轨道前进。
任何好的工厂都需满足以下两个基本需求:
(1) 每个创建方法都是原子的,而且要保证被创建对象或AGGREGATE的所有固定规则。FACTORY生成的对象要处于一致的状态。在生成ENTITY时,这意味着创建满足所有固定规则的整个AGGREGATE,但在创建完成后可以向AGGREGATE添加可选元素。在创建不变的VALUE OBJECT时,这意味着所有属性必须被初始化为正确的最终状态。如果FACTORY通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者采用其他机制,以确保不会返回错误的值。
(2) FACTORY应该被抽象为所需的类型,而不是所要创建的具体类。[Gamma et al. 1995]中的高级FACTORY模式介绍了这一话题。
选择FACTORY及其应用位置
一般来说,FACTORY的作用是隐藏创建对象的细节,而且我们把FACTORY用在那些需要隐藏细节的地方。这些决定通常与AGGREGATE有关。
例如,如果需要向一个已存在的AGGREGATE添加元素,可以在AGGREGATE的根上创建一个FACTORY METHOD。这样就可以把AGGREGATE的内部实现细节隐藏起来,使任何外部客户看不到这些细节,同时使根负责确保AGGREGATE在添加元素时的完整性。
另一个示例是在一个对象上使用FACTORY METHOD,这个对象与生成另一个对象密切相关,但它并不拥有所生成的对象。当一个对象的创建主要使用另一个对象的数据(或许还有规则)时,则可以在后者的对象上创建一个FACTORY METHOD,这样就不必将后者的信息提取到其他地方来创建前者。这样做还有利于表达前者与后者之间的关系。
FACTORY与被构建对象之间是紧密耦合的,因此FACTORY应该只被关联到与被构建对象有着密切联系的对象上。当有些细节需要隐藏(无论要隐藏的是具体实现还是构造的复杂性)而又找不到合适的地方来隐藏它们时,必须创建一个专用的FACTORY对象或SERVICE。整个AGGREGATE通常由一个独立的FACTORY来创建,FACTORY负责把对根的引用传递出去,并确保创建出的AGGREGATE满足固定规则。如果AGGREGATE内部的某个对象需要一个FACTORY,而这个FACTORY又不适合在AGGREGATE根上创建,那么应该构建一个独立的FACTORY。但仍应遵守规则——把访问限制在AGGREGATE内部,并确保从AGGREGATE外部只能对被构建对象进行临时引用。
有些情况下只需使用构造函数
在很多代码中,所有实例都是通过直接调用类构造函数来创建的,或者是使用编程语言的最基本的实例创建方式。FACTORY的引入提供了巨大的优势,而这种优势往往并未得到充分利用。但是,在有些情况下直接使用构造函数确实是最佳选择。FACTORY实际上会使那些不具有多态性的简单对象复杂化。在以下情况下最好使用简单的、公共的构造函数。
类(class)是一种类型(type)。它不是任何相关层次结构的一部分,而且也没有通过接口实现多态性。
客户关心的是实现,可能是将其作为选择STRATEGY的一种方式。
客户可以访问对象的所有属性,因此向客户公开的构造函数中没有嵌套的对象创建。
构造并不复杂。
公共构造函数必须遵守与FACTORY相同的规则:它必须是原子操作,而且要满足被创建对象的所有固定规则。
不要在构造函数中调用其他类的构造函数。构造函数应该保持绝对简单。复杂的装配,特别是AGGREGATE,需要使用FACTORY。使用FACTORY METHOD的门槛并不高。
Java类库提供了一些有趣的例子。所有集合都实现了接口,接口使得客户与具体实现之间不产生耦合。然而,它们都是通过直接调用构造函数创建的。但是,集合类本来是可以使用FACTORY来封装集合的层次结构的。而且,客户也可以使用FACTORY的方法来请求所需的特性,然后由FACTORY来选择适当的类来实例化。这样一来,创建集合的代码就会有更强的表达力,而且新增集合类时不会破坏现有的Java程序。
但在某些场合下使用具体的构造函数更为合适。首先,在很多应用程序中,实现方式的选择对性能的影响是非常敏感的,因此应用程序需要控制选择哪种实现(尽管如此,真正智能的FACTORY仍然可以满足这些因素的要求)。不管怎样,集合类的数量并不多,因此选择并不复杂。
虽然没有使用FACTORY,但抽象集合类型仍然具有一定价值,原因就在于它们的使用模式。集合通常都是在一个地方创建,而在其他地方使用。这意味着最终使用集合(添加、删除和检索其内容)的客户仍可以与接口进行对话,从而不与实现发生耦合。集合类的选择通常由拥有该集合的对象来决定,或是由该对象的FACTORY来决定。
接口的设计
当设计FACTORY的方法签名时,无论是独立的FACTORY还是FACTORY METHOD,都要记住以下两点。
每个操作都必须是原子的。我们必须在与FACTORY的一次交互中把创建对象所需的所有信息传递给FACTORY。同时必须确定当创建失败时将执行什么操作,比如某些固定规则没有被满足。可以抛出一个异常或仅仅返回null。为了保持一致,可以考虑采用编码标准来处理所有FACTORY的失败。
Factory将与其参数发生耦合。如果在选择输入参数时不小心,可能会产生错综复杂的依赖关系。耦合程度取决于对参数(argument)的处理。如果只是简单地将参数插入到要构建的对象中,则依赖度是适中的。如果从参数中选出一部分在构造对象时使用,耦合将更紧密。
最安全的参数是那些来自较低设计层的参数。即使在同一层中,也有一种自然的分层倾向,其中更基本的对象被更高层的对象使用。
另一个好的参数选择是模型中与被构建对象密切相关的对象,这样不会增加新的依赖。
使用抽象类型的参数,而不是它们的具体类。FACTORY与被构建对象的具体类发生耦合,而无需与具体的参数发生耦合。
固定规则的相关逻辑应放置在哪里
FACTORY负责确保它所创建的对象或AGGREGATE满足所有固定规则,然而在把应用于一个对象的规则移到该对象外部之前应三思。FACTORY可以将固定规则的检查工作委派给被创建对象,而且这通常是最佳选择。
但FACTORY与被创建对象之间存在一种特殊关系。FACTORY已经知道被创建对象的内部结构,而且创建FACTORY的目的与被创建对象的实现有着密切的联系。在某些情况下,把固定规则的相关逻辑放到FACTORY中是有好处的,这样可以让被创建对象的职责更明晰。对于AGGREGATE规则来说尤其如此(这些规则会约束很多对象)。但固定规则的相关逻辑却特别不适合放到那些与其他领域对象关联的FACTORY METHOD中。
虽然原则上在每个操作结束时都应该应用固定规则,但通常对象所允许的转换可能永远也不会用到这些规则。可能ENTITY标识属性的赋值需要满足一条固定规则。但该标识在创建后可能一直保持不变。VALUE OBJECT则是完全不变的。如果规定规则在对象的有效生命周期内永远也不被用到,那么对象就没有必要携带这个规定规则。在这种情况下,FACTORY是放置固定规则的合适地方,这样可以使FACTORY创建出的对象更简单。
ENTITY FACTORY与VALUE OBJECT FACTORY
ENTITY FACTORY与VALUE OBJECT FACTORY有两个方面的不同。由于VALUE OBJECT是不可变的,因此,FACTORY所生成的对象就是最终形式。因此FACTORY操作必须得到被创建对象的完整描述。而ENTITY FACTORY则只需具有构造有效AGGREGATE所需的那些属性。对于固定规则不关心的细节,可以之后再添加。
我们来看一下为ENTITY分配标识时将涉及的问题(VALUE OBJECT不会涉及这些问题)。如果客户的标识是通过电话号码跟踪的,那么该电话号码必须作为参数被显式地传递给FACTORY。当由程序分配标识符时,FACTORY是控制它的理想场所。尽管唯一跟踪ID实际上是由数据库"序列"或其他基础设施机制生成的,但FACTORY知道需要什么样的标识,以及将标识放到何处。
重建已存储的对象
到目前为止,FACTORY只是发挥了它在对象生命周期开始时的作用。到了某一时刻,大部分对象都要存储在数据库中或通过网络传输,而在当前的数据库技术中,几乎没有哪种技术能够保持对象的内容特征。大多数传输方法都要将对象转换为平面数据才能传输,这使得对象只能以非常有限的形式出现。因此,检索操作潜在地需要一个复杂的过程将各个部分重新装配成一个可用的对象。
用于重建对象的FACTORY与用于创建对象的FACTORY很类似,主要有以下两点不同。
(1) 用于重建对象的ENTITY FACTORY不分配新的跟踪ID。如果重新分配ID,将丢失与先前对象的连续性。因此,在重建对象的FACTORY中,标识属性必须是输入参数的一部分。
(2) 当固定规则未被满足时,重建对象的FACTORY采用不同的方式进行处理。当创建新对象时,如果未满足固定规则,FACTORY应该简单地拒绝创建对象,但在重建对象时则需要更灵活的响应。如果对象已经在系统的某个地方存在(如在数据库中),那么不能忽略这个事实。但是,同样也不能任凭规则被破坏。必须通过某种策略来修复这种不一致的情况,这使得重建对象比创建新对象更困难。
总之,必须把创建实例的访问点标识出来,并显式地定义它们的范围。它们可能只是构造函数,但通常需要有一种更抽象或更复杂的实例创建机制。为了满足这种需求,需要在设计中引入新的构造——FACTORY。FACTORY通常不表示模型的任何部分,但它们是领域设计的一部分,能够使对象更明确地表示出模型。
FACTORY封装了对象创建和重建时的生命周期转换。还有一种转换大大增加了领域设计的技术复杂性,这是对象与存储之间的互相转换。这种转换由另一种领域设计构造来处理,它就是REPOSITORY。
模式:REPOSITORY
REPOSITORY(存储库)——一种把存储、检索和搜索行为封装起来的机制,它类似于一个对象集合。
我们可以通过对象之间的关联来找到对象。但当它处于生命周期的中间时,必须要有一个起点,以便从这个起点遍历到一个ENTITY或VALUE。
无论要用对象执行什么操作,都需要保持一个对它的引用。那么如何获得这个引用呢?一种方法是创建对象,因为创建操作将返回对新对象的引用。第二种方法是遍历关联。我们以一个已知对象作为起点,并向它请求一个关联的对象。这样的操作在任何面向对象的程序中都会大量用到,而且对象之间的这些链接使对象模型具有更强的表达能力。但我们必须首先获得作为起点的那个对象。
数据库搜索是全局可访问的,它使我们可以直接访问任何对象。由此,所有对象不需要相互联接起来,整个对象关系网就能够保持在可控的范围内。是提供遍历还是依靠搜索,这成为一个设计决策,需要在搜索的解耦与关联的内聚之间做出权衡。恰当地结合搜索与关联将会得到易于理解的设计。
遗憾的是,开发人员一般不会过多地考虑这种精细的设计,因为他们满脑子都是需要用到的机制,以便很有技巧地利用它们来实现对象的存储、取回和最终删除。
现在,从技术的观点来看,检索已存储对象实际上属于创建对象的范畴,因为从数据库中检索出来的数据要被用来组装新的对象。实际上,由于需要经常编写这样的代码,我们对此形成了根深蒂固的观念。但从概念上讲,对象检索发生在ENTITY生命周期的中间。本文把使用已存储的数据创建实例的过程称为重建。
领域驱动设计的目标是通过关注领域模型(而不是技术)来创建更好的软件。假设开发人员构造了一个SQL查询,并将它传递给基础设施层中的某个查询服务,然后再根据得到的表行数据的结果集提取出所需信息,最后将这些信息传递给构造函数或FACTORY。开发人员执行这一连串操作的时候,早已不再把模型当作重点了。我们很自然地会把对象看作容器来放置查询出来的数据,这样整个设计就转向了数据处理风格。虽然具体的技术细节有所不同,但问题仍然存在——客户处理的是技术,而不是模型概念。诸如METADATA MAPPING LAYER[Fowler 2002]这样的基础设施可以提供很大帮助,利用它很容易将查询结果转换为对象,但开发人员考虑的仍然是技术机制,而不是领域。更糟的是,当客户代码直接使用数据库时,开发人员会试图绕过模型的功能(如AGGREGATE,甚至是对象封装),而直接获取和操作他们所需的数据。这将导致越来越多的领域规则被嵌入到查询代码中,或者干脆丢失了。虽然对象数据库消除了转换问题,但搜索机制还是很机械的,开发人员仍倾向于要什么就去拿什么。
客户需要一种有效的方式来获取对已存在的领域对象的引用。如果基础设施提供了这方面的便利,那么开发人员可能会增加很多可遍历的关联,这会使模型变得非常混乱。另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取具体的对象,而不是通过AGGREGATE的根来得到这些对象。这样就导致领域逻辑进入查询和客户代码中,而ENTITY和VALUE OBJECT则变成单纯的数据容器。采用大多数处理数据库访问的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员简化领域层,最终使模型变得无关紧要。
根据到目前为止所讨论的设计原则,如果我们找到一种访问方法,它能够明确地将模型作为焦点,从而应用这些原则,那么我们就可以在某种程度上缩小对象访问问题的范围。初学者可以不必关心临时对象。临时对象(通常是VALUE OBJECT)只存在很短的时间,在客户操作中用到它们时才创建它们,用完就删除了。我们也不需要对那些很容易通过遍历来找到的持久对象进行查询访问。例如,地址可以通过Person对象获取。而且最重要的是,除了通过根来遍历查找对象这种方法以外,禁止用其他方法对AGGREGATE内部的任何对象进行访问。
持久化的VALUE OBJECT一般可以通过遍历某个ENTITY来找到,在这里ENTITY就是把对象封装在一起的AGGREGATE的根。事实上,对VALUE OBJECT的全局搜索访问常常是没有意义的,因为通过属性找到VALUE OBJECT相当于用这些属性创建一个新实例。但也有例外情况。例如,当我在线规划旅行线路时,有时会先保存几个中意的行程,过后再回头从中选择一个来预订。这些行程就是VALUE(如果两个行程由相同的航班构成,那么我不会关心哪个是哪个),但它们已经与我的用户名关联到一起了,而且可以原封不动地将它们检索出来。另一个例子是"枚举",在枚举中一个类型有一组严格限定的、预定义的可能值。但是,对VALUE OBJECT的全局访问比对ENTITY的全局访问更少见,如果确实需要在数据库中搜索一个已存在的VALUE,那么值得考虑一下,搜索结果可能实际上是一个ENTITY,只是尚未识别它的标识。
从上面的讨论显然可以看出,大多数对象都不应该通过全局搜索来访问。如果很容易就能从设计中看那些确实需要全局搜索访问的对象,那该有多好!现在可以更精确地将问题重新表述如下:
在所有持久化对象中,有一小部分必须通过基于对象属性的搜索来全局访问。当很难通过遍历方式来访问某些AGGREGATE根的时候,就需要使用这种访问方式。它们通常是ENTITY,有时是具有复杂内部结构的VALUE OBJECT,还可能是枚举VALUE。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。随意的数据库查询会破坏领域对象的封装和AGGREGATE。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计。
有大量的技术可以用来解决数据库访问的技术难题,例如,将SQL封装到QUERY OBJECT中,或利用METADATA MAPPING LAYER进行对象和表之间的转换[Fowler 2002]。FACTORY可以帮助重建那些已存储的对象(本章后面将会讨论)。这些技术和很多其他技术有助于控制数据库访问的复杂度。
有得必有失,我们应该注意失去了什么。我们已经不再考虑领域模型中的概念。代码也不再表达业务,而是对数据库检索技术进行操纵。REPOSITORY是一个简单的概念框架,它可用来封装这些解决方案,并将我们的注意力重新拉回到模型上。
REPOSITORY将某种类型的所有对象表示为一个概念集合(通常是模拟的)。它的行为类似于集合(collection),只是具有更复杂的查询功能。在添加或删除相应类型的对象时,REPOSITORY的后台机制负责将对象添加到数据库中,或从数据库中删除对象。这个定义将一组紧密相关的职责集中在一起,这些职责提供了对AGGREGATE根的整个生命周期的全程访问。
客户使用查询方法向REPOSITORY请求对象,这些查询方法根据客户所指定的条件(通常是特定属性的值)来挑选对象。REPOSITORY检索被请求的对象,并封装数据库查询和元数据映射机制。REPOSITORY可以根据客户所要求的各种条件来挑选对象。它们也可以返回汇总信息,如有多少个实例满足查询条件。REPOSITORY甚至能返回汇总计算,如所有匹配对象的某个数值属性的总和。
REPOSITORY解除了客户的巨大负担,使客户只需与一个简单的、易于理解的接口进行对话,并根据模型向这个接口提出它的请求。要实现所有这些功能需要大量复杂的技术基础设施,但接口很简单,而且在概念层次上与领域模型紧密联系在一起。因此:
为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的"替身"。通过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体条件来挑选对象的方法,并返回属性值满足查询条件的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给REPOSITORY来完成。
REPOSITORY有很多优点,包括:
它们为客户提供了一个简单的模型,可用来获取持久化对象并管理它们的生命周期
它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦
它们体现了有关对象访问的设计决策
可以很容易将它们替换为"哑实现"(dummy implementation),以便在测试中使用(通常使用内存中的集合)
REPOSITORY的查询
所有REPOSITORY都为客户提供了根据某种条件来查询对象的方法,但如何设计这个接口却有很多选择。
最容易构建的REPOSITORY用硬编码的方式来实现一些具有特定参数的查询。这些查询可以形式各异,例如,通过标识来检索ENTITY(几乎所有REPOSITORY都提供了这种查询)、通过某个特定属性值或复杂的参数组合来请求一个对象集合、根据值域(如日期范围)来选择对象,甚至可以执行某些属于REPOSITORY一般职责范围内的计算(特别是利用那些底层数据库所支持的操作)。
尽管大多数查询都返回一个对象或对象集合,但返回某些类型的汇总计算也符合REPOSITORY的概念,如对象数目,或模型需要对某个数值属性进行求和统计。
在任何基础设施上,都可以构建硬编码式的查询,也不需要很大的投入,因为即使它们不做这些事,有些客户也必须要做。
在一些需要执行大量查询的项目上,可以构建一个支持更灵活查询的REPOSITORY框架。如下图所示:
这要求开发人员熟悉必要的技术,而且一个支持性的基础设施会提供巨大的帮助。
基于SPECIFICATION(规格)的查询是将REPOSITORY通用化的好办法。客户可以使用规格来描述(也就是指定)它需要什么,而不必关心如何获得结果。在这个过程中,可以创建一个对象来实际执行筛选操作。
基于SPECIFICATION的查询是一种优雅且灵活的查询方法。根据所用的基础设施的不同,它可能易于实现,也可能极为复杂。Rob Mee和Edward Hieatt在[Fowler 2002]一书中探讨了设计这样的REPOSITORY时所涉及的更多技术问题。
即使一个REPOSITORY的设计采取了灵活的查询方式,也应该允许添加专门的硬编码查询。这些查询作为便捷的方法,可以封装常用查询或不返回对象(如返回的是选中对象的汇总计算)的查询。不支持这些特殊查询方式的框架有可能会扭曲领域设计,或是干脆被开发人员弃之不用。
客户代码可以忽略REPOSITORY的实现,但开发人员不能忽略
持久化技术的封装可以使得客户变得十分简单,并且使客户与REPOSITORY的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。在使用REPOSITORY时,不同的使用方式或工作方式可能会对性能产生极大的影响。
Kyle Brown曾告诉过我他的一段经历,有一次他被请去解决一个基于WebSphere的制造业应用程序的问题,当时这个程序正向生产环境部署。系统在运行几小时后会莫名其妙地耗尽内存。Kyle在检查代码后发现了原因:在某一时刻,系统需要将工厂中每件产品的信息汇总到一起。开发人员使用了一个名为all objects(所有对象)的查询来进行汇总,这个操作对每个对象进行实例化,然后选择他们所需的数据。这段代码的结果是一次性将整个数据库装入内存中!这个问题在测试中并未发现,原因是测试数据较少。
这是一个明显的禁忌,而一些更不容易注意到的疏忽可能会产生同样严重的问题。开发人员需要理解使用封装行为的隐含问题,但这并不意味着要熟悉实现的每个细节。设计良好的组件是有显著特征的。
底层技术可能会限制我们的建模选择。例如,关系数据库可能对复合对象结构的深度有实际的限制。同样,开发人员要获得REPOSITORY的使用及其查询实现之间的双向反馈。
REPOSITORY的实现
根据所使用的持久化技术和基础设施不同,REPOSITORY的实现也将有很大的变化。理想的实现是向客户隐藏所有内部工作细节(尽管不向客户的开发人员隐藏这些细节),这样不管数据是存储在对象数据库中,还是存储在关系数据库中,或是简单地保持在内存中,客户代码都相同。REPOSITORY将会委托相应的基础设施服务来完成工作。将存储、检索和查询机制封装起来是REPOSITORY实现的最基本的特性。
REPOSITORY概念在很多情况下都适用。可能的实现方法有很多,这里只能列出如下一些需要谨记的注意事项。
对类型进行抽象。REPOSITORY"含有"特定类型的所有实例,但这并不意味着每个类都需要有一个REPOSITORY。类型可以是一个层次结构中的抽象超类(例如,TradeOrder可以是BuyOrder或SellOrder)。类型可以是一个接口——接口的实现者并没有层次结构上的关联,也可以是一个具体类。记住,由于数据库技术缺乏这样的多态性质,因此我们将面临很多约束。
充分利用与客户解耦的优点。我们可以很容易地更改REPOSITORY的实现,但如果客户直接调用底层机制,我们就很难修改其实现。也可以利用解耦来优化性能,因为这样就可以使用不同的查询技术,或在内存中缓存对象,可以随时自由地切换持久化策略。通过提供一个易于操纵的、内存中的(in-memory)哑实现,还能够方便客户代码和领域对象的测试。
将事务的控制权留给客户。尽管REPOSITORY会执行数据库的插入和删除操作,但它通常不会提交事务。例如,保存数据后紧接着就提交似乎是很自然的事情,但想必只有客户才有上下文,从而能够正确地初始化和提交工作单元。如果REPOSITORY不插手事务控制,那么事务管理就会简单得多。
通常,项目团队会在基础设施层中添加框架,用来支持REPOSITORY的实现。REPOSITORY超类除了与较低层的基础设施组件进行协作以外,还可以实现一些基本查询,特别是要实现的灵活查询时。遗憾的是,对于类似Java这样的类型系统,这种方法会使返回的对象只能是Object类型,而让客户将它们转换为REPOSITORY含有的类型。当然,如果在Java中查询所返回的对象是集合时,客户不管怎样都要执行这样的转换。
有关实现REPOSITORY的更多指导和一些支持性技术模式(如QUERY OBJECT)可以在[Fowler 2002]一书中找到。
在框架内工作
在实现REPOSITORY这样的构造之前,需要认真思考所使用的基础设施,特别是架构框架。这些框架可能提供了一些可用来轻松创建REPOSITORY的服务,但也可能会妨碍创建REPOSITORY的工作。我们可能会发现架构框架已经定义了一种用来获取持久化对象的等效模式,也有可能定义了一种与REPOSITORY完全不同的模式。例如,你的项目可能会使用J2EE。看看这个框架与MODEL-DRIVEN DESIGN的模式之间有哪些概念上近似的地方(记住,实体bean与ENTITY不是一回事),你可能会把实体bean和AGGREGATE根当作一对类似的概念。在J2EE框架中,负责对这些对象进行访问的构造是EJB Home。但如果把EJB Home装饰成REPOSITORY的样子可能会导致其他问题。
一般来讲,在使用框架时要顺其自然。当框架无法与需求契合时,要想办法在大方向上保持领域驱动设计的基本原理,而一些不符的细节则不必过分苛求。寻求领域驱动设计的概念与框架中的概念之间的相似性。这里的假设是除了使用指定框架之外没有别的选择。很多J2EE项目根本不使用实体bean。如果可以自由选择,那么应该选择与你所使用的设计风格相协调的框架或框架中的一些部分。
REPOSITORY与FACTORY的关系
FACTORY负责处理对象生命周期的开始,而REPOSITORY帮助管理生命周期的中间和结束。当对象驻留在内存中或存储在对象数据库中时,这是很好理解的。但通常至少有一部分对象存储在关系数据库、文件或其他非面向对象的系统中。在这些情况下,检索出来的数据必须被重建为对象形式。
由于在这种情况下REPOSITORY基于数据来创建对象,因此很多人认为REPOSITORY就是FACTORY,而从技术角度来看的确如此。但我们最好还是从模型的角度来看待这一问题,前面讲过,重建一个已存储的对象并不是创建一个新的概念对象。从领域驱动设计的角度来看,FACTORY和REPOSITORY具有完全不同的职责。**FACTORY负责制造新对象,而REPOSITORY负责查找已有对象。**REPOSITORY应该让客户感觉到那些对象就好像驻留在内存中一样。对象可能必须被重建(的确,可能会创建一个新实例),但它是同一个概念对象,仍旧处于生命周期的中间。
REPOSITORY也可以委托FACTORY来创建一个对象,这种方法(虽然实际很少这样做,但在理论上是可行的)可用于从头开始创建对象,此时就没有必要区分这两种看问题的角度了,如下图所示:
这种职责上的明确区分还有助于FACTORY摆脱所有持久化职责。FACTORY的工作是用数据来实例化一个可能很复杂的对象。如果产品是一个新对象,那么客户将知道在创建完成之后应该把它添加到REPOSITORY中,由REPOSITORY来封装对象在数据库中的存储,如下图所示:
另一种情况促使人们将FACTORY和REPOSITORY结合起来使用,这就是想要实现一种"查找或创建"功能,即客户描述它所需的对象,如果找不到这样的对象,则为客户新创建一个。我们最好不要追求这种功能,它不会带来多少方便。当将ENTITY和VALUE OBJECT区分开时,很多看上去有用的功能就不复存在了。需要VALUE OBJECT的客户可以直接请求FACTORY来创建一个。通常,在领域中将新对象和原有对象区分开是很重要的,而将它们组合在一起的框架实际上只会使局面变得混乱。
为关系数据库设计对象
在以面向对象技术为主的软件系统中,最常用的非对象组件就是关系数据库。这种现状产生了混合使用范式的常见问题。但与大部分其他组件相比,数据库与对象模型的关系要紧密得多。数据库不仅仅与对象进行交互,而且它还把构成对象的数据存储为持久化形式。有一些相当完善的工具可用来创建和管理它们之间的映射。除了技术上的难点以外,这种不匹配可能对对象模型产生很大的影响。有3种常见情况:
(1) 数据库是对象的主要存储库;
(2) 数据库是为另一个系统设计的;
(3) 数据库是为这个系统设计的,但它的任务不是用于存储对象。
如果数据库模式(database schema)是专门为对象存储而设计的,那么接受模型的一些限制是值得的,这样可以让映射变得简单一点。如果在数据库模式设计上没有其他的要求,那么可以精心设计数据库结构,以便使得在更新数据时能更安全地保证聚合的完整性,并使数据更新变得更加高效。从技术上来看,关系表的设计不必反映出领域模型。映射工具已经非常完善了,足以消除二者之间的巨大差别。问题在于多个重叠的模型过于复杂了。MODEL-DRIVEN DESIGN的很多关于避免将分析和设计模型分开的观点,也同样适用于这种不匹配问题。这确实会牺牲一些对象模型的丰富性,而且有时必须在数据库设计中做出一些折中(如有些地方不能规范化)。但如果不做这些牺牲就会冒另一种风险,那就是模型与实现之间失去了紧密的耦合。这种方法并不要必须使用一种简单的、一个对象/一个表的映射。依靠映射工具的功能,可以实现一些聚合或对象的组合。但至关重要的是:映射要保持透明,并易于理解——能够通过审查代码或阅读映射工具中的条目就搞明白。
当数据库被视作对象存储时,数据模型与对象模型的差别不应太大(不管映射工具有多么强大的功能)。可以牺牲一些对象关系的丰富性,以保证它与关系模型的紧密关联。如果有助于简化对象映射的话,不妨牺牲某些正式的关系标准(如规范化)。
对象系统外部的过程不应该访问这样的对象存储。它们可能会破坏对象必须满足的固定规则。此外,它们的访问将会锁定数据模型,这样使得在重构对象时很难修改模型。
另一方面,很多情况下数据是来自遗留系统或外部系统的,而这些系统从来没打算被用作对象的存储。在这种情况下,同一个系统中就会有两个领域模型共存。或许与另一个系统中隐含的模型保持一致有一定的道理,也可能更好的方法是使这两个模型完全不同。
允许例外情况的另一个原因是性能。为了解决执行速度的问题,有时可能需要对设计做出一些非常规的修改。
但大多数情况下关系数据库是面向对象领域中的持久化存储形式,因此简单的对应关系才是最好的。表中的一行应该包含一个对象,也可能还包含AGGREGATE中的一些附属项。表中的外键应该转换为对另一个ENTITY对象的引用。有时我们不得不违背这种简单的对应关系,但不应该此就全盘放弃简单映射的原则。
UBIQUITOUS LANGUAGE可能有助于将对象和关系组件联系起来,使之成为单一的模型。对象中的元素的名称和关联应该严格地对应于关系表中相应的项。尽管有些功能强大的映射工具使这看上去有些多此一举,但关系中的微小差别可能引发很多混乱。
对象世界中越来越盛行的重构实际上并没有对关系数据库设计造成多大的影响。此外,一些严重的数据迁移问题也使人们不愿意对数据库进行频繁的修改。这可能会阻碍对象模型的重构,但如果对象模型和数据库模型开始背离,那么很快就会失去透明性。
最后,有些原因使我们不得不使用与对象模型完全不同的数据库模式,即使数据库是专门为我们的系统创建的。数据库也有可能被其他一些不对对象进行实例化的软件使用。即使当对象的行为快速变化或演变的时候,数据库可能并不需要修改。让模型与数据库之间保持松散的关联是很有吸引力的。但这种结果往往是无意为之,原因是团队没有保持数据库与模型之间的同步。如果有意将两个模型分开,那么它可能会产生更整洁的数据库模式,而不是一个为了与早前的对象模型保持一致而到处都是折中处理的拙劣的数据库模式。
参考
《领域驱动设计 软件核心复杂性应对之道》 Eric Evans 著, 赵俐 盛海艳 刘霞 等译, 任发科 审校