前言: 在 DDD 中,一个业务用例对应一个事务,一个事务对应一个聚合根,在一次事务中,只能对一个聚合根进行操作。那么在复杂的业务场景涉及多个聚合根的修改,特别是许多聚合根处于不同的限界上下文中时,我们可以选择使用领域事件对其进行修改。

转自:

参考:go语言中文文档:www.topgoer.com

使用 freedom ,两行代码就让你轻松搞定领域事件!

一、DomainEvent 什么是领域事件

领域事件是领域驱动设计中的一个重要概念,我们使用领域事件来捕获领域中发生的一些事情。只有那些对业务有价值,能够有助于形成完整的业务闭环,能够 推动下一步业务发展 的事情,才能被当做领域事件。

这里举一个例子,当用户在订单服务下单付款后,仓储物流团队就可以开始进行发货流程发货了。当用户确认收货后,发票服务的会计团队就能开始做账报税开票了。这一系列的动作都是属于事件,属于各自领域的事件,并且明显的能够推动业务的发展!

随着微服务的兴起,拆分服务和 事件风暴 在不断的演进,领域事件扮演着灵魂角色。在事件风暴中,发现并提取领域事件,将以领域事件为中心的业务模型,演化成以聚合为中心的 领域模型 ,是DDD落地实践的一种重要手段。

领域事件是属于领域模型的,前面我们已经介绍过 聚合 实体 都是领域模型。在 领域事件 建模时,我们因该关注这一点。用户 实体 修改密码?订单 聚合 支付?订单 聚合 发货?它们完成命令后是否因该发布通知?

二、事件的存储

在数据的 最终一致性 的问题上,我们至少要保证该限界上下文内领域模型的 持久化 和领域事件的发布是一致的。

如果订单状态修改为 已付款 ,然后在使用 MQ基础设施 发布失败,那么是不一致的。那么如果是先使用 MQ基础设施 发布成功,然后在修改订单状态为 已付款 失败,也是不一致的。

我们需要一张事件表和使用本地事务。在领域模型持久化的同时并插入一条领域事件的记录,它们依赖本地事务来保持数据的一致性。

当事务成功后再使用 MQ基础设施 发布通知,如果失败了,定时器扫描后继续发送直至发送成功。

 //事务伪代码
begin
  update order set status = PAID
  insert into domain_event_publish
 com mit 

//事件发布表
CREATE TABLE `domain_event_publish` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `topic` varchar(255) NOT NULL,
  `content` varchar(2000) NOT NULL,
  PRIMARY KEY (`id`)
);

//订单表
CREATE TABLE `order` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `order_no` varchar(65) NOT NULL DEFAULT '',
  `user_id` int(11) NOT NULL COMMENT '用户id',
  `status` enum('PAID','NON_PAYMENT','SHIPMENT','DONE') NOT NULL DEFAULT 'NON_PAYMENT' COMMENT '支付,未支付,发货,完成',
)

//事件消费表
CREATE TABLE `domain_event_subscribe` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `publish_id` int(11) NOT NULL,
  `topic` varchar(255) NOT NULL,
  `content` varchar(2000) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `publish_id` (`publish_id`)
)
  

看到这里你可能觉得会很麻烦和复杂,其实只要代码设计的漂亮,这不过是几行代码的事情。

freedom 还有一个额外的优点,当我们事务成功后,并不一定要用 MQ 这种基础设施去发布通知,也可以选择用 HTTP API RPC ,甚至是 Golang 的 Channel Cond

发布方

发布流程

订阅方

消费流程

三、领域事件的注意事项

领域事件不仅仅只有 Topic ,而且还需要使用唯一身份标识符,这样该事件才是一个 幂等 的事件。

通常发布方用重试用唯一身份标识符来识别,订阅方的幂等消费也用唯一身份标识符来识别。上述示例使用了自增的唯一 ID ,实际生产中建议使用 UUID

虽然事务成功立刻发布消息,定时器只是重试失败的发布,但依然会存在发布和消费的 延迟 。我们因该细心的评估这种影响。是否可以接受?如果不能接受并且要加入 中间状态 (例如‘订单确认中’),是否产生依赖和耦合?

领域事件可以解耦微服务,使限界上下文更清晰,并且达到最终一致。通常 聚合根 可以引用其他限界上下文 Query 来的 值对象 ,然后聚合根发布事件来触发其他限界上下文的变更。聚合根是不能组合其他限界上下文的实体,不论是否在一个服务内。

