《领域驱动设计之PHP实现》全书翻译 - 领域事件

第六章 领域事件

  1. 《领域驱动设计之PHP实现》全书翻译 - DDD入门
  2. 《领域驱动设计之PHP实现》全书翻译 - 架构风格
  3. 《领域驱动设计之PHP实现》全书翻译 - 值对象
  4. 《领域驱动设计之PHP实现》全书翻译 - 实体
  5. 《领域驱动设计之PHP实现》全书翻译 - 服务
  6. 《领域驱动设计之PHP实现》全书翻译 - 领域事件
  7. 《领域驱动设计之PHP实现》全书翻译 - 模块
  8. 《领域驱动设计之PHP实现》全书翻译 - 聚合
  9. 《领域驱动设计之PHP实现》全书翻译 - 工厂
  10. 《领域驱动设计之PHP实现》全书翻译 - 仓储
  11. 《领域驱动设计之PHP实现》全书翻译 - 应用服务
  12. 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
  13. 《用PHP实现六边形架构》

软件事件即系统中其它组件感兴趣的事物。PHP 程序员工作中普遍不使用事件,因为这不是该语言的特性。不过,现在更常见的是,新的框架和库采用它们来提供一种新的解耦,重用和加速代码的途径。

领域事件是领域发生改变时相关的事件。领域事件即发生在领域内的事件,是领域专家所关心的。

在领域驱动设计中,领域事件是基础构建块,它们可以:

  • 与其它界限上下文通讯
  • 提高性能和可扩展性,推进最终一致性
  • 作为历史归档记录

领域事件的本质是异步通信。有关此主题的详细信息,我们推荐 Gregor Hohpe 和 Bobby Woolf 《企业集成模式:设计,构建及部署消息传递解决方案》一书。

简介

假设有一个 Javascript 2D 平台游戏,在屏幕上同时有大量不同组件交互。其中一个组件表示剩余生命值,另一个显示所有得分,还有一个显示完成当前等级还剩余的时间。每次你的角色跳到敌人身上,分数就会增加。当你的得分高于一个分数值时,就会获取额外一条命。当你的角色捡起一把钥匙,一扇门通常就会打开。但是所有这些组件是如何相互交互的呢?此场景最佳构架又是什么?

这里有两个可选的方案:第一种是每个组件与它所连接的组件结合起来。不过,在上面的例子中,意味着有大量组件耦合在一起,每个额外的功能增加都需要开发者修改代码。但你还记得开闭原则(OCP)吗?增加一个新的组件不应该使它必须更新第一个组件,这会有太多要维护的工作。

第二种,更好的方法,就是将所有组件连接到一个单独的对象上,该对象处理游戏中所有重要的事件。它接收来自每个组件的事件并转发给特定的组件。例如,得分组件可能对一个 EnemyKilled 事件感兴趣,而 LifeCaptered 事件对玩家实体和剩余生命数组件相当有用。通常这种方式,所有组件与一个管理所有通知的单独组件耦合。使用这种方法,增加或者移除组件都不会影响现有的组件。

当开发一个单体应用时,事件对于解耦组件非常有用。当用分布式方式来开发整个领域时,事件对于领域中发挥作用的每个服务或者应用之间的解耦相当有用。关键点是一样的,但规模却不同。

定义

领域事件是一种特殊类型的事件,用来通知本地或者远程领域限界上下文的变化。
Vaughn Vernon 定义领域事件为:

领域中发生的事情。

Eric Evans 定义领域事件为:

领域事件即领域模型中一个完整的部分,是领域中发生的事件的表现形式。忽然不相碰的领域活动,同时明确领域专家希望追踪的,或者被通知的事件,或与其它领域对象状态改变有关的事件。

Martin Flower 定义领域事件为:

一类捕获影响领域的感兴趣的记录。

在 web 应用里的领域事件例子有用户注册订货转移用户以及添加产品等。

小故事

在一家售票代理机构中,运营经理决定提高 U2 秀节目的价格。她进入后台,编辑该节目。一个ShowPriceChanged 领域事件被发布,并且在同一事务里将新的节目价格持久到数据库中。

一个批处理进程获取该领域事件并它投递到 RabbitMQ 队列中。领域事件被成成两个队列:一是同一个本地限界上下文,另一个远程事件用于商务智能目的。

在第一个队列中,一个工作进程通过事件里的 ID 检索相应的节目,并将其推送到 Elasticsearch 服务器,从而使得用户在搜索时可以看到最新价格。

在第二个队列中,另一个进程将信息插入到一个日志服务器或者数据池,在这可以运行报表或者数据挖掘进程。

一个不能使用领域事件集成到系统的外部应用可以通过本地限界上下文提供的 REST API,访问所有的 ShowPriceChanged 事件。

如你所见,领域事件在处理最终一致性和整合不同限界上下文时非常有用。聚合创建并发布事件。订阅者可以存储事件以及之后转发它们给其它远程订阅者。

隐喻

星期二我们去巴布饭店吃饭,用信用卡支付。这可能被建模为一个事件,事件类型为 "下单",主题是 "我的信用卡", 发生时间为 "星期二"。如果巴布饭使用旧的手动系统,直到周五才传输交易,那么交易将在周五生效。

事情就这样发生了。并不是所有事情都有意义,一些值得记录但并不会发生反应。然而,一般是最感兴趣的事情才发生反应。许多需要对感兴趣的事件做出反应。多数情况下你需要知道为什么一个系统会做出这样的反应。

通过将系统的输入传输到领域事件流中,你可以记录所有的系统输入。这有助于你组织你的处理逻辑,还允许你保留系统输入的审核日志。

练习
尝试在你当前的领域中定位潜在的领域事件

真实案例

在进入了解领域事件的细节之前,让我们来看一个真实的领域事件实例,以及它们是怎样对我们的应用和整个领域起到帮助的。

让我们考虑一个简单的 Application Service,新用户注册,例如一个电子商务上下文。Application Service 会在其它章节阐述,所以不必在表面上操心太多。相反的,仅需要关注执行方法:

