我们在上一节给大家介绍了在Golang中如何实现值对象。在这一节,我们来说说实体和聚合根。

实体

一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。我们可以对实体做多次修改,故一个实体对象可能和它先前的状态大不相同。但是,由于它们拥有相同的身份标识(identity),它们依然是同一个实体。 —- from IDDD

区别于数据对象

实体有两个突出的特征:唯一的身份标识和可变性,而这两个特征同样存在于数据对象身上,因此为了避免先入为主的将数据对象等同于实体,这里先说下两者的区别和联系。

数据对象

数据对象一般指的是我们在 model 层定义的一些 struct,这些 struct 的属性跟数据库中某个表的列信息是保持一致的,通过 ORM 软件(我们常用的就是Gorm),可以方便的将数据库表里的一行映射成一个数据对象的实例。
反之亦然。
这些对象之所以叫数据对象,主要原因在于他们只是承载了数据功能。
比如,在一个名为 User 的数据模型中,我们可以看到它包含了用户名、手机号、性别、年龄,等等。但是单从这个数据模型上是看不出它可以做什么的。
而具体能做什么、怎么做,则被放到了某个服务(通常会有个 service 层)里面,在服务中通过一些赋值操作来更新数据对象的某些属性,最后再通过 ORM 保存回数据库中。
这种模式下,数据对象因为缺少了行为,又被称为贫血模型。
从本质上来说,这种方式依然是面向过程的编程范式,本应围绕着领域模型的业务逻辑被泄露到了各个 service 中,久而久之,会使得代码越来越难以理解。

实体对象

而实体是 DDD 中的领域对象,它是一个富有行为的领域概念。
领域对象里的成员和数据对象里的成员可能是一致的,也可能不一致,这完全取决于你使用什么样的存储技术。
比如,在 MongoDB 这类文档型数据库中,实体模型和数据模型很可能是高度一致的,但是在传统的 MySQL 数据库中,很多时候会将一个实体模型映射成多个数据模型。
考虑在订单中要有配送地址这个场景。
如果是使用 MongoDB ,可能直接存成一个doc:

  1. {
  2. "id": "xxx",
  3. "address": {
  4. "province": "",
  5. "city": "",
  6. "detail": "",
  7. },
  8. ...
  9. }

而在 MySQL 中,就需要将地址信息拆到另外一张表里。
除此以外,实体对象跟数据对象的本质区别还在于模型的丰富程度,实体对象是包含了丰富的领域概念的。
还是订单这个例子, Order 实体上可能还会定义一些领域方法:

  1. type Order struct {
  2. ...
  3. }
  4. func (o *Order) IsPayed() bool {
  5. ...
  6. }
  7. func (o *Order) CalculateTotalPrice() {
  8. ...
  9. }
  10. func (o *Order) AddItem(productId, price, count int64) error {
  11. ...
  12. }
  13. func (o *Order) ChangeProductCount(productId, count int64) error {
  14. ...
  15. }
  16. func (o *Order) ChangeAddress(province, city, detail string) error {
  17. ...
  18. }

而数据模型就只是光秃秃的一个 Order 结构体。

唯一标识不等同于数据库主键

说到唯一标识,我们很容易联想到数据库表里的唯一主键,认为业务的唯一标识就是表记录里的id列,其实这种理解是不太全面的。
数据表里的主键 id 在某些情况下可以作为实体的唯一标识,但两者本身属于不同的概念。
还是以 Order 实体为例,它在数据库中可能存在类似下面这样的一张表:

