软创互联

DDD 领域驱动设计学习(五)- 实体/值对象/领域服务

领域驱动设计
ddd

#1

领域驱动设计DDD在战术建模上提供了一个元模型体系(如下图):

DDD构建的元模型元素脑图

元模型往往用来在某一特定的领域定义一个基础的通用的语言,来讨论和描述该领域的问题及解决方法。可以将元模型想象成为某种形式语言,这样模型就是一篇用该语言描述的文章,其中元模型中的元素就是该语言的词汇,元素之间的关系就是该语言的语法。元模型的例子其实很多,例如交通指示标志就定义了一种非常简单的交通规则的元模型。DDD的元模型图也是用于描述如何去创建一个DDD的模型。

DDD的战术阶段实际就是这样一个抽象过程。这个抽象过程由于元模型的存在实际是一定程度模式化的。这样的好处是并非只能技术人员参与建模,业务人员经过一定的培训也是完全可以理解的。

DDD的战术建模包括如下内容:

  1. 实体-Entity
  2. 值对象-Value Objects
  3. 领域服务-Domain Services
  4. 领域事件-Domain Events
  5. 模块-Modules
  6. 聚合-Aggregate
  7. 资源库-Repository

实体和值对象

实体和值对象放在一起讲容易区分,概括而言,实体不仅需要知道它是什么?而且还需要知道它是哪个?而值对象只需要知道它是什么?先看定义:

定义:

  • 实体 :许多对象不是由它们的属性来定义,而是通过一系列的连续性(continuity)和标识(identity)来从根本上定义的。只要一个对象在生命周期中能够保持连续性,并且独立于它的属性(即使这些属性对系统用户非常重要),那它就是一个实体。
  • 值对象 :当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。

对于实体Entity,实体核心是用唯一的标识符来定义,而不是通过属性来定义。即使属性完全相同也可能是两个不同的对象。同时实体本身有状态的,实体有演进的生命周期,实体本身会体现出相关的业务行为,业务行为会实体属性或状态造成影响和改变。

如果从值对象本身无状态,不可变,并且不分配具体的标识层面来看。那么值对象可以仅仅理解为实际的Entity对象的一个属性结合而已。该值对象附属在一个实际的实体对象上面。值对象本身不存在一个独立的生命周期,也一般不会产生独立的行为。

初看还是很难理解,举几个例子:

案例分析

  1. 营业厅会卖手机以及很多手机配件,在客户业务规则中,往往每一部手机都要单独管理,通过手机的SN号来识别。而手机配件是一种数量类型的实物,只关心其数量的变化,并不关心到每一个具体的手机配件。这种场景就是典型的实体和对象的案例。
  2. 地址是实体还是值对象。在电力公司服务软件中,一个地址对应于公司线路和服务的目的地。如果多个住所都申请了电力服务,那么这个公司需要知道这一点,因此地址是实体。我们也可以用另一种方法,在模型中将“住所”关联到运营服务,其中“住所”是一个包含地址属性的实体。此时,地址就是一个值对象。
  3. 体育场座位例子。当我们发放的门票上有座位号的时候,座位需要作为独立的实体,座位号是唯一的标识。而当先到先座模式下,我们只关心剩余座位数,那么座位号并不是唯一标识,这时候座位就可以作为一个值对象。这跟我们的业务需求有关。
  4. 消息场景中,发件人、收件人是实体?还是值对象?这个在 三个问题思考实体和值对象一文中有讨论。
  5. 值对象的常见例子包括数字,比如100和293.51;或者文本字符串,比如"hello world";或者日期时间;还有更加详细的对象,此如某人的全名,其中包含姓改、名字和头衔;再比如货币、颜色、电话号码和邮寄地址等。当然还有更加复杂的值对象。这种对象无状态,本身不产生行为,不存在生命周期演进。

值对象的目的和使用

实体对象相对容易理解,我们常见的类的都可以看成是实体对象。值对象在DDD中相对而言是难以理解并且容易误用的。