class SignUpUserService implements ApplicationService
{
    private $userRepository;
    private $userFactory;
    private $userTransformer;

    public function __construct(
        UserRepository $userRepository,
        UserFactory $userFactory,
        UserTransformer $userTransformer
    )
    {
        $this->userRepository = $userRepository;
        $this->userFactory = $userFactory;
        $this->userTransformer = $userTransformer;
    }

    /**
     * @param SignUpUserRequest $request
     * @return User
     * @throws UserAlreadyExistsException
     */
    public function execute(SignUpUserRequest $request)
    {
        $email = $request->email();
        $password = $request->password();
        $user = $this->userRepository->userOfEmail($email);
        if ($user) {
            throw new UserAlreadyExistsException();
        }
        $user = $this->userFactory->build(
            $this->userRepository->nextIdentity(),
            $email,
            $password
        );
        $this->userRepository->add($user);
        $this->userTransformer->write($user);
    }
}

如上所示,Application Service 部分会检查用户是否存在。如果不存在,则会创建一个新用户并添加到 UserRepository 中。

现在考虑一个附加需求:一个新用户在注册时需要用邮件提醒。不需要想太多,首先我们想到的方法就是更新 Application Service,加入一段可以完成这项工作的代码,可能是 EmailSender 这种在添加方法之后运行的代码。不过,现在让我们考虑另一种方法。

触发一个 UserRegistered 事件,另一个组件监听到后发送邮件怎么样?这种新方法有一些非常酷的好处。首先,在新用户注册时,我们不需要每次再去更新 Application Service 的代码。其次,它更易于测试。Application Service 也变得更简单。每次有新的动作开发时,我们仅需要为此动作写测试用例。

后来在同一个电子商务项目中,我们被告知集成一个非 PHP 编写的开源游戏化平台。每次用户在我们的电子商务上下文下单或者浏览产品时,他们可以在他们的电子商务用户主页上看到所获取的徽章或者被邮件通知到。我们该如何为此问题建模呢?

按照第一种方法,我们将用之前确认电子邮件的方法来更新应用服务,来整合到新的平台中。使用领域事件的方法,我们可以为 UserRegistered 创建另一个 listener 事件,该事件可以用 REST 或者 SOA 的方式连接到游戏平台。更妙的是,它可以将事件放到 RabbitMQ 这样的消息队列,以便游戏限界上下文可以订阅并自动收到通知。我们电子商务限界上下文根本不需要了解游戏上下文。

特征

领域事件通常是不可变的,因为他们是过去某些内容的记录。除了事件的描述外,一个领域事件通常包含一个事件发生时刻的时间戳以及事件中涉及的实体标识。此外,一个领域事件通常具有单独的时间戳,来指示事件何时进入系统,以及输入事件的人员身份。领域事件本身的标识可以基于这些属性集。例如,如果同一个事件的两个实例到达一个节点,它们可以被视为相同。

领域事件的本质就是,你可以使用它来捕获应用中那么可以触发改变的事物,或者领域中其它应用中你感兴趣的改变。这些随后被处理的事件对象会导致系统的改变,并被存储在审记系统中。

命名约定

所有事件都必须用过去时动词表示,因为它们都在过去发生的。例如,CustomerRelocatedCargoShipped,或者 InventoryLossageRecorded。在英语中有一些有趣的例子,人们可能会倾向于使用名词,而不是过去时动词。例如一个对自然灾害感兴趣的国会议来说,"地震"或者"倾塌"就是相关事件。我们建议尽量避免在领域事件中使用类似名词的诱惑,而是坚持用动词的过去时态。

领域事件与通用语言

当我们讨论"重定位用户"的副作用时,请思考通用语言的不同。这个事件使概念变得明确,而以前,聚合或者多个聚合之间发生的改变会留下隐式的概念,这些都需要探索和定义。例如,在大多数系统中,当Hibernate或者实体框架这样的库上发生副作用时,它不会影响到领域。从客户端的角度来看,这些事件是隐式和透明的。事件的引入使概念变得明确,并使之成为通用语言的一部分。"重定位用户"不仅仅是改变某些内容,还会在语言中显式的产生CustomerRelocatedEvent事件。

不变性

正如我们提及过的,领域事件关注的是领域内过去发生的改变。根据定义,你不可能改变过去,除非你是Marty McFly并且有一个DeLorean(译者注:这里是《回到未来》电影里的角色)。因此,请记住领域事件是不可变的。

Symfony 事件分派器

一些 PHP 框架支持事件。不过,不要混淆这些事件与领域事件。它们在特征和目的上是不同的。例如,SymfonyEvent Dispatcher 组件,如果你需要为一个状态机实现一个事件系统,则可以依赖它。在Symfony中,在请求与响应的转换过程也是由事件处理。但是,Symfony Events是可变的,并且每个listeners侦听器都能够修改,添加或者更新事件中的信息。

事件建模

为了准确地描述你的领域业务,你需要与领域专家紧密合作,来定义通用语言。这需要使用领域事件,实体,值对象等等来完成领域概念。在对事件建模时,依据通用语言,在它们的限界上下文内去命名事件及它们的属性。如果一个事件是一个聚合上的命令执行操作的结果,则名称通常派生自执行的命令。事件名称必须反映事件过去的性质,这一点非常和重要。

让我们考虑用户注册功能。领域事件需要表示它。下面的代码显示了基本领域事件的最小接口:

interface DomainEvent
{
    /**
     * @return DateTimeImmutable
     */
    public function occurredOn();
}

正如你所见,最小的必要信息就是DateTimeImmutable,这是为了知道事件是何时发生的。

现在让我们用下面的代码来建模用户注册事件。正如我们在上面提到的,事件名称必须是动词过去式,那么UserRegistered是个不错的选择:

class UserRegistered implements DomainEvent
{
    private $userId;

    public function __construct(UserId $userId)
    {
        $this->userId = $userId;
        $this->occurredOn = new \DateTimeImmutable();
    }

    public function userId()
    {
        return $this->userId;
    }