CREATE TABLE IF NOT EXISTS `orders`( 
    `id` INT UNSIGNED AUTO_INCREMENT, 
    `order_id` VARCHAR(100) NOT NULL comment '订单编号', 
    ...
    PRIMARY KEY ( `id` ) 
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

那么,这里的 id 只是数据库表里的一个主键,而 order_id 才是 Order 这个领域实体的唯一标识。
再来看一个 Product 的例子,它的定义比较简单:

CREATE TABLE IF NOT EXISTS `products`( 
    `id` INT UNSIGNED AUTO_INCREMENT, 
    `name` VARCHAR(100) NOT NULL comment '产品名', 
    `price` VARCHAR(16) NOT NULL comment '价格',
    `detail` TEXT NOT NULL comment '详情',
    PRIMARY KEY ( `id` ) 
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

products 表有一个 id 列作为主键,同时,我们通常也会将这个 id 作为 Product 实体的唯一标识。
也就是说,数据库表的主键有的时候可以作为唯一标识使用,有的时候却不可以。
总之,我们只需要记住,唯一标识和数据库结构没有关系,主键 id 是存储层面的唯一标识,而业务层面的唯一标识才是实体关心的。

如何表示唯一标识

比较教条的做法是无论什么情况都用一个值对象来存放实体的唯一标识。
值对象具有不变性,这也就保证了实体身份的稳定。
但在一些比较简单的情况下,可以直接使用原始类型(比如string、int)来作为唯一标识。

使用值对象表示唯一标识

这里考虑一个订单号生成的例子,假如我们生成订单号的规则如下:

时间戳+业务类型+下单平台+随机码(或自增码,自增码每天可清零)+支付渠道

那么,通过这样一个订单号我们可以解析出该订单下单的时间、支付的渠道等信息。
这些信息的解析跟订单号是密不可分的,这些行为和订单号本身形成了一个完整的业务概念整体,因此,这个时候将订单号编码为一个值对象是合理的。

type OrderId struct {
   code int64
}

func (i OrderId) CreateDate() time.Time {
   ...
}

func (i OrderId) PaymentChannel() Channel {
   ...
}

...

type Order struct {
    Id OrderId
    ...
}

使用值对象来实现唯一标识不仅能够更好的表达业务,同时,可以一定程度上规避一些错误。
看下面的代码,我们要提供一个根据订单ID和商品ID来获取订单项信息的函数:

func findOrderItem(int64, int64)
// or
func findOrderItem(OrderId, ProductId)

第一种实现的入参都是 int64 类型,第二种是值对象类型。
对于第一种来说,调用方即使在传参时将 orderId 和 productId 搞反了,编译器也是不会报错的,而这种错误在第二种实现方式中是完全不可能发生的。
这种表示方式的唯一缺点在于代码量的增加。
在很多地方,因为必须要对原始类型与值对象类型进行转换(比如数据库里存储的订单号还是 int 类型,但是读取出来要转成领域实体,就需要转成 OrderId 类型,在实体持久化的时候还需要将 OrderId 转成 int),复杂度会有一定的增加。

直接使用 int64 作为唯一标识

上面提到的产品ID是一个不需要使用值对象做唯一标识的例子。
Product 实体用自增 ID 来代表唯一标识,这个 ID 除了能唯一标识一个产品外,没有其他任何与之相关的行为。
所以这里可以将其简化成一个 int64 类型。int64 也是不可变的,因此其本质上也是符合值对象的特点的。

type Product struct {
    Id int64  // 唯一标识
    ...
}

这种实现方式的缺点是表达能力不强,但好在足够简单。
综合来看:

  • 如果是使用通用的ID生成器这类的工具来生成唯一标识,其本身除了唯一标识一个实体,也没有其他的行为,这种情况下推荐直接使用原始类型就可以了;
  • 如果唯一标识是按照一定的规则来生成的,并且围绕这个唯一标识还会有一些方法(行为),这个时候最好使用值对象来承载;

    生成唯一标识

    根据不同的场景,大体上分为两种生成方式:用户传入和系统生成。

    用户直接传入唯一标识

    这种情况依赖用户的输入,用户需要保证输入的唯一标识真的是唯一的,这通常很难。但是,在某些特殊场景下还是可以做到的。
    比如在学校图书馆,管理员录入书本的场景。
    我们知道每本书都有一个 ISBN 码,这个 ISBN 码就可以作为书本的唯一标识。
    管理员在录入书本时,用手持设备直接扫描 ISBN 码,这个扫描到的 ISBN 码就可以认为是用户直接传入的唯一标识。

    系统生成唯一标识

    大部分情况下,我们遵循的都是这种方法。无论是上面提到的 OrderId 还是 ProductId,虽然生成的方式不同,但都可以归属到系统生成的范畴。
    这里面有一种比较特殊的情况,是使用数据库的自增来生成。
    这种情况特殊的点在于,实体创建好了之后,可能还没有来得及分配一个唯一标识,因为此时实体还没有进行持久化。
    没有唯一标识的实体可能需要面对下面两个问题:

  • 如果需要发布领域事件,这个时候因为还没生成唯一标识,事件接收者无法知道是哪个实体发出的事件;

  • 如果我们创建了多个实体,同时又需要将这些实体暂存在一个map中,因为唯一标识都是零值,会导致部分实体丢失;

解决这个问题的办法就是将唯一标识的生成提前,在实体创建好的时候,保证一定是有唯一标识的。

代码如何实现

通常,我们会在 Repository 接口中定义一个 NextIdentity 方法,如下:

Repository 对应的是 DDD 里的仓储概念,我们在后面的章节会介绍。

type ProductRepository interface {
    NextIdentity() (int64, error)
    ...
}

而需要用到这个 NextIdentity 方法的地方,一般是在工厂或应用服务里。

工厂和应用服务也是 DDD 里的概念,同样放在后面的章节进行介绍。

对于应用服务,基本都需要持有一个对应的 Repository 属性,比如这样:

type ProductApplicationService struct {
    productRepo repository.ProductRepository
    ...
}

func (s *ProductApplicationService) CreateProduct(ctx context.Context, cmd *CreateProductCmd) (int64, error) {
    id, err := s.productRepo.NextIdentify(ctx)
    if err != nil {
       return nil, err
    }
    rlt := &entity.ProductAggregate{
       Id: id,
       ...
    }
    ...
}

而如果是在工厂里,就要展开讨论了。
如果工厂是无状态的,也可以让工厂直接持有对应 Repository 属性,实现方式跟在应用服务里类似。
如果工厂是有状态的,那么只能每次前都创建一个工厂的实例,在创建的时候将 Repository 作为参数传入:

func NewProductFactory(ctx context.Context, repo repository.ProductRepository) *OrderFactory {
   return &OrderFactory{
      ctx:         ctx,
      productRepo: repo,
   }
}

type ProductFactory struct {
   ctx       context.Context
   productRepo repository.ProductRepository
}

...

聚合根

聚合是 DDD 中较为难以理解的一个概念。
很多刚刚接触 DDD 的同学,常犯的错误是设计出一个囊括天地万物的大聚合。这在战略设计阶段往往看不出什么问题,但是一旦要落实到代码层面,就会发现根本行不通。
当然,我们这里不会太多的去讲应该如何去设计聚合,这不是这篇文章的重点。
但是,我们至少要知道,设计小聚合是很重要的一条原则。
小聚合的前提是要保证聚合内的一致性条件不被破坏。这里有篇文章可以帮助大家更好的理解>>
更多的原则参考这里>>
当我们回归到小聚合的设计后,就会发现,聚合根的实现方式跟实体是非常类似的。
还是考虑 Product 这个例子:

type Product struct {
    Id int64
    Name string
    Price int64
    Desc string
}

假如说我们的产品模型非常简单,只有如上的四个属性,我们是否还有必要再单独定义一个 ProductAggregate 结构体做聚合根呢?

type ProductAggregate struct {
    ProductId int64
    Product *Product
}

上面这个写法显然是多余的,Product 是实体,但是,如果在聚合根里只包含实体一个属性时,Product 本身也可以当做聚合根来用。
同理,可以推广到稍复杂的情况。
比如在一个订单中,通常会包含总价、支付方式、订单项、地址等内容,如果我们强制区分实体和聚合根的话,可能会写出如下的代码:

type Order struct {
    Id int64
    Price int64
    PayChannel int64
    Address string
}

type OrderItem struct {
    Id int64
    OrderId int64
    ProductId int64
    ProductCount int64
}

type OrderAggregate struct {
    Id int64
    Order *Order
    Items []*OrderItems
}

但在实际开发中,可以在 Order 中直接引用 OrderItem,这样就省去了对 OrderAggregate 的维护,Order 也变成了实体加聚合根的双重身份:

type OrderItem struct {
    Id int64
    OrderId int64
    ProductId int64
    ProductCount int64
}

type Order struct {
    Id int64
    Price int64
    PayChannel int64
    Address string
    Items []*OrderItems
}

同时,我们建议所有的聚合根在命名上都加一个 Aggregate 或 Agg 的后缀,用以明确的表示这是一个聚合根。
使用聚合根时,还有一个经常容易犯的错误,就是在一个聚合根中引用了另外一个聚合根。
正确的做法是通过全局唯一标识来引用外部的聚合。
原因就在于,在一个事务中,原则上只能修改一个聚合,如果不持有对其他对象的引用,也就避免了对这个对象的修改。
在你的代码里,如果一个事务必须要修改多个聚合,这个时候就要考虑聚合设计的是否合理,这种情况通常意味着聚合的一致性边界是错误的。

总结

实体/聚合根是DDD领域层最核心的概念,其上可能包含了对多个值对象的引用,同时也是业务逻辑主要的载体。
在实现上,跟普通的 Struct 并无太大的区别,唯一需要注意的就是唯一标识的表示。
现在,实体有了,接下来的工作就是将其持久化到数据库中,但实体毕竟不同于数据模型,没法直接调用 Gorm 的相关方法。
具体如何做呢,我们在下一章节继续说说仓储。