《领域驱动设计精简版》读书笔记(2)——模型驱动设计

基本概念

通用语言应该在建模过程中广泛尝试以推动软件专家和领域专家之间的沟通,以及发现要在模型中使用的主要的领域概念。建模过程的目的是创建一个优良的模型,下一步是将模型实现成代码。这是软件开发过程中同等重要的两个阶段。

某些特殊的领域(例如数学)可以借助过程化编程被轻易地建模和
实现,是因为许多数学理论大多数都是关于计算的,可以用函数调
用和数据结构简单解决。许多复杂的领域不仅是一组抽象概念涉及
到的计算,所以不能简化成一系列的算法,因此过程化语言不足以
完成表述各自模型的任务。因为这个原因,模型驱动设计中不推荐
过程化编程

基本构成要素

下图是要展现的模式和模式间关系的总图:
模式与模式间关系总图
领域驱动设计的分层架构如下图所示:
分层架构图
当我们创建一个软件应用时,这个应用的很大一部分是不能直接跟领域关联的,但它们是基础设施的一部分或者是为软件服务的。最好能让应用中的领域部分尽可能少地和其他的部分掺杂在一起,因为一个典型的应用包含了很多和数据库访问,文件或网络访问以及用户界面等相关的代码。
在一个面向对象的程序中,用户界面、数据库以及其他支持性代码经常被直接写到业务对象中。附加的业务逻辑被嵌入到 UI 组件和数据库脚本的行为中。之所以这样做的某些原因是这样可以很容易地让事情快速工作起来。

但是,当领域相关的代码被混入到其他层时,要阅读和思考它也变得极其困难。表面看上去是对 UI 的修改,却变成了对业务逻辑的修改。对业务规则的变更可能需要谨慎跟踪用户界面层代码、数据库代码以及其他程序元素。实现粘连在了一起,模型驱动对象于是变得不再可行。也很难使用自动化测试。对于每个活动中涉及到的技术和逻辑,程序必须保持简单,否则就会变得很难理解。

因此,将一个复杂的程序切分成层。开发每一个层中内聚的设计,让每个层仅依赖于它底下的那层。遵照标准的架构模式以提供层的低耦合。将领域模型相关的代码集中到一个层中,把它从用户界面、应用和基础设施代码中分隔开来。释放领域对象的显示自己、保存自己、管理应用任务等职责,让它专注于展现领域模型。这会让一个模型进一步富含知识,更清晰地捕获基础的业务知识,让它们正常工作。
一个领域驱动设计的架构性解决方案包含4个概念层:

层次 功能
用户界面层/展现层 负责向用户展示信息以及解释用户命令
应用层 很薄的一层,用来协调应用的活动,不包含业务逻辑,不保留业务对象的状态,但它保有应用任务的进度状态
领域层 包含关于领域的信息,是业务软件的核心,在这里保留业务对象的状态,对业务对象和他们状态的持久化被委托给了基础设施层
基础设施层 本层作为其它层的支撑库存在,它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用

将应用划分成分离的层并建立层间的交换规则很重要。如果代码没有被清晰隔离到某层中,它会迅即混乱,因为它变得非常难以管理变更。在某处对代码的一个简单修改会对其他地方的代码造成不可估量的结果。领域层应该关注核心的领域问题。它应该不涉及基础设施类的活动。用户界面既不跟业务逻辑紧密捆绑也不包含通常属于基础设施层的任务。在很多情况下应用层是必要的。它会成为业务逻辑之上的管理者,用来监督和协调应用的整个活动。

例如,对一个典型的交互型应用,领域和基础设施层看上去会这样:用户希望预定一个飞行路线,要求用一个应用层中的应用服务来完成。应用依次从基础设施中取得相关的领域对象,调用它们的相关方法,比如检查与另一个已经被预定的飞行线路的安全边界。当领域对象执行完所有的检查并修改了它们的状态决定后,应用服务将对象持久化到基础设施中。

实体(Entity)

有一类对象看上去好像拥有标识符,它的标识符在历经软件的各种状态后仍能保持不变。对这些对象来讲这已经不再是它们关心的属性,这意味着这些对象能够跨越系统的生命周期,我们把这样的对象称为实体

OOP 语言会把对象的实例放于内存,它们对每个对象会保持一个对象引用或者是记录一个对象地址。在给定的某个时刻,这种引用对每一个对象而言是唯一的,但是很难保证在不确定的某个时间段它也是如此。实际上恰恰相反。对象经常被移出或者移回内存,它被序列化后在网络上传输,然后在另一端被重新建立,或者它们都被消除。在程序的运行环境中,那个看起来像标识符的引用关系其实并不是我们在谈论的标识符。

如果有一个存放了天气信息(如温度)的类,很容易产生同一个类的不同实例,这两个实例都包含了同样的值,这两个对象是完全相当的,可以用其中一个跟另一个交换,但它们拥有不同的引用,它们不是实体。