领域事件也会有陷阱,最常见的是 被动操控型命令 《一、什么是领域事件》 已经简单介绍过命令和事件。

这里还是举个例子,当用户为订单付款后,订单发布事件的Topic如果是 订单已支付 ,这是属于该订单领域模型自身发布的领域事件,这个是正确的。如果Topic是 开发票 ,订单这个领域模型 让谁 开发票???这是错误的,这就是被动操控型命令(Passive-aggressive command).

四、代码实战

创建一个修改密码的领域事件

 package event

import "encoding/json"

// ChangePassword 修改密码事件
type ChangePassword struct {
    ID          int `json:"id"`
    prototypes  map[string]interface{}
    UserID      int    `json:"userID"`
    NewPassword string `json:"newPassword"`
    OldPassword string `json:"oldPassword"`
}

// Topic 返回该事件的Topic.
func (password *ChangePassword) Topic() string {
    return "ChangePassword"
}

// Marshal 序列化.
func (password *ChangePassword) Marshal() []byte {
    data, _ := json.Marshal(password)
    return data
}

// Identity 返回唯一标识.
func (password *ChangePassword) Identity() interface{} {
    return password.ID
}

// SetIdentity 设置唯一标识,通常是事件管理器统一设置.
func (password *ChangePassword) SetIdentity(identity interface{}) {
    password.ID = identity.(int)
}
  

在实体修改密码的行为中使用领域事件

 package entity

import (
    " github .com/8treenet/freedom"
    "github.com/8treenet/freedom/example/fshop/domain/event"
    "github.com/8treenet/freedom/example/fshop/domain/po"
)

type User struct {
    freedom.Entity //实体的基类,继承后可以使用事件集合的方法。
    po.User
}

// Identity 返回实体的唯一标识
func (u *User) Identity() string {
    return strconv.Itoa(u.ID)
}

// ChangePassword 修改密码
func (u *User) ChangePassword(newPassword, oldPassword string) error {
    if u.Password != oldPassword {
        return errors.New("Password error")
    }
    u.SetPassword(newPassword)

    //为实体加入修改密码事件
    u.AddPubEvent(&event.ChangePassword{
        UserID:      u.User.ID,
        NewPassword: u.Password,
        OldPassword: oldPassword,
    })
    return nil
}
  

领域服务的处理

 import (
    "github.com/8treenet/freedom/example/fshop/domain/dependency"
    "github.com/8treenet/freedom/example/fshop/domain/entity"
)

type UserService struct {
    UserRepo    dependency.UserRepo           //依赖倒置用户资源库
    Transaction *domainevent.EventTransaction //依赖注入事务组件
}

// ChangePassword 修改密码
func (user *UserService) ChangePassword(userID int, newPassword, oldPassword string) (e error) {
    //使用资源库读取用户实体
    var userEntity *entity.User
    userEntity, e = user.UserRepo.Get(userID)
    if e != nil {
        return
    }

    //修改密码
    if e = userEntity.ChangePassword(newPassword, oldPassword); e != nil {
        return
    }

    //使用事务组件保证一致性 1.修改密码属性, 2.事件表增加记录
    //Execute 如果返回错误 会触发回滚。成功会调用infra/domainevent/EventManager.push
    e = user.Transaction.Execute(func() error {
        return user.UserRepo.Save(userEntity)
    })
    return
}
  

资源库的处理

 package repository

import (
    "github.com/8treenet/freedom/example/fshop/domain/entity"
    "github.com/8treenet/freedom/example/fshop/infra/domainevent"
    "github.com/8treenet/freedom"
)

type UserRepository struct {
    freedom.Repository
    EventRepository *domainevent.EventManager //领域事件组件
}
// Save .
func (repo *UserRepository) Save(entity *entity.User) error {
    //持久化实体
    _, e := saveUser(repo, &entity.User)
    if e != nil {
        return e
    }
    //持久化事件
    return repo.EventRepository.Save(&repo.Repository, entity)
}
  

领域事件组件介绍

 //领域事件基础设施包
package domainevent

// EventManager 实现了通用的Pub/Sub处理,资源库引入后直接使用Save方法.
type EventManager struct {
}

//EventTransaction 配合EventManager的事务组件.
type EventTransaction struct {
}
  

更多细节,请参考代码。