    public function occurredOn()
    {
        return $this->occurredOn;
    }
}

通知订阅者新用户创建所必需的最少量信息就是 UserId。有了这个信息,任何过程,命令,或者应用服务 - 不管是是否来自同一限界上下文 - 都可能都此事件做出反应。

一般来说

  • 领域事件通常被设计为不变的
  • 构造器将初始化领域事件的全部状态
  • 领域事件有 getters 访问器来访问它们的属性
  • 领域事件包含执行此动作的聚合根
  • 领域事件包含其它与第一个事件关联的聚合根
  • 领域事件包含触发事件的参数(如果有用的话)

但是,如果相同或者不同的限界上下文需要更多信息的话会发生什么?下面让我们看看用更多信息来建模领域事件 - 例如,邮箱地址:

class UserRegistered implements DomainEvent
{
    private $userId;
    private $userEmail;

    public function __construct(UserId $userId, $userEmail)
    {
        $this->userId = $userId;
        $this->userEmail = $userEmail;
        $this->occurredOn = new DateTimeImmutable();
    }

    public function userId()
    {
        return $this->userId;
    }

    public function userEmail()
    {
        return $this->userEmail;
    }

    public function occurredOn()
    {
        return $this->occurredOn;
    }
}

上面,我们添加了邮箱地址,添加更多信息到一个领域事件可以帮助提高性能或者使不同限界上下文整合理简单化。从另一个限界上下文的视角来考虑,也有助于建模事件。当在上游的限界上下文创建一个新用户时,下游的上下文则创建它自己的用户。添加用户邮箱可以保存一个同步请求到上游上下文,以防万一下游的上下文需要它。

你是否还记得游戏化机制的例子?为了创建游平台用户,也就是所说的玩家,那么一个来自电子商务限界上下文的 UserId 可能就够了。但如果游戏平台要用邮件通知用户中奖消息怎么样?在这种情况下,邮箱地址则是必要的。所以,如果邮箱地址包含在源领域事件中,我们就可以做到。如果不在,游戏限界上下文就需要用 REST 或者 SOA 从电子商务上下文中获取这些信息。

为何不用整个用户实体

想知道你是否应该在限界上下文的领域事件中包含整个用户实体?我们的建议是不需要。领域事件一般用于内部的给定上下文或者外部其它上下文的消息通信。换句话说,在 C2C 电子商务产品目录限界上下文中的卖方是谁,产品反馈中的产品评论作者是谁。两者可以共享相同的ID或者电子邮件,但是卖方和作者是不同的概念,代表来自不同的限界上下文。因此,来自一个限界上下文的实体在另一个上下文没有任何意义或完全不同。

Doctrine 事件

领域事件不仅仅是做批量作业,例如发送邮件或者与其它上下文通信。它们也对性能和可扩展提升感兴趣。让我们看一个例子。

考虑以下场景:你有一个电子商务应用,你的主要持久化机制工具是 MySQL,但是对于浏览或者过滤你的产品目录,你用了一个更好的方法,例如 Elasticsearch 或者 Solr。在 Elasticsearch 里,你最获取到存储在完整数据库中的一部分信息。如何保持数据同步?内容团队通过后台工具更新目录时会发生什么?

有人不时重建整个目录的索引。这非常昂贵且缓慢。一种更明智的方法是更新与已更新的产品的一个或一些文档。我们该怎么做呢?答案是使用领域事件。

不过,假如你已经在用 Doctrine 了,这些对你来说就不怎么新鲜了。根据 Doctrine 2 ORM 2 Documentation:

Doctrine 2 具有轻量级事件系统,该系统是 Common 包的一部分。Doctrine 使用它来高度系统事件,主要是生命周期事件。你也可以将其用于你的自定义事件。

此外,它声明了:

生命周期回调定义在一个实体类上。它们使你可以在该实体的实例遇到相关生命周期事件时触发回调。每个生命周期事件可以定义多个回调。生命周期回调最好用于特定实体类生命周期的简单操作上。

让我们看一个来自 Doctrine Events Documentation 中的例子:

/** @Entity @HasLifecycleCallbacks */
class User
{
// ...
    /**
     * @Column(type="string", length=255)
     */
    public $value;
    /** @Column(name="created_at", type="string", length=255) */
    private $createdAt;

    /** @PrePersist */
    public function doStuffOnPrePersist()
    {
        $this->createdAt = date('Y-m-d H:i:s');
    }

    /** @PrePersist */
    public function doOtherStuffOnPrePersist()
    {
        $this->value = 'changed from prePersist callback!';
    }

    /** @PostPersist */
    public function doStuffOnPostPersist()
    {
        $this->value = 'changed from postPersist callback!';
    }

    /** @PostLoad */
    public function doStuffOnPostLoad()
    {
        $this->value = 'changed from postLoad callback!';
    }

    /** @PreUpdate */
    public function doStuffOnPreUpdate()
    {
        $this->value = 'changed from preUpdate callback!';
    }
}

你可以将特定任务挂载到 Doctrine 实体生命周期的每个不同的重要时刻。例如,在 PostPersist 上,你可以生成实体的 JSON 文档并将其放到 Elasticsearch 中。这样,就很容易使不同持久化机制间的数据保持一致。

Doctrine 事件是一个很好的例子来说明用事件围绕你的实体的好处。但是你可以想知道使用它们的问题是什么。这是因为它们耦合到框架,它们是同步的,并且它们在你的应用程序级别上起作用,却不是出于通信的目的。所以这就是为什么尽管难以实施和处理,领域事件仍然非常有趣的原因。

持久化领域事件

持久化事件总是一个好的想法。你们中的一些人可以想知道为什么不能直接发布领域事件到一个消息或者日志系统。这是因为持久化它们有一些有趣的好处:

  • 你可以通过 REST 接口暴露你的领域事件给其它限界上下文
  • 你可以在推送领域事件和聚合变化到 RabbitMQ 持久化它们到同一数据库事务。(你并不想发送未发生的事件通知,就像你并不希望错过发生过的事件通知。)
  • 商务智能系统可以使用这些数据来分析和预测趋势。
  • 你可以审核你的实体变化。
  • 对于事件源(Event Souring)机制,你可以从领域事件中重建聚合。