如果我们要用软件程序实现一个“人”的概念,我们可能会创建一个 Person 类,这个类会带有一系列的属性,如:名称,出生日期,出生地等。这些属性中有哪个可以作为 Person 的标识符吗?名字不可以作为标识符,因为可能有很多人拥有同一个名字。如果我们只考虑两个人的名字的话,我们不能使用同一个名字来区分他们两个。我们也不能使用出生日期作为标识符,因为会有很多人出在同一天出生。同样也不能用出生地作为标识符。一个对象必须与其他的对象区分开来,即使是它们拥有着相同的属性。错误的标识符可能会导致数据混乱。

考虑一下一个银行会计系统。每一个账户拥有它自己的数字码,每一个账户可以用它的数字码来精确标识。这个数字码在系统的生命周期中会保持不变,并保证延续性。账户码可以作为一个对象存在于内存中,也可以被在内存中销毁,发送到数据库中。当这个账户被关闭时,它还可以被归档,只要还有人对它感兴趣,它就依然在某处存在。不论它的表现形式如何,数字码会保持一致。

因此,在软件中实现实体意味着创建标识符。对一个人而言,其标识符可能是属性的组合:名称,出生日期,出生地,父母名称、当前地址。在美国,社会保险号也会用来创建标识符。对一个银行账户来说,账号看上去已经足可以作为标识符了。通常标识符或是对象的一个属性(比如账户号码)或属性的组合,一个专门为保存和表现标识符而创建的属性(比如自动生成的序列号),也或是一种行为。对两个拥有不同标识符的对象来说,能用系统轻易地把它们区分开来,或者两个使用了相同标识符的对象能被系统看成是相同的,这些都是非常重要的。如果不能满足这个条件,整个系统可能是有问题的。

有很多不同的方式来为每一个对象创建一个唯一的标识符:可能由一个模型来自动产生 ID,在软件中内部使用,不会让它对用户可见;它可能是数据库表的一个主键,会被保证在数据库中是唯一的。只要对象从数据库中被检索,它的 ID 就会被检索出并在内存中被重建;ID 也可能由用户创建,例如每个机场会有一个关联的代码。每个机场拥有一个唯一的字符串 ID,这个字符串是在世界范围内通用的,被世界上的每一个旅行代理使用以标识它们的旅行计划中涉及的机场。另一种解决方案是使用对象的属性来创建标识符,当这个属性不足以代表标识符时,另一个属性就会被加入以帮助确定每一个对象。

当一个对象可以用其标识符而不是它的属性来区分时,可以将它作为模型中的主要定义。保证类定义简洁并关注生命周期的延续性和可标识性。对每个对象定义一个有意义的区分,而不管它的形式或者历史。警惕要求使用属性匹配对象的需求。定义一个可以保证对每一个对象产生一个唯一的结果的操作,这个过程可能需要某个符号以保证唯一性。这意味着标识可以来自外部,或者它可以是由系统产生、使用任意的标识符,但它必须符合模型中的身份差别。模型必须定义哪些被看作同一事物。

实体是领域模型中非常重要的对象,并且它们应该在建模过程开始时就被考虑。决定一个对象是否需要成为一个实体也很重要,这会在下一个模型中被讨论。

值对象(Value object)

我们可能被引导将所有对象看成实体。实体是可以被跟踪的。但跟踪和创建标识符需要很大的成本。我们需要保证每一个实体都有唯一标识,跟踪标识也并非易事。我们需要确保每个实例拥有它唯一的标识,跟踪标识也并非易事。需要花费很多仔细的考虑来决定由什么来构成一个标识符,因为一个错误的决定可能会让对象拥有相同的标识,而这并不是我们所预期的。将所有的对象视为实体也会带来隐含的性能问题,因为需要对每个对象产生一个实例。

如果我们对某个对象是什么不感兴趣,只关心它拥有的属性。用来描述领域的特殊方面、且没有标识符的一个对象,叫做值对象。实际上,只建议选择那些符合实体定义的对象作为实体,将剩下的对象处理成值对象(目前假设现在只有实体对象和值对象两种)。这会简化设计,并且将会产生某些其他的积极的意义。推荐所有值对象的属性都尽量是值对象。

