领域驱动设计(3)模型驱动设计
前面的章节强调过软件开发过程的重点:它必须以业务领域为中心。
我们说过让模型植根于领域、并精确反映出领域中的基础概念是建立模型的一个最重要的基础。
通用语言应该在建模过程中广泛尝试以推动软件专家和领域专家之间的沟通,以及发现要在模型中使用的主要的领域概念。
建模过程的目的是创建一个优良的模型,下一步是将模型实现成代码。
这是软件开发过程中同等重要的两个阶段。
创建了优良的模型,但却未能将其成功地转换成代码将把软件的质量带入未知境地。
曾经发生过软件分析人员和业务领域专家在一起工作了若干个月,一起发现了领域的基础元素,强调了元素之间的关系,创建了一个正确的模型,模型也正确捕获了领域知识。
然后模型被传递给了软件开发人员。
开发人员看模型时可能会发现模型中的有些概念或者关系不能被正确地转换成代码。
所以他们使用模型作为灵感的源泉,但是创建了自己的设计,虽然某些设计借鉴了模型的思想,另外他们还增加了很多自己的东西。
开发过程继续进行,更多的类被加入到代码中,进一步加大了原始模型和最终实现的差距。
在这种情况下,很难保证产生优良的最终结果。
优秀的开发人员可能会让一个产品最终交付使用,但它能经得起生产环境的考验吗?它能容易地被扩展吗?它能容易地被维护吗?
任何领域都能被表现成多种模型,每一种模型都能用不同的方式表现成代码。
对每一个特殊问题而言,可能会对应不止一个解决方案。
我们应该选择哪一个呢?拥有一个看上去正确的模型不代表模型能被直接转换成代码。
也或者它的实现会违背某些我们所建议的软件设计原则呢。
选择一个能够被轻易和准确转换成代码的模型很重要。
最根本的问题是:我们应该如何动手处理从模型到代码的转换。
一个推荐的设计技巧是使用分析模型,它被认为是从代码设计中分离出来、通常是由多个人完成的。
分析模型是业务领域分析的结果,其产生的模型不考虑软件需要如何实现。
这样的一个模型可用来理解领域,它建立了特定级别的知识,模型看上去会很正确。
软件不是这个阶段要考虑的,因为它的介入会被看作是一个导致混乱的因素。
这个模型到达开发人员那里后,由他们来做设计的工作。
因为这个模型中没有涉及到设计原则,它可能不能很好地完成目标。
因此开发人员不得不修改它,或者建立分离的设计。
在模型和代码之间也不再存在映射关系。
最终的结果是分析模型在编码开始后就被放弃了。
这个方法中存在的一个主要的问题是分析不能预见模型中存在的某些缺陷以及领域中的所有的复杂关系。
分析人员可能深入到了模型中某些组件的细节,但却未深入到其他的部分。
非常重要的细节直到设计和实现过程才被发现。
如实反映领域的模型可能会导致对象持久化的一系列问题,或者导致不可接受的性能行为。
开发人员会被强制做出他们自己的决定,会做出设计变更以解决实际问题,而这个问题在模型建立时是没有考虑到的。
他们建立了一个偏离了模型的设计,让它们二者越来越不相关。
如果分析人员独立工作,他们最终也会创建出一个模型。
当这个模型被传递给开发人员,分析人员的一些领域知识和模型就丢失了。
虽然模型可以被表现成图或者文字形式,极有可能的还是开发人员不能掌握整个模型、或者某些对象的关系或者他们之间的行为。
模型中的很多细节不适合表现成图的方式,甚至也可能不能完全用文字来表现。
开发人员需要花费大量精力去处理它们。
在某种情况下他们会对想要的行为做出某种假设,这极有可能让他们做出错误的决定,最终导致错误的程序功能。
分析人员会参加频繁的领域讨论会议,他们会享有很多知识。
他们建立假定包含了所有信息的浓缩模型,开发人员不得不阅读所有他们给的文档以吸收精华。
如果开发人员能够加入分析讨论会议,并在开始设计编码前获得清晰完整的领域和模型视图会更有效率。
一个更好的方法是紧密关联领域建模和设计。
模型在构建时就考虑到软件和设计。
开发人员会被加入到建模的过程中来。
主要的想法是选择一个能够恰当在软件中表现的模型,这样设计过程会很顺畅并且基于模型。
代码和其下的模型紧密关联会让代码更有意义并与模型更相关。
有了开发人员的参与就会有反馈。它能保证模型被实现成软件。如果其中某处有错误,会在早期就被标识出来,问题也会容易修正。
写代码的人会很好地了解模型,会感觉自己有责任保持它的完整性。
他们会意识到对代码的一个变更其实就隐含着对模型的变更,另外,如果哪里的代码不能表现原始模型的话,他们会重构代码。
如果分析人员从实现过程中分离出去,他会不再关心开发过程中引入的局限性。最终结果是模型不再实用。
任何技术人员想对模型做出贡献必须花费一些时间来接触代码,无论他在项目中担负的是什么主要角色。
任何一个负责修改代码的人都必须学会用代码表现模型。每位开发人员都必须参与到一定级别的领域讨论中并和领域专家联络。
那些按不同方式贡献的人必须自觉地与接触代码的人使用通用语言,动态交换模型思想。
如果设计或者设计中的核心部分不能映射到领域模型,模型基本上就没有什么价值,而软件是否正确也就令人怀疑。
同时,模型和设计功能之间的复杂映射很难理解,实际上可能很难维护设计变更。
分析和设计之间铁定被割裂开来,这样一个(分析或设计)活动中获取到的想法将不能对另外一个产生影响。
设计软件系统的一部分,保证它能如实反映领域模型,让映射显而易见。
重新访问模型,修改它,让它能在软件中更自然地得到实现,甚至可让它能按你所愿反映对领域的更深层理解。
除了支持流畅的通用语言,还可以要求一个单独的模型来服务好这两个目的。
从模型中去除在设计中使用的术语和所赋予的基本职责后,代码就成了模型的表达式,所以对代码的一个变更就可能称为对模型的变更。
这个影响会波及到项目的其余活动中。
为了紧密捆绑起实现和模型,通常需要支持建模范型的软件开发工具和语言,例如面向对象编程。
面向对象编程非常适合对模型的实现,因为它们基于同一个范型。
面向对象编程提供了对象的类和类之间的关联关系、对象实例、以及对象实例之间的消息通信。
面向对象编程语言让建立模型对象、对象关系与它们的编程副本之间的直接映射成为可能。
过程化语言提供了有限的模型驱动设计的支持。这样的语言不能提供实现模型关键组件所必须的构建能力。
有人说象C 这样的过程化语言能够实现面向对象编程,确实,某些功能可能被用某种方式再现:对象可能被模拟成数据结构。
这样的数据结构不能包含对象的行为,所以必须额外加入函数。这种数据的意义仅存在开发人员的脑海中,因为代码本身都不那么明显。
一段用过程化语言写就的程序通常被认为是一组函数,一个函数调用另一个,为达到一个特定的结果共同协作。
这样的程序不能轻易地封装概念性连接,很难实现领域和代码之间的映射。
某些特殊的领域(例如数学)可以借助过程化编程被轻易地建模和实现,是因为许多数学理论大多数都是关于计算的,可以用函数调用和数据结构简单解决。
许多复杂的领域不仅是一组抽象概念涉及到的计算,所以不能简化成一系列的算法,因此过程化语言不足以完成表述各自模型的任务。
因为这个原因,模型驱动设计中不推荐过程化编程。
模型驱动设计的基本构成要素
本章接下来的章节会展现在模型驱动设计中要使用的最重要的模式。这些模式的作用是从领域驱动设计的角度展现一些对象建模和软件设计中的基本元素。
下图是要展现的模式和模式间关系的总图。
分层架构
当我们创建一个软件应用时,这个应用的很大一部分是不能直接跟领域关联的,但它们是基础设施的一部分或者是为软件服务的。
最好能让应用中的领域部分尽可能少地和其他的部分掺杂在一起,因为一个典型的应用包含了很多和数据库访问,文件或网络访问以及用户界面等相关的代码。
在一个面向对象的程序中,用户界面、数据库以及其他支持性代码经常被直接写到业务对象中。
附加的业务逻辑被嵌入到UI 组件和数据库脚本的行为中。之所以这样做的某些原因是这样可以很容易地让事情快速工作起来。
但是,当领域相关的代码被混入到其他层时,要阅读和思考它也变得极其困难。表面看上去是对UI 的修改,却变成了对业务逻辑的修改。
对业务规则的变更可能需要谨慎跟踪用户界面层代码、数据库代码以及其他程序元素。
实现粘连在了一起,模型驱动对象于是变得不再可行。也很难使用自动化测试。对于每个活动中涉及到的技术和逻辑,程序必须保持简单,否则就会变得很难理解。
因此,将一个复杂的程序切分成层。开发每一个层中内聚的设计,让每个层仅依赖于它底下的那层。
遵照标准的架构模式以提供层的低耦合。将领域模型相关的代码集中到一个层中,把它从用户界面、应用和基础设施代码中分隔开来。
释放领域对象的显示自己、保存自己、管理应用任务等职责,让它专注于展现领域模型。
这会让一个模型进一步富含知识,更清晰地捕获基础的业务知识,让它们正常工作。
一个通用领域驱动设计的架构性解决方案包含4个概念层:
用户界面/展现层:负责向用户展现信息以及解释用户命令。
应用层:很薄的一层,用来协调应用的活动。它不包含业务逻辑。它不保留业务对象的状态,但它保有应用任务的进度状态。
领域层:本层包含关于领域的信息。这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层。
基础设施层:本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用。
将应用划分成分离的层并建立层间的交换规则很重要。
如果代码没有被清晰隔离到某层中,它会迅即混乱,因为它变得非常难以管理变更。
在某处对代码的一个简单修改会对其他地方的代码造成不可估量的结果。
领域层应该关注核心的领域问题。它应该不涉及基础设施类的活动。
用户界面既不跟业务逻辑紧密捆绑也不包含通常属于基础设施层的任务。
在很多情况下应用层是必要的。它会成为业务逻辑之上的管理者,用来监督和协调应用的整个活动。
例如,对一个典型的交互型应用,领域和基础设施层看上去会这样:用户希望预定一个飞行路线,要求用一个应用层中的应用服务来完成。
应用依次从基础设施中取得相关的领域对象,调用它们的相关方法,比如检查与另一个已经被预定的飞行线路的安全边界。
当领域对象执行完所有的检查并修改了它们的状态决定后,应用服务将对象持久化到基础设施中。
实体
有一类对象看上去好像拥有标识符,它的标识符在历经软件的各种状态后仍能保持一致。
对这些对象来讲这已经不再是它们关心的属性,这意味着能够跨越系统的生命周期甚至能超越软件系统的一系列的延续性和标识符。我们把这样的对象称为实体。
OOP 语言会把对象的实例放于内存,它们对每个对象会保持一个对象引用或者是记录一个对象地址。
在给定的某个时刻,这种引用对每一个对象而言是唯一的,但是很难保证在不确定的某个时间段它也是如此。
实际上恰恰相反。对象经常被移出或者移回内存,它被序列化后在网络上传输,然后在另一端被重新建立,或者它们都被消除。
在程序的运行环境中,那个看起来像标识符的引用关系其实并不是我们在谈论的标识符。
如果有一个存放了天气信息(如温度)的类,很容易产生同一个类的不同实例,这两个实例都包含了同样的值,这两个对象是完全相当的,可以用其中一个跟另一个交换,但它们拥有不同的引用,它
们不是实体。
因此,在软件中实现实体意味着创建标识符。
对两个拥有不同标识符的对象来说,能用系统轻易地把它们区分开来,或者两个使用了相同标识符的对象能被系统看成是相同的,这些都是非常重要的。
如果不能满足这个条件,整个系统可能是有问题的。
实体是领域模型中非常重要的对象,并且它们应该在建模过程开始时就被考虑。决定一个对象是否需要成为一个实体也很重要,这会在下一个模型中被讨论。
值对象
让我们考虑一个绘画应用。用户会看到一个画布且他能够用任何宽度、样式和颜色来画任何点和线。
创建一个叫做Point 的对象类非常有用,程序会对画布上的每一个点创建这个类的一个实例。
这样的一个点会包含两个属性对应屏幕或者画布的坐标。
考虑每一个点都拥有标识符是必要的吗?它会有延续性吗?看上去与这样一个对象相关的事情只有它的坐标。
这是我们需要包含一个领域对象的某些属性时的例子。
我们对某个对象是什么不感兴趣,只关心它拥有的属性。用来描述领域的特殊方面、且没有标识符的一个对象,叫做值对象。
区分实体对象和值对象非常必要。出于要统一的缘故而将所有对象处理成实体对象并没有太大帮助。
实际上,只建议选择那些符合实体定义的对象作为实体,将剩下的对象处理成值对象(我们会在下一个章节引入其他类型的对象,但我们假设现在只有实体对象和值对象两种)。
这会简化设计,并且将会产生某些其他的积极的意义。
极力推荐值对象是不变的。它们由一个构造器创建,并且在它们的生命周期内永远不会被修改。
当你希望一个对象的不同的值时,你会简单地去创建另一个对象。这会对设计产生重要的结果。保持不变,并且不具有标识符,值对象就可以被共享了。
这对某些设计是必要的。不变的对象可在重要的性能语境中共享。它们也能表明一致性,如:数据一致性。
一条箴言是:如果值对象是可共享的,那么它们应该是不可变的。值对象应该保持尽量的简单。当其他当事人需要一个值对象时,可以简单地传递值,或者创建一个副本。
制作一个值对象的副本是非常简单的,通常不会有什么副作用。如果没有标识符,你可以按你所需创建若干副本,然后根据需要来销毁它们。
值对象可以包含其他的值对象,它们甚至还可以包含对实体对象的引用。
虽然值对象可用来简化一个领域对象要包含的属性,但这并不意味着它应该包含所有的一大长列的属性。
属性可以被分组到不同的对象中。被选择用来构成一个值对象的属性应该形成一个概念上的整体。
一个客户会跟其名称、街道、城市、州县相关。最好分离出一个对象来包含地址信息,客户对象会包含一个对地址对象的引用。
街道、城市、州县应该归属于一个对象,因为它们在概念上属于一体的,而不应该是作为分离的客户属性。
服务
当我们分析领域并试图定义构成模型的主要对象时,我们发现有些方面的领域很难映射成对象。
对象要通常考虑的是拥有属性,对象会管理它的内部状态并暴露行为。在我们开发通用语言时,领域中的主要概念被引入到语言中,语言中的名词很容易被映射成对象。
语言中对应那些名词的动词变成那些对象的行为。但是有些领域中的动作,它们是一些动词,看上去却不属于任何对象。
它们代表了领域中的一个重要的行为,所以不能忽略它们或者简单的把它们合并到某个实体或者值对象中。
给一个对象增加这样的行为会破坏这个对象,让它看上去拥有了本该属于它的功能。
但是,要使用一种面向对象语言,我们必须用到一个对象才行。
我们不能只拥有一个单独的功能,它必须附属于某个对象。
通常这种行为类的功能会跨越若干个对象,或许是不同的类。
例如,为了从一个账户向另一个账户转钱,这个功能应该放到转出的账户还是在接收的账户中?感觉放在这两个中的哪一个也不对劲。
当这样的行为从领域中被识别出来时,最佳实践是将它声明成一个服务。
这样的对象不再拥有内置的状态了,它的作用是为了简化所提供的领域功能。
服务所能提供的协调作用是非常重要的,一个服务可以将服务于实体和值对象的相关功能进行分组。
最好显式声明服务,因为它创建了领域中的一个清晰的特性,它封装了一个概念。
把这样的功能放入实体或者值对象都会导致混乱,因为那些对象的立场将变得不清楚。
服务担当了一个提供操作的接口。服务在技术框架中是通用的,但它们也能被运用到领域层中。
一个服务不是在执行服务的对象,而与被执行操作的对象相关。在这种情况下,一个服务通常变成了多个对象的一个链接点。
这也是为什么行为很自然地依附于一个服务而不是被包含到其他领域对象的一个原因。
如果这样的功能被包含进领域对象,就会在领域对象和成为操作受益者的对象之间建立起一个密集的关联网。
众多对象间的高耦合度是糟糕设计的一个信号,因为这会让代码很难阅读与理解,更重要的是,这会导致很难进行变更。
一个服务应该不是对通常属于领域对象的操作的替代。我们不应该为每一个需要的操作来建立一个服务。但是当一个操作凸现为一个领域中的重要概念时,就需要为它建立一个服务了。
以下是服务的3个特征:
1. 服务执行的操作涉及一个领域概念,这个领域概念通常不属于一个实体或者值对象。
2. 被执行的操作涉及到领域中的其他的对象。
3.操作是无状态的。
当领域中的一个重要的过程或者变化不属于一个实体或者值对象的自然职责时,向模型中增加一个操作,作为一个单独的接口将其声明一个服务。
根据领域模型的语言定义一个接口,确保操作的名字是通用语言的一部分。让服务变得无状态。
使用服务时保持领域层的隔离非常重要。很容易弄混属于领域层的服务和属于基础设施层的服务。服务层也可能有服务,这会继续增加层级的复杂性。
这些服务甚至更难从与领域层中的近似的服务中分离开来。当我们在设计阶段建立模型时,我们需要确保领域层保持从其他层中隔离开来。
不论是应用服务还是领域服务,通常都是建立在领域实体和值对象的上层,以便直接为这些相关的对象提供所需的服务。
决定一个服务所应归属的层是非常困难的事情。如果所执行的操作概念上属于领域层,那么服务就应该放到这个层。
如果操作和领域对象相关,而且确实也跟领域有关,能够满足领域的需要,那么它就应该属于领域层。
让我们考虑一个实际的Web 报表应用的例子。
报表使用存储在数据库中的数据,它们会基于模版产生。最终的结果是一个在Web浏览器中可以显式给用户查看的HTML页面。
用户界面层被合并成Web页面,允许用户登录,选择所期望的报表,单击一个按钮就可以发出请求。
应用层是非常薄的一个层,它位于用户界面和领域层以及基础设施层的中间位置。
它在登录操作时,会跟数据库基础设施进行交互;在需要创建报表时会和领域层进行交互。
领域层中包含了领域的核心部分,对象直接关联到报表。有两个这样的对象是报表产生的基础, 它们是Report和Template。
基础设施层将支持数据库访问和文件访问。
当一个用户选择创建一个报表时,他实际上从名称列表中选择了一个报表的名称。这会是一个字符串类型的reportID。
还会传递其他的参数,例如要在报表中显示的项目以及报表中要包括的数据的时间间隔等。但出于简化的考虑我们将只提到reportID。
这个名字会通过应用层传递到领域层。领域层有义务根据所给的名字来创建并返回报表。
因为报表会基于模版产生,我们需要创建一个服务,它的作用是根据一个reportID 获得对应的模版,这个模版被存放在一个文件或者数据库中。
这不适于作为Report对象自身的一个操作。它也同样不属于Template对象。
所以我们创建了一个分离服务出来,这个服务的目的是基于一个report的标识符来检索一个报表模版。
这会是一个位于领域层的服务。它也会用到文件类的基础设施,以从磁盘上检索模版。
模块
模块被用来作为组织相关概念和任务以便降低复杂性的一种方法。
模块在许多项目中被广泛使用。
如果你查看模快包含的内容以及那些模块间的关系,就会很容易从中掌握大型模型的概况。
理解了模型间的交互之后,人们就可以开始处理模块中的细节了。这是管理复杂性的简单有效的方法。
另一个使用模块的原因跟代码质量有关。
普遍认为软件代码应该具有高层次的内聚性和低层次的耦合度。
虽然内聚开始于类和方法级别,它也可以应用于模块级别。
强烈推荐将高关联度的类分组到一个模块以提供尽可能大的内聚。
有很多类型的内聚。最常用到的两个是通信性内聚和功能性内聚。
通信性内聚通常在模块的部件操作相同的数据时使用。
把它们分到一组很有意义,因为它们之间存在很强的关联性。
功能性内聚在模块中的部件协同工作以完成定义好的任务时使用。这被认为是最佳的内聚类型。
在设计中使用模块是一种增进内聚和消除耦合的方法。
模块应该由在功能上或者逻辑上属于一体的元素构成,以保证内聚。
模块应该具有定义好的接口,这些接口可以被其他的模块访问。
最好用访问一个接口的方式替代调用模块中的三个对象,因为这可以降低耦合。
低耦合降低了复杂性并增强了可维护性。
当要执行定义好的功能时,模块间仅有极少的连接会让人很容易理解系统是如何工作的,这要比每个模块同其他的模块间存在许多关联好很多。
我们应该选择那些能够表述系统内涵并且包含具有内聚性概念集的模块。
这种方式通常会使得模块之间松耦合,但是如果没有让模型中的概念更加清晰明白,它也能找到一个或许可以作为模块基础的宏观概念,使得各个元素有机地组合在一起。
寻找每一个能够被独立理解和辨别的概念间的低耦合。重定义模型,直到它被按照高级别的领域概念区分开来,而且对应的代码也被很好地解耦合。
给定的模块名称会成为通用语言的组成部分。模块和它们的名字应该能反映对领域的深层理解。
设计人员会习惯地从一开始就创建模块,这在我们的设计过程中是很普通的部分。
模块的角色被决定以后通常会保持不变,尽管模块的内部会发生很多的变化。
强烈推荐拥有一些弹性,允许模块随这项目的进展而演化,并且不被冻结。
大家都明白模块的重构成本要比类的重构昂贵的多,但如果一个模块被发现存在设计错误,最好是通过变更模块来定位它,然后再找其他的解决途径。
聚合
本章的最后3个模式将处理不同的建模挑战,它们跟领域对象的生命周期相关。
领域对象在它们的生命期内会历经若干种状态,直到最后消亡。
有时它们会被保存到一个永久的位置,例如数据库中,这样可以在以后的日子里被检索到,或者被存档。
有时它们会被完全从系统中清除掉,包括从数据库和归档介质上。
管理领域对象的生命周期自身就会遇到一个挑战,如果做得不恰当,就会对领域模型产生一个负面的影响。
我们将引入3个模式来帮助我们处理这个挑战。
聚合是一个用来定义对象所有权和边界的领域模式。
工厂和资源库是另外的两个设计模式,用来帮助我们处理对象的创建和存储问题。
我们将从聚合开始讨论。
一个模型会包含众多的领域对象。不管在设计时做了多少考虑,我们都会看到许多对象会跟其他的对象发生关联,形成了一个复杂的关系网。
这些关联的类型有很多种。对模型中的每个可导航的关联而言,都应该有对应的软件机制来强调它。
领域对象间实际的关联在代码中结束,有时甚至却在数据库中。
客户和用它名字开立的银行账户之间存在的一个1 对1 的关系会被表现为两个对象之间的引用,并且在两个数据库表中隐含着一个关联关系,一个表存放客户信息,另一个表存放账户信息。
来自模型的挑战是通常不让它们尽量完整,而是让它们尽量地简单和容易理解。
这意味着,直到模型中嵌入了对领域的深层理解,否则就要时常对模型中的关系进行消减和简化。
一个1对多的关联关系就更复杂了,因为它涉及到了相关的多个对象。这种关系可以被简单转化成一个对象和一组其他对象之间的一个关联,虽然这并不总能行得通。
多对多的关联关系大部分情况下是双向的。这又增加了复杂度,使得对这样的对象的生命周期管理变得困难。
关联的数字应该被尽可能消减。
首先,要删除模型中非本质的关联关系。它们可能在领域中是存在的,但它们在我们的模型中不是必要的,所以我们要清除它们。
其次,可以通过增加约束的方式来消减多重性。如果很多对象满足一种关系,那么在这个关系上加入正确的约束后,很有可能只有一个对象会继续满足这种关系。
第三,很多时候双向关联可以被转换成非双向的关联。每一辆汽车都有一个引擎,并且引擎在运转时,都会属于一辆汽车。这种关系是双向的,但是很容易成为汽车拥有引擎,而不用考虑反向的。
在我们消除和简化了对象间的关联后,我们仍然会遭遇到很多的关系。
一个银行系统会保留并处理客户数据。
这些数据包括客户个人数据(例如姓名、地址、电话号码、工作描述等)和账户数据:账户、余额、执行的操作等。
当系统归档或者完全删除一个客户的信息时,必须要保证所有的引用都被删除了。如果许多对象保有这样的引用,则很难确保它们全被清除了。
同样,如果一个客户的某些数据发生了变化,系统必须确保在系统中执行了适当的更新,数据一致性必须得到保证。
这通常被提交到数据库层面进行处理。事务通常用来确保数据一致性。
但是如果模型没有被仔细地设计过,会产生很大程度的数据库争夺,导致性能极差。
当数据库事务在这样的操作中担负重要角色时,我们会期望直接在模型中解决跟数据一致性相关的一些问题。
通常也有必要确保不变量。不变量是在数据发生变化时必须维护的那些规则。这在许多对象与数据发生变化的对象保持引用时更难实现。
在模型中拥有复杂关联的对象发生变化时,很难保证其一致性。许多时候不变量被应用到密切相关的对象,而不是离散的对象。
但是谨慎的锁定模式又会导致多个用户之间发生不必要的冲突,系统变得不可用。
因此,使用聚合。
聚合是针对数据变化可以考虑成一个单元的一组相关的对象。
聚合使用边界将内部和外部的对象划分开来。每个聚合有一个根。
这个根是一个实体,并且它是外部可以访问的唯一的对象。
根可以保持对任意聚合对象的引用,并且其他的对象可以持有任意其他的对象,但一个外部对象只能持有根对象的引用。
如果边界内有其他的实体,那些实体的标识符是本地化的,只在聚合内有意义。
聚合是如何保持数据一致性和强化不变量的呢?因为其他对象只能持有根对象的引用,这意味着它们不能直接变更聚合内的其他的对象。
它们所能做的就是对根进行变更,或者让根来执行某些活动。
根能够变更其他的对象,但这是聚合内包含的操作,并且它是可控的。
如果根从内存中被删除或者移除,聚合内的其他所有的对象也将被删除,因为再不会有其他的对象持有它们当中的任何一个了。
当针对根对象的修改间接影响到聚合内的其他的对象,强化不变量变得简单了,因为根将做这件事情。
如果外部对象能直接访问内部对象并且变更它们时,这将变得越发困难。
在这种情况下想强化不变量意味着讲某些逻辑放到外部对象中去处理,这不是我们所期望的。
根对象可能将内部的临时引用传递给外部对象,作为限制,当操作完成后,外部对象不能再持有这个引用。
一个简单的实现方式是向外部对象传递一个值对象的拷贝。在这个对象上发生了什么将不再重要,因为它不会以任何方式影响到聚合的一致性。
如果聚合对象被保存到数据库中,只有根可以通过查询来获得。其他的对象只能通过导航关联来获得。
聚合内的对象可以被允许持有对其他聚合的根的引用。
根实体拥有全局的标识符,并且有责任管理不变量。内部的实体拥有内部的标识符。将有关的实体和值对象放置到聚合中并且围绕它们定义边界。
选择一个实体作为每个聚合的根,并且通过根来控制所有对边界内的对象的访问。
允许外部对象仅持有对根的引用。临时对内部成员的引用仅可以被传递给一个单独的操作使用。
因为根控制了访问,将不能盲目对内部对象进行变更。这种安排让强化聚合内对象的不变量变得可行,并且对聚合而言,它是一个处于任何变更状态的整体。
一个简单的聚合的案例如下图所示。客户是聚合的根,并且其他所有的对象都是内部的。如果需要地址,一个它的拷贝将被传递到外部对象。
工厂
实体和聚合通常会很大很复杂,根实体的构造函数内的创建逻辑也会很复杂。
实际上通过构造器努力构建一个复杂的聚合也与领域本身通常做的事情相冲突,在领域中,某些事物通常是由别的事物创建的(例如电器是在组装线上被创建的)。
这看上去是用打印机构建自己。
当一个客户程序对象想创建另一个对象时,它会调用它的构造函数,可能传递某些参数。
但是当对象构建是一个很费力的过程时,对象创建涉及了好多的知识,包括:关于对象内部结构的,关于所含对象之间的关系的以及应用其上的规则等。
这意味着对象的每个客户程序将持有关于对象构建的专有知识。
这破坏了领域对象和聚合的封装。
如果客户程序属于应用层,领域层的一部分将被移到了外边,扰乱整个设计。
实际上,给我们塑胶、橡胶、金属、硅,让我们构建自己的打印机。这不是不可能完成的,但这样做值得吗?
一个对象的创建可能是它自身的主要操作,但是复杂的组装操作不应该成为被创建对象的职责。组合这样的职责会产生笨拙的设计,也很难让人理解。
因此,有必要引入一个新的概念,这个概念可以帮助封装复杂的对象创建过程,它就是工厂(Factory)。
工厂用来封装对象创建所必需的知识,它们对创建聚合特别有用。
当聚合的根建立时,所有聚合包含的对象将随之建立,所有的不变量得到了强化。
将创建过程原子化非常重要。如果不这样做,创建过程就会存在对某个对象执行了一半操作的机会,将这些对象置于未定义的状态,对聚合而言更是如此。
当根被创建时,所有对象服从的不变量也必须被创建完毕,否则,不变量将不能得到保证。
对不变的值对象而言则意味着所有的对象被初始化成有效的状态。
如果一个对象不能被正常创建,将会产生一个异常,确保没有返回一个无效值。
因此,转变创建复杂对象和聚合的实例的职责给一个单独的对象,虽然这个对象本身在领域模型中没有职责,但它仍是领域设计的一部分。
提供一个封装了所有复杂组装的接口,客户程序将不再需要引用要初始化的对象的具体的类。将整个聚合当作一个单元来创建,强化它们的不变量。
有很多的设计模式可以用来实现工厂模式。由Gamma 等人著的《设计模式》一书中对此有详细描述,并介绍了两种不同的模式:
工厂方法和抽象工厂。我们不会努力从设计的视角引入模式,而是从领域建模的角度来引入它。
工厂方法是一个对象的方法,包含并隐藏了必要的创建其他对象的知识。
这在一个客户程序试图创建一个属于某聚合的对象时是很有用的。
解决方案是给聚合的根增加一个方法,这个方法非常关心对象的创建,强化所有的不变量,返回对那个对象的引用或者拷贝。
Container 包含着许多组件,这些组件都是特定类型的。当这样的一个组件被创建后能自动归属于一个Container 是很有必要的。
客户程序调用Container 的createComponent(Type t)方法,Container实例化一个新的组件。组件的具体类取决于它的类型。
在创建之后,组件被增加到Container 所包含的组件的集合中,并且返回给客户程序一个拷贝。
当创建一个极其复杂的对象或者创建的对象涉及到创建其他一系列的对象时(例如,创建一个聚合),需要大量的时间。
隐藏聚合的内部构建所需要的任务可以用一个单独的工厂对象来完成。
让我们考虑一个程序模块的案例,计算一辆汽车从起点行驶到终点的路线,而且被给定了很多的约束。
用户登录Web 站点后运行应用程序,并指定其中一个要遵守的约束条件:最短的路线,最快的路线,或者最便宜的路线。
被创建的路线可以被登录的用户评论,而且这些信息都需要被保存下来。这样就可以在客户下次登录时检索出它们。
路线ID 的生成器被用来给每条路线创建一个唯一的标识符,这对一个实体而言是非常必要的。
当创建一个工厂时,我们被迫违反一个对象的封装原则,而这应该小心行事。
每当对象中发生了某种变化时,会对构造规则或者某些不变量造成影响,我们需要确认工厂也被更新以支持新的条件。
工厂和它们要创建的对象是紧密关联的。这可能是个弱点,但它也有长处。
一个聚合包含了一系列密切相关的对象。跟的构建与聚合内的其他对象的创建是相关的。
会有一些逻辑放置到聚合中,这些逻辑不属于任何一个对象,因为它总跟其他对象的构建有关。
看上去,使用一个专用的工厂类,给定创建整个聚合的任务比较合适,
聚合中的包含的规则、约束和不变量将被确保在聚合内有效。这个对象会保持简单,并将完成特定的目的,不会造成复杂的构建逻辑的混乱。
实体工厂和值对象工厂是有差异的。值通常是不可变的对象,并且其所有的必需的属性需要在创建时完成。
当一个对象被创建时,它是有效的,也是最终的,不会再发生变化。实体是非不可变的。
它们会在以后发生变化,前面提及过设置某些属性时需要考虑所有的不变量。另一个差异源于实体需要标识符,而值对象不需要。
有时工厂是不需要的,一个简单的构造函数就足够了。
在如下情况下使用构造函数:
1)构造过程并不复杂。
2)对象的创建不涉及到其他对象的创建,所有的属性需要传递给构造函数。
3)客户程序对实现很感兴趣,可能希望选择使用策略模式。
4)类是特定的类型,不涉及到继承,所以不用在一系列的具体实现中进行选择。
其他观察结果是工厂需要从无到有创建一个新对象,也或者它们需要从先前已经存在但可能已经持久化到一个数据库中的对象进行重建。
将实体对象从它们所在的数据库中取回内存中,涉及了一个和创建一个新对象完全不同的过程。
对象已经有一个了,对不变量的违反将区别对待。
当一个新的对象从无到有创建时,任何对不变量的违反都会产生一个异常。
我们不能在对象从数据库重建时也这样处理。
这个对象需要的是如何修复,这样它们才是可工作的,否则就会有数据的丢失。
资源库
在模型驱动设计中,对象从被创建开始,直到被删除或者被归档结束,是有一个生命周期的。
一个构造函数或者工厂可应用来处理对象的创建。创建对象的整体作用是为了使用它们。
在一个面向对象的语言中,我们必须保持对一个对象的引用以便能够使用它。
为了获得这样的引用,客户程序必须创建一个对象或者通过导航已有的关联关系从另一个对象中获得它。
例如,为了从一个聚合中获得一个值对象,客户程序需要向聚合的根发送请求。
问题是现在客户程序必须先拥有一个对根的引用。
对大型的应用而言,这会变成一个问题。因为我们必须保证客户始终对需要的对象保持一个引用,或者是对关注的对象保有引用。
在设计中使用这样的规则将强制要求对象持有一系列它们可能其实并不需要保持的一系列的引用。
这增加了耦合性,创建了一系列本不需要的关联。
要使用一个对象,则意味着这个对象已经被创建完毕了。
如果这个对象是聚合的根,那么它是一个实体,它会被保持到一个被持久化的状态,可能是在数据库中,也可能是其他的持久化形式。
如果它是一个值对象,它会通过一个实体经由对关联的导航来获得。
我们可以推导出大部分的对象可以从数据库中直接获取到。这解决了获取对象引用的问题。
当一个客户程序需要使用一个对象时,它可以访问数据库,从中检索出对象并使用它。
这看上去是个非常快捷并且简单的解决方案,但它对设计会产生负面的影响。
数据库是基础设施的一部分。一个不好的解决方案是客户程序必须知道要访问数据库所需的细节。
例如,客户需要创建sql查询语句来检索所需的数据。
数据库查询可能会返回一组记录,甚至暴露其内部的更细节信息。
当许多客户程序不得不直接从数据库创建对象时,会导致这样的代码在整个模型中四散。
从这点上讲领域模型做出了妥协。它必须处理许多基础设施的细节而不是处理领域概念。
如果我们做出了一个对数据库进行变更的变更那会怎么样呢?所有四散的代码需要变更以便能够访问新的存储。
当客户代码直接访问一个数据库时,它极有可能存储聚合中的一个内部对象。这会破坏聚合的封装性,带来未知的结果。
客户程序需要有一个获取已存在领域对象引用的实际方式。如果基础设施让这变得简单,客户程序的开发人员可能会增加更多的可导航的关联进一步混乱模型。
从另一方面讲,他们可能使用查询从数据库中获取所需的数据,或者拿到几个特定的对象,而不是通过聚合的根来递归。
领域逻辑分散到查询和客户代码中,实体和值对象变得更像数据容器。
应用到众多数据库访问的基础设施的技术复杂性会迅速蔓延在客户代码中,开发人员不再关注领域层,所做的工作跟模型无关了。
最终的结果是丢失了对领域的关注,设计做了妥协。
因此,使用一个资源库,它的目的是封装所有获取对象引用所需的逻辑。
领域对象不需处理基础设施,以得到领域中对其他对象的所需的引用。
只需从资源库中获取它们,于是模型重获它应有的清晰和焦点。
资源库会保存对某些对象的引用。
当一个对象被创建出来时,它可以被保存到资源库中,然后以后使用时可从资源库中检索到。
如果客户程序从资源库中请求一个对象,而资源库中并没有它,就会从存储介质中获取它。
换种说法是,资源库作为一个全局的可访问对象的存储点而存在。
资源库可能包含一定的策略。
它可能基于一个特定的策略来访问某个或者另一个持久化存储介质。
它可能会对不同类型的对象使用不同的存储位置。
最终结果是领域模型同需要保存的对象和它们的引用中解耦,可以访问潜在的持久化基础设施。
对于需要全局访问的每种类型的对象,创建一个新对象来提供该类型所有对象的内存列表的对应。
通过一个共知的全局接口设置访问。提供方法来增加或者删除对象,封装向数据存储中插入或者删除数据的实际操作。
提供基于某种条件选择对象的方法,返回属性值符合条件的完全实例化的对象或者一组对象,继而封装实际的存储和查询技术。
仅对真正需要直接访问的聚合根提供资源库。让客户程序保持对模型的关注,把所有的对象存储和访问细节委托给资源库。
资源库可能包含用来访问基础设施的细节信息,但它的接口应非常简单。资源库应该拥有一组用来检索对象的方法。
客户程序调用这样的方法,传递一个或者多个代表筛选条件的参数用来选择一个或者一组匹配的对象。
实体可能通过传递其标识符被轻易指定。其他选择条件可能由一组对象的属性构成。资源库将针对这组条件来比对所有的对象,并返回符合条件的那些对象。
资源库接口可能包含用来执行某些辅助计算(例如特定类型对象的数量)的方法。
看上去资源库的实现可能会非常类似于基础设施,但资源库的接口是纯粹的领域模型。
另一种选项是用规约(specification)指定一个查询条件。规约允许定义更复杂的条件,见下图:
工厂和资源库之间存在一定的关系。它们都是模型驱动设计中的模式,它们都能帮助我们关联领域对象的生命周期。
然而工厂关注的是对象的创建,而资源库关心的是已经存在的对象。
资源库可能会在本地缓存对象,但更常见的情况是需要从一个持久化存储中检索它们。
对象可以用构造函数创建,也可以被传递给一个工厂来构建。
从这个原因上讲,资源库也可以被看作一个工厂,因为它创建对象。
不过它不是从无到有创建新的对象,而是对已有对象的重建。
我们将不把资源库视为一个工厂。工厂创建新的对象,而资源库应该是用来发现已经创建过的对象。
当一个新对象被添加到资源库时,它应该是先由工厂创建过的,然后它应该被传递给资源库以便将来保存它,见下面的例子:
另外要注意的是工厂是“纯的领域”,而资源库会包含对基础设施的连接,如数据库。
posted on 2011-12-19 17:30 zhouyonghua0520 阅读(945) 评论(0) 编辑 收藏 举报