事件存储

我们在哪持久化领域事件?在一个事件存储器(Event Store)。事件存储器是一个领域事件仓储,它作为一个抽象(接口或抽象类)存在于我们的领域空间中。它的职责是附带领域事件并对进行查询。一种可能的基本接口如下:

interface EventStore
{
    public function append(DomainEvent $aDomainEvent);

    public function allStoredEventsSince($anEventId);
}

然而,根据你领域事件的用途,上一个接口可以有更多的方法来查询事件。

在实现方面,你可以决定使用 Doctrine Respository, DBAL,或者普通的 PDO。因为领域事件是不可变的,所以使用 Doctrine Repository 会加大不必要的性能损失,尽管对于中小型程序而言,Doctrine 可能还够用。让我们看下 Doctrine 的可能实现:

class DoctrineEventStore extends EntityRepository implements EventStore
{
    private $serializer;

    public function append(DomainEvent $aDomainEvent)
    {
        $storedEvent = new StoredEvent(
            get_class($aDomainEvent),
            $aDomainEvent->occurredOn(),
            $this->serializer()->serialize($aDomainEvent, 'json')
        );
        $this->getEntityManager()->persist($storedEvent);
    }

    public function allStoredEventsSince($anEventId)
    {
        $query = $this->createQueryBuilder('e');
        if ($anEventId) {

            $query->where('e.eventId > :eventId');
            $query->setParameters(['eventId' => $anEventId]);
        }
        $query->orderBy('e.eventId');
        return $query->getQuery()->getResult();
    }

    private function serializer()
    {
        if (null === $this->serializer) {
            /** \JMS\Serializer\Serializer\SerializerBuilder */
            $this->serializer = SerializerBuilder::create()->build();
        }
        return $this->serializer;
    }
}

StoreEvent 需要 Doctrine 实体映射到数据库。正如你所见,在附带和持久化 Store 之后,是没有 flush 方法调用的,如果这个操作在 Doctrine 事务内,那么是不必要的。因此,我们暂时搁置这里,我们会在应用服务一章中再深入探讨。

现在我们来看 StoreEvent 的实现:

class StoredEvent implements DomainEvent
{
    private $eventId;
    private $eventBody;
    private $occurredOn;
    private $typeName;

    /**
     * @param string $aTypeName
     * @param \DateTimeImmutable $anOccurredOn
     * @param string $anEventBody
     */
    public function __construct(
        $aTypeName, \DateTimeImmutable $anOccurredOn, $anEventBody
    )
    {
        $this->eventBody = $anEventBody;
        $this->typeName = $aTypeName;
        $this->occurredOn = $anOccurredOn;
    }

    public function eventBody()
    {
        return $this->eventBody;
    }

    public function eventId()
    {
        return $this->eventId;
    }

    public function typeName()
    {
        return $this->typeName;
    }

    public function occurredOn()
    {
        return $this->occurredOn;
    }
}

下面是它的映射:

Ddd\Domain\Event\StoredEvent:
  type: entity
  table: event
  repositoryClass:
    Ddd\Infrastructure\Application\Notification\DoctrineEventStore
  id:
    eventId:
      type: integer
      column: event_id
      generator:
      strategy: AUTO
  fields:
    eventBody:
      column: event_body
      type: text
    typeName:
      column: type_name
      type: string
      length: 255
    occurredOn:
      column: occurred_on
      type: datetime

为了用不同字段来持久化领域事件,我们将不得不将这些字段连接成一个序列化的字符串。 typeName 字段说明领域事件的领域广度。一个实体或者值对象在限界上下文里才有意义,但领域事件在限界上下文间定义了通讯协议。

在分布式系统中,会发生垃圾。你将不得不处理未发布,在事务链中某个地方丢失或已多次发布的领域事务。这就是为什么必须使用 ID 持久化领域事件很重要,这可以轻松跟踪哪个领域事件已经发布,哪个已经丢失。

从领域模型中发布事件

领域事件应该在它们代表的事实发生时发布。例如,当一个新用户已经注册时,一个新的 UserRegistered 事件应该被发布。

参考下面的报纸比喻:

  • 建模一个领域事件就像写一篇新文章
  • 发布一个领域事件就像把文章印刷到报纸上
  • 传播一个领域事件就像投递报纸,让每个人都可以读到这篇文章

发布领域事件推荐的方法就是使用一个简单的监听者 - 观察者模式来实现 DomainEventPublisher

从实体中发布事件

继续用我们应用中新用户注册的例子,我们看看相应的领域事件是怎样发布的:

class User
{
    protected $userId;
    protected $email;
    protected $password;

    public function __construct(UserId $userId, $email, $password)
    {
        $this->setUserId($userId);
        $this->setEmail($email);
        $this->setPassword($password);
        DomainEventPublisher::instance()->publish(
            new UserRegistered($this->userId)
        );
    }
    // ...
}

如示例所示,用户创建时,一个新的 UserRegistered 事件将发布。这在实体的构造函数内完成,而不是外面。因为用这个方法,可以轻松保持我们领域的一致性;任何创建新用户的客户端都会发布相应的事件。另一方面,这使得需要创建用户实体而不使用其构造函数的基础结构变得更加复杂。例如,Doctrine 使用序列化和反序列化技术来重新创建对象而不调用构造函数。然而,如果你必须创建自己的应用程序,这将不会像 Doctrine 那样容易。

一般来讲,从简单数据(例如数组)构造对象称为水合(水化反应)。让我们看看一种简单的方法来构建从数据库中获取的新用户。首先,让我们通过应用工厂方法(Factory Method)模式将领域事件的发布提取为自己的方法。

根据 Wikipedia

模板方法模式是一种行为设计模式,它在一个操作里定义了一个算法的程序骨架,而将实现延迟到子步骤中

class User
{
    protected $userId;
    protected $email;
    protected $password;