为什么需要使用值对象,书中给了一个解释:

使用不变的值对象使得我们做更少的职责假设

个人理解这个还是基于BC的封闭性而言的,使用值对象在不同的BC中进行数据交换,可以避免不同BC对实体对象的状态变更而引发的数据依赖关系,实现最小化的集成。另外可以从目前流行的Stateless Service角度考虑值对象的价值。

开发者因为习惯趋向于将关注点放在数据而不是领域上。在软件开发中,数据库依然占据着主导地位。我们首先考虑的是数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。这样做的结果是将数据模型直接反映在对象模型上,导致产生贫血型的领域模型的实体。虽然在实体模型中加入getter和setter并不是什么大错,但这却不是DDD的做法。

值类型用于度量和描述事物,DDD中建议应尽量使用值对象来建模而不是实体对象,因为值对象非常容易地对值对象进行创建、测试、使用、优化和维护。

关于值对象,它拥有以下一些特征:

  1. 它度量或者描述了领城中的一件东西。
  2. 它可以作为不变量。
  3. 它将不同的相关的属性组合成一个概念整体(Conceptual Whole)
  4. 当度量和描述改变时,可以用另一个值对象予以替换。
  5. 它可以和其他值对象进行相等性比较。
  6. 它不会对协作对象造成副作用

一个对象的方法可以设计成一个无副作用函数(Side-Effect-Free Function) 。这里的函数表示对某个对象的操作,它只用于产生输出, 而不会修改对象的状态。由于在函数执行的过程中没有状态改变,这样的函数操作也称为无副作用函数。对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。

最小化集成

在所有的DDD项目中,通常存在多个限界上下文,这意味着我们需要找到合适的方法对这些上下文进行集成。当模型概念从上游上下文流入下游上下文中时, 尽量使用值对象来表示这些概念。这样的好处是可以达到最小化集成,即可以最小化下游模型中用于管理职责的属性数目。使用不变的值对象使得我们做更少的职责假设。

领域服务

领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。
当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务了。有时我们傾向于使用聚合根上的静态方法来实现这些这些操作,但是在 DDD中,这是一种坏味道。

什么是领域服务(首先,什么不是领域服务)

听到"服务"这个词时,我们自然地可能会想到一个分布式系统的远程调用场景。可能是一个SOA的服务,也有多种技术和方法可以实现SOA服务,例如远程过程调用(RPC)或者面向消息的中间件(MoM)。

但这些都不是领域服务。

另外也不要将领域服务与应用服务混杂在一起了。在应用服务中,我们并不会处理业务逻辑,但是领域服务却拾恰是处理业务逻辑的。简单来讲,应用服务是领域模型很自然的客户方,进而也是领域服务的客户方。

虽然领域服务中有"服务"这个同,但它并不意昧着需要远程的、重量级的事务操作。

领域模型中的服务是一种非常好的建模工具,现在我们已经知道领域服务不是什么了,那么它到底又是什么昵?

有时,它不见得是一件东西……当领域中的某个操作过程或转换过程不是实体或值对象的职责时,此时我们便成该将该操作放在一个单独的接口中,即领域服务。请确保该领域服务和通用语言是一致的;并且保证它是无状态的。[Evans, pp. 104,106]

那么在什么情况下,某个操作不属于实体或者值对象呢?书中罗列了以下几点:

  • 执行一个显著的业务操作过程。
  • 对领域对象进行转换。
  • 以多个领域对象作为输入进行计算,结果产生一个值对象。

对于最后一点中的计算过程,它应该具有“显著的业务操作过程"的特点。这也是领域服务很常见的应用场景,它可能需要多个聚合作为输人。 当一个方法不便放在实体或值对象上时,使用领域服务便是最佳的解决方法。需要确保领域服务是无状态的,并且能够明确地表达限界上下文中的 通用语言

不过只要在真正必要是才应该使用领域服务,过度使用领域服务将会导致 贫血领域模型 ,所有业务都位于领域服务中,而不是实体和值对象中了。