没有标识符,值对象就可以被轻易地创建或者丢弃。没有人关心创建一个标识符,在没有其他对象引用时,垃圾回收会处理这个对象。这极大简化了设计。极力推荐值对象是不变的。它们由一个构造器创建,并且在它们的生命周期内永远不会被修改。当你希望一个对象的不同的值时,你会简单地去创建另一个对象。这会对设计产生重要的结果。保持不变,并且不具有标识符,值对象就可以被共享了。这对某些设计是必要的。不变的对象可在重要的性能语境中共享。它们也能表明一致性,如:数据一致性。设想一下共享可变的对象会意味着什么。一个航空旅行预定系统可能为每个航班创建对象,这个对象会有一个可能是航班号的属性。一个客户会为一个特定的目的地预定一个航班。另一个客户希望订购同一个航班。因为是同一个航班,系统选择了重用持有那个航班号的对象。这时,客户改变了主意,选择换成一个不同的航班。因为它不是不可修改的,所以系统改变了航班号。这会导致第一个客户的航班号也发生了变化。如果值对象是可共享的,那么它们应该是不可变的

值对象可以包含其他的值对象,它们甚至还可以包含对实体对象的
引用。虽然值对象可用来简化一个领域对象要包含的属性,但这并
不意味着它应该包含所有的一大长列的属性。属性可以被分组到不
同的对象中。如下图所示:
值对象分组

服务(Service)

当我们分析领域并试图定义构成模型的主要对象时,我们发现有些方面的领域很难映射成对象。对象要通常考虑的是拥有属性,对象会管理它的内部状态并暴露行为。在我们开发通用语言时,领域中的主要概念被引入到语言中,语言中的名词很容易被映射成对象。语言中对应那些名词的动词变成那些对象的行为。但是有些领域中的动作,它们是一些动词,看上去却不属于任何对象。它们代表了领域中的一个重要的行为,所以不能忽略它们或者简单的把它们合并到某个实体或者值对象中。面向对象编程的行为一定要依附于某个对象,通常这种行为类的功能会跨越若干个对象,或许是不同的类。例如,为了从一个账户向另一个账户转钱,这个功能应该放到转出的账户还是在接收的账户中?感觉放在这两个中的哪一个也不对劲。当这样的行为从领域中被识别出来时,最佳实践是将它声明成一个服务,一个服务是多个对象的链接点,表示某个无状态的操作,用于实现某个领域的任务。服务不再拥有内置的状态了,它的作用是为了简化所提供的领域功能。服务所能提供的协调作用是非常重要的,一个服务可以将服务于实体和值对象的相关功能进行分组。最好显式声明服务,因为它创建了领域中的一个清晰的特性,它封装了一个概念。把这样的功能放入实体或者值对象都会导致混乱,因为那些对象的立场将变得不清楚。

以下是服务的3个特征:

  1. 服务执行的操作涉及一个领域概念,这个领域概念通常不属于一个实体或者值对象。
  2. 被执行的操作涉及到领域中的其他的对象。
  3. 操作是无状态的

服务可以属于领域层也可以属于基础设施层,不论是应用服务还是领域服务,通常都是建立在领域实体和值对象的上层,以便直接为这些相关的对象提供所需的服务。决定一个服务所应归属的层是非常困难的事情。如果所执行的操作概念上属于领域层,那么服务就应该放到这个层。如果操作和领域对象相关,而且确实也跟领域有关,能够满足领域的需要,那么它就应该属于领域层。

模块(Module)

对于很大的复杂项目而言,模块可以用来组织相关概念和任务,以便于降低系统的复杂度。模块在许多项目中被广泛使用。如果你查看模快包含的内容以及那些模块间的关系,就会很容易从中掌握大型模型的概况。理解了模型间的交互之后,人们就可以开始处理模块中的细节了。这是管理复杂性的简单有效的方法。将高关联度的类分组到一个模块以提供尽可能大的内聚和尽量低的耦合,这可以提升代码质量。内聚性分两种类型:

  1. 通信性内聚:通常在模块的部件操作相同的数据时使用。把它们分到一组很有意义,因为它们之间存在很强的关联性。
  2. 功能性内聚:在模块中的部件协同工作以完成定义好的任务时使用。这被认为是最佳的内聚类型。

模块应该由在功能上或者逻辑上属于一体的元素构成,以保证内聚。模块应该具有定义好的接口,这些接口可以被其他的模块访问。最好用访问一个接口的方式替代调用模块中的三个对象,因为这可以降低耦合。低耦合降低了复杂性并增强了可维护性。当要执行定义好的功能时,模块间仅有极少的连接会让人很容易理解系统是如何工作的,这要比每个模块同其他的模块间存在许多关联好很多。强烈推荐模块的设计拥有一些弹性,允许模块随着项目的进展而演化而不是被冻结,如果模块的设计被发现有错误,也需要及时修正,尽管有时候这样的代价比较大。

聚合(Aggregate)

通过聚合我们可以简化对象与对象之间的关联关系,聚合是针对数据变化可以考虑成一个单元的一组相关的对象。聚合使用边界将内部和外部的对象划分开来。