    public function __construct(UserId $userId, $email, $password)
    {
        $this->setUserId($userId);
        $this->setEmail($email);
        $this->setPassword($password);
        $this->publishEvent();
    }

    protected function publishEvent()
    {
        DomainEventPublisher::instance()->publish(
            new UserRegistered($this->userId)
        );
    }
    // ...
}

现在,让我们用一个新的基础架构实体来扩展当前的 User 类,该实体将为我们完成这项工作。这里的小技巧是使 publishEvent 方法不执行任何操作,以便领域事件不会被发布:

class CustomOrmUser extends User
{
    protected function publishEvent()
    {
    }

    public static function fromRawData($data)
    {
        return new self(
            new UserId($data['user_id']),
            $data['email'],
            $data['password']
        );
    }
}

记住要谨使用此方法。你可能会从持久化机制中获得无效的对象。因为领域规则总是在变化。另一种不使用父构造函数的方法可能如下:

class CustomOrmUser extends User
{
    public function __construct()
    {
    }

    public static function fromRawData($data)
    {
        $user = new self();
        $user->userId = new UserId($data['user_id']);
        $user->email = $data['email'];
        $user->password = $data['password'];
        return $user;
    }
}

用这种方法,父构造函数不能被调用并且 User 的属性必须被保护。其它的方法还有反射,在本色构造函数里传标识,使用诸如 Proxy-Manager 的代理库,或者使用像 Doctrine 这样的 ORM。

其它发布领域事件的方法

正如你在之前的例子中所见,我们使用了静态类来发布领域事件。作为替代方案,其他人,尤其是在使用事件源时,会建议在实体内用一个字段保存所有触发的事件。为了访问所有事件,在聚合里使用 getter 方法器。这也是一种有效的方法。但是,有时很难跟踪哪些实体已触发事件。在非实体的地方触发事件也可能很困难,例如:领域服务。从好的方面来说,测试一个实体是否触发了事件将容易得多。

从领域或者应用服务中发布事件

你应该努力从更深层的事务链发布领域事件。实体或值对象的内部越近越好。正如我们在上一节中看到的,有时候这并不容易,但最终对于客户端来说却更简单。我们看到开发者从应用服务或者领域服务中发布领域事件。这看起来更容易实现,但最终将导致贫血领域模型。这与在领域服务中推送业务逻辑而不是放到你的实体中没有什么不同。

Domain Event Publisher 是怎样工作的

领域发布发布者(Domain Event Publisher)是一个单例类,来自于我们需要发布领域事件的限界上下文。它同时支付附加监听器,Domain Event Subscriber 会监听他们感兴趣的任何领域事件。这与使用 on 方法的 jQuery 订阅事件没有太大差别:

class DomainEventPublisher
{
    private $subscribers;
    private static $instance = null;

    public static function instance()
    {
        if (null === static::$instance) {
            static::$instance = new static();
        }
        return static::$instance;
    }

    private function __construct()
    {
        $this->subscribers = [];
    }

    public function __clone()
    {
        throw new BadMethodCallException('Clone is not supported');
    }

    public function subscribe(
        DomainEventSubscriber $aDomainEventSubscriber
    )
    {
        $this->subscribers[] = $aDomainEventSubscriber;
    }

    public function publish(DomainEvent $anEvent)
    {
        foreach ($this->subscribers as $aSubscriber) {
            if ($aSubscriber->isSubscribedTo($anEvent)) {
                $aSubscriber->handle($anEvent);
            }
        }
    }
}

publish 方法通过所有可能的订阅者,来检查它们是否对发布的领域事件感兴趣。如果是,订阅者的 handle 方法将被调用。

subscribe 方法添加一个新的 DomainEventSubscriber,它将监听指定的领域事件类型:

interface DomainEventSubscriber
{
    /**
     * @param DomainEvent $aDomainEvent
     */
    public function handle($aDomainEvent);

    /**
     * * @param DomainEvent $aDomainEvent
     * @return bool
     */
    public function isSubscribedTo($aDomainEvent);
}

正如我们已经讨论过的,持久化所有领域事件是个好主意。我们可以在我们的应用程序中通过使用指定的订阅者来轻松地持久化所有已发布的领域事件。我们现在创建一个 DomainEventSubscriber,它会监听所有领域事件,无论什么类型,都会持久化到我们的事件存储器 (EventStore) 中。

class PersistDomainEventSubscriber implements DomainEventSubscriber
{
    private $eventStore;

    public function __construct(EventStore $anEventStore)
    {
        $this->eventStore = $anEventStore;
    }

    public function handle($aDomainEvent)
    {
        $this->eventStore->append($aDomainEvent);
    }

    public function isSubscribedTo($aDomainEvent)
    {
        return true;
    }
}

$eventStore 可以是自定义的 Doctrine Repository, 或者正如所看到的其它有能力持久化DomainEvents 到数据库的对象。

设置领域事件监听者

设置 DomainEventPublisher 订阅者最好的地方是哪里?这看需要。对于可能影响整个请求周期的全局订阅者,最好的位置可能是 DomainEventPublisher 自身初始化的地方。对于受特殊应用服务影响的订阅者,服务实例化的地方可能是个更好的选择。让我们来看一个使用 Silex 的例子。

Silex 里,注册 DomainEventPublisher 最好的方法就是通过使用一个应用中间件持久化所有领域事件。根据 Silex 2.0 Documentation:

一个 before 应用中间件允许你在 controller 执行前调整请求。

这是订阅负责将这些事件持久化到数据库的监听器的正确位置,这些事件将在以后发送到 RabbitMQ:

// ...
$app['em'] = $app->share(function () {
    return (new EntityManagerFactory())->build();
});
$app['event_repository'] = $app->share(function ($app) {
    return $app['em']->getRepository(
        'Ddd\Domain\Model\Event\StoredEvent'
    );
});
$app['event_publisher'] = $app->share(function ($app) {
    return DomainEventPublisher::instance();
});
$app->before(
    function (Symfony\Component\HttpFoundation\Request $request)
    use ($app) {
        $app['event_publisher']->subscribe(
            new PersistDomainEventSubscriber(
                $app['event_repository']
            )
        );
    }
);