用户权限认证的例子

《实现领域驱动设计》书中给出了一个例子,对User进行认证的例子。例子中给出的需求是:

  • 系统必须对User进行认证,并且只有当Tenant处于激活状态时候才能对User进行认证。
  • 必须对密码进行加密,并且不能使用明文密码
    对以上的需求,我们可以把认证的方法写在User类或者Tenant类中,不过对于以上解决方案,似乎都给模型带来了太多的问题。对于后一种方案, 我们必须从以下四种解决办法中选择一种:
  1. 在Tenant中处理对密码的加密,然后将加密后的密码传给User。这种方法违背了 单一职责原则
  2. 由于一个User必须保证对密码的加密,它可能已经知道了一些加密信息。如果是这样,我们可以在User上创建一个方法,该方法对明文密码进行认证。但是在这种方式下,认证过程变成了Tenant上的Facade。而实际的认证 功能全在User上。另外User上的认证方法必须声明为Protected,以防止外界 客户端对认证方法的直接调用。
  3. Tenant依赖于User对密码进行加密,然后将加密后的密码与原有密码进行匹配。这种方法似乎在对象协作之间增加了额外的步骤。此时,Tenant依然需要知道认证细节。
  4. 让客户端对密码进行加密。然后将其传给Tenant,这样导致的问题在于客户端承载了它本不应该有的职责。

以上这些方法都有问题,这时候选择通过领域服务会是一个简单而优雅的选择。

UserDescriptor userDescriptor = 
          DomainRegistry
            .authenticationService()
            .authenticate(tenantID,userName,password);

为领域服务创建一个迷你层

一个方法是放入领域对象还是放入领域服务有时候会是一个比较困难的选择。我们可能希望在实体和值对象之上创建一个领域服务的迷你层,这样简化了分析的工作,但这样做可能会导致贫血领域模型这种反模式。
对于有些系统来说,为领域服务创建一个不至于导致贫血领域模型的迷你层还是值得的。当然这取决于领域模型的特征。对于上面提到的身份与访问上下文来说,这样的做法是非常有用的。
如果你决定为领域服务创建一个迷你层,需要注意这样的迷你层和应用层中的服务是不同的。在应用服务中,我们关心的是事务和安全,但是这些不应该出现在领域服务中。

领域事件

参考 DDD 领域驱动设计学习笔记(三)- 领域事件

模块

模块在技术上可以对应Java中的Package。在DDD中,模块表示了一个命名的容器,用于存放领域中内聚在一起的类。

模块应该包含一組具有高内聚性的概念集合.这样做的好处是可以在不同的模块之间实现松耦合。否则,我们应该修改模型以重新划分这些概念。……由于模块名是UL的一部分,模块名应该反映出它们在领域中的概念。[Evans]
模块的设计是基于领域模型的,要符合通用语言的表述。其次,模块的设计要符合高内聚低耦合的设计思想。设计模块时候,有几条简单原则如下:

设计模块的简单原则

模块和BC的关系

模块与子域和限界上下文并不是一致的概念,模块也是一种独立的建模方法。对于何时应该对领域模型进行分离,何时将领域模型建模成一个整体,应该仔细地思考与对待。有时通用语言可以很好地帮助我们做出正确的选择。但是另外的时候,其中的术语将变得非常含糊。在这种情况下,我们并不清楚如何划分上下文边界。此时,我们可以首先将它们放在一起,使用模块来对模型进行划分,面不是限界上下文。

但是,这并不意味着我们就应该限制对限界上下文的创建。我们应该通过通用语言的需求来划分模型边界。但限界上下文不是用来代替模块的。使用摸块的目的在于组织那些内聚在一起的领域对象,对于那些内聚性不强或者没有内聚性的领域对象来说,我们应该将它们划分在不同的模块中。

作者:njluz
链接:https://www.jianshu.com/p/da51d16dbdc4
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。