每个聚合有一个根,这个根是一个实体,并且它是外部可以访问的唯一的对象。根可以保持对任意聚合对象的引用,并且其他的对象可以持有任意其他的对象,但一个外部对象只能持有根对象的引用。如果边界内有其他的实体,那些实体的标识符是本地化的,只在聚合内有意义,一个实体的引用只能被其所属的聚合根所持有,而其它对象只能持有根对象的引用,只能通过根对象变更聚合内的其它对象。如果根从内存中被删除或者移除,聚合内的其他所有的对象也将被删除,因为再不会有其他的对象持有它们当中的任何一个了。如果聚合对象被保存到数据库中,只有根可以通过查询来获得,其他的对象只能通过导航关联来获得。聚合内的对象可以被允许持有对其他聚合的根的引用。一个简单的聚合案例如图所示:
聚合
客户是聚合的根,并且其他所有的对象都是内部的。如果需要地址,一个它的拷贝将被传递到外部对象。

工厂(Factory)

实体和聚合通常会很大很复杂,根实体的构造函数内的创建逻辑也会很复杂。如果通过构造函数创建对象,有时候客户程序需要持有关于对象构建的专有知识,这破坏了领域对象和聚合的封装性。一个对象的创建可能是它自身的主要操作,但是复杂的组装操作不应该成为被创建对象的职责。

所以可以通过工厂来封装复杂的对象创建过程、用来封装对象创建所必需的知识,它们对创建聚合特别有用。当聚合的根建立时,所有聚合包含的对象将随之建立,所有的不变量得到了强化。

将创建过程原子化非常重要。如果不这样做,创建过程就会存在对某个对象执行了一半操作的机会,将这些对象置于未定义的状态,对聚合而言更是如此。当根被创建时,所有对象服从的不变量也必须被创建完毕,否则,不变量将不能得到保证。对不变的值对象而言则意味着所有的对象被初始化成有效的状态。如果一个对象不能被正常创建,将会产生一个异常,确保没有返回一个无效值。因此,转变创建复杂对象和聚合的实例的职责给一个单独的对象,虽然这个对象本身在领域模型中没有职责,但它仍是领域设计的一部分。提供一个封装了所有复杂组装的接口,客户程序将不再需要引用要初始化的对象的具体的类。将整个聚合当作一个单元来创建,强化它们的不变量。工厂方法举例:
工厂方法Container 包含着许多组件,这些组件都是特定类型的。当这样的一个组件被创建后能自动归属于一个 Container 是很有必要的。客户程序调用 Container 的 createComponent(Type t)方法,Container实例化一个新的组件。组件的具体类取决于它的类型。在创建之后,组件被增加到 Container 所包含的组件的集合中,并且返回给客户程序一个拷贝。

有时工厂是不需要的,一个简单的构造函数就足够了。在如下情况下使用构造函数:

  1. 构造过程并不复杂。
  2. 对象的创建不涉及到其他对象的创建,所有的属性需要传递给构造函数。
  3. 客户程序对实现很感兴趣,可能希望选择使用策略模式。
  4. 类是特定的类型,不涉及到继承,所以不用在一系列的具体实现中进行选择。

资源库

我们可以推导出大部分的对象可以从数据库中直接获取到。这解决了获取对象引用的问题。当一个客户程序需要使用一个对象时,它可以访问数据库,从中检索出对象并使用它。这看上去是个非常快捷并且简单的解决方案,但它对设计会产生负面的影响。数据库是基础设施的一部分。一个不好的解决方案是客户程序必须知道要访问数据库所需的细节。例如,客户需要创建 sql 查询语句来检索所需的数据。数据库查询可能会返回一组记录,甚至暴露其内部的更细节信息。当许多客户程序不得不直接从数据库创建对象时,会导致这样的代码在整个模型中四散。
使用一个资源库,它的目的是封装所有获取对象引用所需的逻辑。领域对象不需处理基础设施,以得到领域中对其他对象的所需的引用。只需从资源库中获取它们,于是模型重获它应有的清晰和焦点。资源库会保存对某些对象的引用。当一个对象被创建出来时,它可以被保存到资源库中,然后以后使用时可从资源库中检索到。如果客户程序从资源库中请求一个对象,而资源库中并没有它,就会从存储介质中获取它。
资源库
资源库可能包含一定的策略。它可能基于一个特定的策略来访问某个或者另一个持久化存储介质。它可能会对不同类型的对象使用不同的存储位置。最终结果是领域模型同需要保存的对象和它们的引用中解耦,可以访问潜在的持久化基础设施。要注意在有聚合的场景中仅对真正需要直接访问的聚合根提供资源库。另外要注意的是工厂是“纯的领域”,而资源库会包含对基础设施的连接,如数据库。下图为资源库的接口实例:
资源库举例
看上去资源库的实现可能会非常类似于基础设施,但资源库的接口是纯粹的领域模型(所以Java具体实现的时候可以把接口定义在领域层,在基础设施层提供接口的具体实现类)。

参考

《领域驱动设计精简版》
DDD领域驱动聚合根

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章