使用此设置,每次聚合发布领域事件时,它将被持久化到数据库中。任务完成。

练习

如果你使用 Symfony, Laravel, 或者其它 PHP 框架,找到一种方法,来订阅全局指定订阅者,围绕你的领域事件执行任务。

如果你要在请求即将完成时对所有领域事件执行任何操作,则可以创建一个监听器,该监听器将所有已发布的的领域事件存储在内存中。如果你添加一个 getter 访问器到这个监听器,来返回所有领域事件,则可以决定要做什么。如前文所建议,如果你不想或无法持久化事件到同一事务,这将非常有用。

测试领域事件

你已经知道了如何发布领域事件,但你怎样对此做单元测试并确保 UserRegistered 真的被触发?最简单的方法就是,我们建议用一个指定的 EventListener,它被当作一个 Spy 来记录领域事件是否发布。让我们看看 User 实体的单元测试例子:

use Ddd\Domain\DomainEventPublisher;
use Ddd\Domain\DomainEventSubscriber;

class UserTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function itShouldPublishUserRegisteredEvent()
    {
        $subscriber = new SpySubscriber();
        $id = DomainEventPublisher::instance()->subscribe($subscriber);
        $userId = new UserId();
        new User($userId, 'valid@email.com', 'password');
        DomainEventPublisher::instance()->unsubscribe($id);
        $this->assertUserRegisteredEventPublished($subscriber, $userId);
    }

    private function assertUserRegisteredEventPublished(
        $subscriber, $userId
    )
    {
        $this->assertInstanceOf(
            'UserRegistered', $subscriber->domainEvent
        );
        $this->assertTrue(
            $subscriber->domainEvent->serId()->equals($userId)
        );
    }
}

class SpySubscriber implements DomainEventSubscriber
{
    public $domainEvent;

    public function handle($aDomainEvent)
    {
        $this->domainEvent = $aDomainEvent;
    }

    public function isSubscribedTo($aDomainEvent)
    {
        return true;
    }
}

对于上面有一些替代方案。你可以为 DomainEventPublisher 或者某些反射框架使用静态 setter 来检测调用。不过,我们认为我们分享的方法更为自然。最后但并非最不重要的一点就是,请记住清理 Spy 订阅。以免影响其他单元测试的执行。

广播事件给远程限界上下文

为了将一组领域事件传达给本地或者远程限界上下文,主要有两种策略:消息或者 REST API。第一个方法是使用诸如 RabbitMQ 之类的消息系统来传输领域事件。第二个应时创建一个 REST API,来访问特定上下文的领域事件。

消息中间件

随着所有领域事件持久化到数据库中,唯一剩下的事情就是将它们推送到我们最喜欢的消息系统中。我们更喜欢 RabbitMQ,不过其他任何系统(例如 ActiveMQ 或者 ZeroMQ)都能任务。要使用 PHP 整合 RabbitMQ,没有很多选择,但 php-amqplib 可以完成这项工作。

首先, 我们需要一种能够将持久化的领域事件发送 RabbitMQ 的服务。你可以想要为所有事件而查询 EventStore,并发送每个事件,这不是坏事。然而,我们可以多次推送同一领域事件,通常来说,我们需要将重新发布的领域事件减少到最少。如果重发的领域事件为0,那就更好了。为了不重发领域事件,我们需要某种组件来跟踪哪些领域事件已经被推送,哪些仍然残余。最后但并非最不重要的一点就是,一旦我们知道必须推送哪些领域事件,就将它们发送,并追踪发布到消息系统中的最后一个事件。让我们看一下该服务的可能实现:

class NotificationService
{
    private $serializer;
    private $eventStore;
    private $publishedMessageTracker;
    private $messageProducer;

    public function __construct(
        EventStore $anEventStore,
        PublishedMessageTracker $aPublishedMessageTracker,
        MessageProducer $aMessageProducer,
        Serializer $aSerializer
    )
    {
        $this->eventStore = $anEventStore;
        $this->publishedMessageTracker = $aPublishedMessageTracker;
        $this->messageProducer = $aMessageProducer;
        $this->serializer = $aSerializer;
    }

    /**
     * @return int
     */
    public function publishNotifications($exchangeName)
    {
        $publishedMessageTracker = $this->publishedMessageTracker();
        $notifications = $this->listUnpublishedNotifications(
            $publishedMessageTracker
                ->mostRecentPublishedMessageId($exchangeName)
        );
        if (!$notifications) {
            return 0;
        }
        $messageProducer = $this->messageProducer();
        $messageProducer->open($exchangeName);
        try {
            $publishedMessages = 0;
            $lastPublishedNotification = null;
            foreach ($notifications as $notification) {
                $lastPublishedNotification = $this->publish(
                    $exchangeName,
                    $notification,
                    $messageProducer
                );
                $publishedMessages++;
            }
        } catch (\Exception $e) {
// Log your error (trigger_error, Monolog, etc.)
        }
        $this->trackMostRecentPublishedMessage(
            $publishedMessageTracker,
            $exchangeName,
            $lastPublishedNotification
        );
        $messageProducer->close($exchangeName);
        return $publishedMessages;
    }

    protected function publishedMessageTracker()
    {
        return $this->publishedMessageTracker;
    }

    /**
     * @return StoredEvent[]
     */
    private function listUnpublishedNotifications(
        $mostRecentPublishedMessageId
    )
    {
        return $this
            ->eventStore()
            ->allStoredEventsSince($mostRecentPublishedMessageId);
    }

    protected function eventStore()
    {
        return $this->eventStore;
    }

    private function messageProducer()
    {
        return $this->messageProducer;
    }

    private function publish(
        $exchangeName,
        StoredEvent $notification,
        MessageProducer $messageProducer
    )
    {
        $messageProducer->send(
            $exchangeName,
            $this->serializer()->serialize($notification, 'json'),
            $notification->typeName(),
            $notification->eventId(),
            $notification->occurredOn()
        );
        return $notification;
    }

    private function serializer()
    {
        return $this->serializer;
    }

    private function trackMostRecentPublishedMessage(
        PublishedMessageTracker $publishedMessageTracker,
        $exchangeName,
        $notification
    )
    {
        $publishedMessageTracker->trackMostRecentPublishedMessage(
            $exchangeName, $notification
        );
    }
}

NotificationService 依赖三个接口。我们已经看到 EventStore,它主要负责增加和查询领域事件。第二个是 PublishedMessageTracker,主要用来追踪已推送的消息。第三个就是 MessageProducer,一个表示我们消息系统的接口:

interface PublishedMessageTracker
{
    /**
     * @param string $exchangeName
     * @return int
     */
    public function mostRecentPublishedMessageId($exchangeName);

    /**
     * @param string $exchangeName
     * @param StoredEvent $notification
     */
    public function trackMostRecentPublishedMessage(
        $exchangeName, $notification
    );
}

mostRecentPublishedMessageId 方法返回 最后发布消息的 ID,因此这个过程可以从下一次开始。trackMostRecentPublishedMessage 负责追踪哪个消息是最后发送的,目的是在你可能需要时重发消息。exchangeName 代表我们将要把领域事件发往的通信频道。让我们看看一个 Doctrine 实现的 PublishedMessageTracker

class DoctrinePublishedMessageTracker extends EntityRepository\
implements PublishedMessageTracker
{
    /**
     * @param $exchangeName
     * @return int
     */
    public function mostRecentPublishedMessageId($exchangeName)
    {
        $messageTracked = $this->findOneByExchangeName($exchangeName);
        if (!$messageTracked) {
            return null;
        }
        return $messageTracked->mostRecentPublishedMessageId();
    }

    /**
     * @param $exchangeName
     * @param StoredEvent $notification
     */
    public function trackMostRecentPublishedMessage(
        $exchangeName, $notification
    )
    {
        if (!$notification) {
            return;
        }
        $maxId = $notification->eventId();
        $publishedMessage = $this->findOneByExchangeName($exchangeName);
        if (null === $publishedMessage) {
            $publishedMessage = new PublishedMessage(
                $exchangeName,
                $maxId
            );
        }
        $publishedMessage->updateMostRecentPublishedMessageId($maxId);
        $this->getEntityManager()->persist($publishedMessage);
        $this->getEntityManager()->flush($publishedMessage);
    }
}

这里的代码非常简单明了。我们唯一需要的极端情况就是,系统尚未发布任何领域事件。

为什么是交换机名称?

我们将在第 12 章集成限界上下文一章中更为详细地介绍这一点。但是,当系统正在运行并且新的限界上下文开始起作用时,你可能会对所有领域事件重发到新的限界上下文感兴趣。因此,跟踪上一次发布的领域事件及其改善的频道可能会在以后派上用场。

为了跟踪已发布的领域事件,我们需要一个交换机名称一个通知 ID。下面是一种可能的实现:

class PublishedMessage
{
    private $mostRecentPublishedMessageId;
    private $trackerId;
    private $exchangeName;

    /**
     * @param string $exchangeName
     * @param int $aMostRecentPublishedMessageId
     */
    public function __construct(
        $exchangeName, $aMostRecentPublishedMessageId
    )
    {
        $this->mostRecentPublishedMessageId =
            $aMostRecentPublishedMessageId;
        $this->exchangeName = $exchangeName;
    }

    public function mostRecentPublishedMessageId()
    {
        return $this->mostRecentPublishedMessageId;
    }

    public function updateMostRecentPublishedMessageId($maxId)
    {
        $this->mostRecentPublishedMessageId = $maxId;
    }

    public function trackerId()
    {
        return $this->trackerId;
    }
}

这是其对应的映射关系:

Ddd\Domain\Event\PublishedMessage:
  type: entity
  table: event_published_message_tracker
  repositoryClass:
    Ddd\Infrastructure\Application\Notification\
    DoctrinePublished\MessageTracker
  id:
  trackerId:
    column: tracker_id
    type: integer
    generator:
    strategy: AUTO
  fields:
    mostRecentPublishedMessageId:
      column: most_recent_published_message_id
      type: bigint
    exchangeName:
      type: string
      column: exchange_name

现在,让我们看看 MessageProducer 接口用来做什么的,以及它的实现细节:

interface MessageProducer
{
    public function open($exchangeName);

    /**
     * @param $exchangeName
     * @param string $notificationMessage
     * @param string $notificationType
     * * @param int $notificationId
     * @param \DateTimeImmutable $notificationOccurredOn
     * @return
     */
    public function send(
        $exchangeName,
        $notificationMessage,
        $notificationType,
        $notificationId,
        \DateTimeImmutable $notificationOccurredOn
    );

    public function close($exchangeName);
}

简单!openclose 方法打开和关闭一个消息系统连接。send 方法携带一个消息体(消息名称及消息 ID),并发送到我们的消息引擎,而不用关心它是什么。因为我们选择的是 RabbitMQ,我们需要实现连接及发送过程:

abstract class RabbitMqMessaging
{
    protected $connection;
    protected $channel;

    public function __construct(AMQPConnection $aConnection)
    {
        $this->connection = $aConnection;
        $this->channel = null;
    }

    private function connect($exchangeName)
    {
        if (null !== $this->channel) {
            return;
        }
        $channel = $this->connection->channel();
        $channel->exchange_declare(
            $exchangeName, 'fanout', false, true, false
        );
        $channel->queue_declare(
            $exchangeName, false, true, false, false
        );
        $channel->queue_bind($exchangeName, $exchangeName);
        $this->channel = $channel;
    }

    public function open($exchangeName)
    {
    }

    protected function channel($exchangeName)
    {
        $this->connect($exchangeName);
        return $this->channel;
    }

    public function close($exchangeName)
    {
        $this->channel->close();
        $this->connection->close();
    }
}

class RabbitMqMessageProducer
    extends RabbitMqMessaging
    implements MessageProducer
{
    /**
     * @param $exchangeName
     * @param string $notificationMessage
     * @param string $notificationType
     * @param int $notificationId
     * @param \DateTimeImmutable $notificationOccurredOn
     */
    public function send(
        $exchangeName,
        $notificationMessage,
        $notificationType,
        $notificationId,
        \DateTimeImmutable $notificationOccurredOn
    )
    {
        $this->channel($exchangeName)->basic_publish(
            new AMQPMessage(
                $notificationMessage,
                [
                    'type' => $notificationType,
                    'timestamp' => $notificationOccurredOn->getTimestamp(),
                    'message_id' => $notificationId
                ]
            ),
            $exchangeName
        );
    }
}

现在我们有了一个 DomainService,可以将领域事件推送到 RabbitMQ 这样的消息系统中,是时候执行它们了。我们需要选择一种交付机制来运行服务。我们个人建议是创建一个 Symfony Console 命令:

class PushNotificationsCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('domain:events:spread')
            ->setDescription('Notify all domain events via messaging')
            ->addArgument(
                'exchange-name',
                InputArgument::OPTIONAL,
                'Exchange name to publish events to',
                'my-bc-app'
            );
    }

    protected function execute(
        InputInterface $input, OutputInterface $output
    )
    {
        $app = $this->getApplication()->getContainer();
        $numberOfNotifications =
            $app['notification_service']
                ->publishNotifications(
                    $input->getArgument('exchange-name')
                );
        $output->writeln(
            sprintf(
                '<comment>%d</comment>' .
                '<info>notification(s) sent!</info>',
                $numberOfNotifications
            )
        );
    }
}

按照这个 Silex 例子,让我们看看定义在 Silex Pimple Service Container 中的 $app['notification_service'] 的定义:

// ...
$app['event_store'] = $app->share(function ($app) {
    return $app['em']->getRepository('Ddd\Domain\Event\StoredEvent');
});
$app['message_tracker'] = $app->share(function ($app) {
    return $app['em']
        ->getRepository('Ddd\Domain\Event\Published\Message');
});
$app['message_producer'] = $app->share(function () {
    return new RabbitMqMessageProducer(
        new AMQPStreamConnection('localhost', 5672, 'guest', 'guest')
    );
});
$app['message_serializer'] = $app->share(function () {
    return SerializerBuilder::create()->build();
});
$app['notification_service'] = $app->share(function ($app) {
    return new NotificationService(
        $app['event_store'],
        $app['message_tracker'],
        $app['message_producer'],
        $app['message_serializer']
    );
});
//...

用 REST 同步领域服务

有了消息传统中已经实现的 EventStore,应该很容易添加一些分布功能,领域查询事件以及渲染 JSON 或者 XML 表述,以发布 REST API。为什么这么有趣?嗯,分布式系统使用消息中间件必须面对许多不同的问题,例如消息未到达,消息重复到达,或消息到达失序。这就是为什么需要一个 API 来发布你的领域事件,以便其它限界上下文可以要求一些缺失信息的原因。仅作为示例,考虑一个 /event 端点的 HTTP 请求。一个可能的实现如下:

[
  {
    "id": 1,
    "version": 1,
    "typeName": "Lw\\Domain\\Model\\User\\UserRegistered",
    "eventBody": {
      "user_id": {
        "id": "459a4ffc-cd57-4cf0-b3a2-0f2ccbc48234"
      }
    },
    "occurredOn": {
      "date": "2016-05-26 06:06:07.000000",
      "timezone_type": 3,
      "timezone": "UTC"
    }
  },
  {
    "id": 2,
    "version": 2,
    "typeName": "Lw\\Domain\\Model\\Wish\\WishWasMade",
    "eventBody": {
      "wish_id": {
        "id": "9e90435a-395c-46b0-b4c4-d4b769cbf201"
      },
      "user_id": {
        "id": "459a4ffc-cd57-4cf0-b3a2-0f2ccbc48234"
      },
      "address": "john@example.com",
      "content": "This is my new wish!"
    },
    "occurredOn": {
      "date": "2016-05-26 06:06:27.000000",
      "timezone_type": 3,
      "timezone": "UTC"
    },
    "timeTaken": "650"
  }
  //...
]

如你在前面的示例中所见,我们在一个 JSON REST API 中暴露一组领域事件。在输出示例中,你可以看到一个关于每个领域事件的 JSON 表述。这有一些有趣的要点。首先是 version 字段。有时你的领域事件会发展:它们会包含更多字段,它们会改变某些现有字段的行为,或者会删除某些现有字段。这就是在领域事件中添加 version 字段很重要的原因。如果其他限界上下文正在监听此类事件,则它们可以使用 version 字段以不同方式解析领域事件。在对 REST API 进行版本控制时,你也可能会遇到相同的问题。

另外一个就是名称。如果你想使用领域事件的类名,那么大多数情况下都可以。问题是当团队由于重构而决定更改类名时,在这种情况下,所有监听该名称的限界上下文都将停止工作。仅当你在同一队列中发布不同领域事件时,才会出现此问题。如果你将每个领域事件类型发布到不同的队列中,则不是真正的问题,但如果你选择这种方法,那么将面临一系列不同的问题,例如接收无序事件。像许多其他情况下一样,这需要权衡。我们强烈建议你阅读 **《企业集成模式:设计,构建和部署消息系统解决方案》。在这本书里,你将学习使用异步方法集成多个应用程序的不同模式。由于领域事件在集成频道发送消息,因此所有消息模式都适用于它们。

练习

考虑为领域事件使用 REST API 的利弊。考虑限界上下文耦合。你也可以为你当前的应用实现 REST API。

小结

我们看到了使用基本接口建模一个合适的领域事件的技巧,也了解到在何处发布领域事件(越接近实体越好),并且了解到将这些领域事件传播到本地和远程限界上下文的策略。现在,剩下的唯一事情就是在消息系统中监听通知,读取通知,并执行相应的应用服务或命令。我们将在第 12 章,集成有限上下文 和第 5 章,服务 中看到如何执行此操作。

阅读 1.5k

推荐阅读