《领域驱动设计之PHP实现》全书翻译 - 集成上下文

 阅读约 27 分钟

集成上下文

  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实现六边形架构》

每个企业应用通常由公司运营的多个领域组成。计费,库存,运输管理,产品目录等等领域是常见示例。解决所有问题最简单的方法似乎倾向于单一系统。但是,你可能想知道,是否一定要这样?如果将这个庞大的整体应用程序分成较小的独立块,可以减少在这些不同领域工作的团队之间的摩擦,该怎么办?在本章中,我们将探索如何做到这一点,所以要对战略设计的见解和启发式学习做好准备。

使用分布式系统

用分布式系统处理很困难。把系统分成独立自主的部分有它的好处,但同时带来了复杂性。例如,分布式系统的协调和同步并非易事,因此要谨慎考虑。正如 Martin Fowler 在 PoEAA 一书中所说的那样,分布式系统的第一定律始终是:不要分布式

通过数据存储集成

集成一个应用程序不同部分最常见的一种技术一直是共享相同的数据存储,以及相同的代码库。这通常称为单体应用(monolithic application),它通常以单个数据存储结束,该数据存储托管与应用程序中所有关注事项相关的数据。

考虑一个电子商务应用。共享数据存储将包含围绕目录,账单,库存等所有关注事项(例如:关系数据库中的表)。这种方法本身没有什么问题,例如,在复杂度不太高的小型线性应用中。但是,在复杂的领域中,可能会出现一些问题。如果你在涉及多个应用程序问题的多个表之间共享数据,则事务将对性能产生重大影响。

另一个会出现的技术性较弱的问题是通用语言。分离限界上下文的主要优点是每个上下文都有一个单一的通用语言。这样,模型将被分离成自己的上下文。在同一个上下文中将所有模型混合在一起会导致歧义和混乱。

回到电子商务系统,想象一下我们想引入T恤的概念。在目录上下文,T恤是一种具有颜色,尺寸,材料和一些精美图片等属性的产品。但是,在库存系统中,我们并不真正关心这些事情。在这里,产品具有不同的含义,我们关注的是不同的属性,例如重量,仓库中的位置或尺寸。将这两个上下文混合在一起会使概念纠缠并使设计复杂化。用领域驱动设计的术语来说,以这种方式混合的概念就是所谓的共享内核(Shared Kernel)。

共享内核
是指定团队同意共享的领域模型的子集。当然,这包括与模型的子集一起的,与模型的该部分相关联的代码或数据库设计的子集。明确共享的内容具有特殊的地位,在没有与其他团队协商的情况下不应更改。经常集成功能系统,但频率不如团队内部连续集成的速度。在这些集成中,运行两个团队的测试。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

我们不建议使用共享内核,因为多个团队可能会在其开发过程中发生冲突,这不仅会导致维护问题,而且会引起冲突。但是,如果你选择使用共享内核,则应事先在相关各方之间达成一致。从概念上讲,这种方法还存在其他问题,例如人们将其视为放置不属于其他任何地方的东西的袋子,并且这个问题会无限期的增长。处理整体的不断增长的复杂性的一种更好的方法是将其分解为不同的自治部分,例如通过 REST,RPC 或消息系统进行通信。这就要求划清界限,每个上下文最终都可能拥有自己的基础架构(数据存储,服务器,消息中间件等),甚至是自己的团队。

正如你想象的那样,这可能导致某种程度的重复,但这是我们为降低复杂性而愿意做出的权衡。在领域驱动设计中,我们将这些独立的部分称为限界上下文。

关系集成(Integration Relationships)

客户 - 供应商

当两个限界上下文之间存在单向集成时,其中一个充当提供者(上游),另一个充当客户(下游),我们最终得到一个客户 - 供应商开发团队。

在两个团队之间建立清晰的客户/供应商关系。在计划会议中,让下游团队扮演上游团队的客户角色。协商和预算下游需求的任务,以便每个人都了解承诺和进度。共同开发自动验收测试,以验证预期的接口。将这些测试添加到上游团队的测试套件中,以作为其持续集成的一部分运行。该测试将使上游团队有能力进行更改,而不必担心下游的副作用。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

客户 - 供应商开发团队是集成限界上下文最常见的方式,并且当团队紧密工作时通常呈现一个双赢的局面。

分离方式(Separate Ways)

继续电子商务的例子,考虑将收入报告给旧的传统零售商财务系统。集成可能会非常昂贵,从而导致不值得进行努力。在领域驱动设计战略术语中,这称为分离方式。

集成问题昂贵的。有时候收益很小。因此,声明一个限界上下文根本不与其他任何上下文关联,从而使开发人员可以在这个很小的范围内找到简单,专业的解决方案。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

追随者(Conformist)

再次考虑电子商务例子以及与第三方运输服务的集成。这两个领域在模型,团队和基础架构上都不同。负责维护第三方运输服务的团队将不会参与你的产品计划或为电子商务系统提供任何解决方案。这些团队没有亲密关系。我们可以选择接受并遵循他们的领域模型。在战略设计中,这就是所谓的追随集成(Conformist Integration)。

通过严格遵守上游团队的模型,消除上绑定上下文之间翻译的复杂性。尽管这限制了下游设计人员的风格,并且应用程序提供理想的模型,但是选择 CONFORMITY 可以极大地简化集成。另外,你将与供应商团队共享通用语言。供应商位于驾驶员座位上,因此使他们之间的交流变得容易。利他主义可能足以使他们与你共享信息。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

实现上下文集成

为了使事情更简单,我们假设限界上下文存在客户 - 供应商关系。

现代 RPC

对于现代 RPC,我们通过 RESTful 资源引用 RPC。一个限界上下文提示了与外界交互的清晰接口。它暴露了可以通过 HTTP 动词操作的资源。我们可以说限界上下文提供了一组服务和操作。从策略上讲,这就是所谓的开放主机服务(Open Host Service)。

开放主机服务

定义一个协议,以一组服务的形式访问子系统。打开协议,以便所有需要与你集成的人都可以使用它。增强并扩展协议以处理新的集成需求,除非单个团队有特殊需求。然后,使用一次性翻译器扩展该特殊情况的协议,以便共享协议可以保持简单和一致。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

让我们研究本书提供的,GitHub 随附的应用程序,Last Wishes 中的示例。

这个应用是一个让降死之人留下他们最后的遗愿的 web 平台。它有两个上下文:一个负责处理遗愿-遗愿上下文,一个负责为系统用户提供积分-游戏化上下文。在遗愿上下文里,用户可能具有 与用户在游戏化上下文中所获得的分数有关的徽章。这意味着我们需要将两个上下文集成在一起,以显示用户在遗愿上下文上拥有的徽章。

游戏化上下文是由定制的事件源引擎提供支持的成熟的事件驱动应用程序。根据 Richardson 成熟度模型( Richardson Maturity Model)。这是一个完整的 Symfony 应用程序,它使用 FOSRestBundle,BazingaHateoasBundle,JMSSerializerBundle,NelmioApiDocBundle 和 OngrElasticsearchBundle 来提供 3 级以上的 REST API(通常称为 Glory of REST)。此上下文中触发的所有事件都将针对 Elasticsearch 服务器进行投影,以生成视图所需的数据。我们将通过诸如 http://gamification.context.h... 之类的端点公开给定用户的分数。

我们还将从 Elasticsearch 获取用户投影并将其序列化为先前与客户端协商的格式:

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;

class UsersController extends FOSRestController
{
    /**
     * @ApiDoc(
     * resource = true,
     * description = "Finds a user given a user ID",
     * statusCodes = {* 200 = "Returned when the user have been found",
     * 404 = "Returned when the user could not be found"
     * }
     * )
     *
     * @Rest\View(
     * statusCode = 200
     * )
     */
    public function getUserAction($id)
    {
        $repo = $this->get('es.manager.default.user');
        $user = $repo->find($id);
        if (!$user) {
            throw $this->createNotFoundException(
                sprintf(
                    'A user with an ID of %s does not exist',
                    $id
                )
            );
        }
        return $user;
    }

正如我们在第 2 章,架构风格中解释的那样,读取被视为基础设施问题,因此无需将它们包装在一个 Command / Command Handler 流中。

得到的 JSON + HAL 用户表述像这样:

{
  "id": "c3c587c6-610a-42df",
  "points": 0,
  "_links": {
    "self": {
      "href":
      "http://gamification.ctx/api/users/c3c587c6-610a-42df"
    }
  }
}

现在我们处于集成两个上下文的良好位置。我们只需要在遗愿上下文中编写客户端即可消费我们刚刚创建的端点。我们应该混合两种领域模型吗?直接消化游戏化上下文将意味着使遗愿上下文适应游戏化上下文,从而产生一个 Conformist integration。但是,分离这些问题似乎值得付出努力。我们需要一个层来保证遗愿上下文中的领域模型的完整性和一致性,并且需要将积分(游戏化)转换为徽章(遗愿)。在领域驱动设计中,这种转换机制称为防腐层(Anti-Corruption layer)。

防腐层

即创建一个隔离层,以根据客户端自己的领域模型为客户提供功能。该层通过其现有接口与另一个系统进行通信,几乎不需要或不需要对其进行任何修改。在内部,该层在两个模型之间都需要在两个方向上平移。
Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

那么,防腐层长什么样子呢?大多数时候,服务会和 Adapters 与 Facades 的结合体进行交互。服务封闭并隐藏了这些转换背后的底层复杂性。Facades 有助于隐藏和封装从游戏化模型中获取数据所需的访问详细信息。Adapters 通常使用专门的转换器在模型之间转换。

让我们看看如何在遗愿模型中定义用户服务,该服务将负责检索给定用户获得的徽章:

namespace Lw\Domain\Model\User;
interface UserService
{
    public function badgesFrom(UserId $id);
}

现在,让我们看看基础设施方面的实现。我们将使用一个 adapter 做过程转换:

namespace Lw\Infrastructure\Service;
use Lw\Domain\Model\User\UserId;
use Lw\Domain\Model\User\UserService;
class TranslatingUserService implements UserService
{
    private $userAdapter;
    public function __construct(UserAdapter $userAdapter)
    {
        $this->userAdapter = $userAdapter;
    }
    public function badgesFrom(UserId $id)
    {
        return $this->userAdapter->toBadges($id);
    }
}

以及这里是 UserAdapter 的 HTTP 实现:

namespace Lw\Infrastructure\Service;
use GuzzleHttp\Client;
class HttpUserAdapter implements UserAdapter
{
    private $client;
    public function __construct(Client $client)
    {
        $this->client = $client;
    }
    public function toBadges( $id)
    {
        $response = $this->client->get(
            sprintf('/users/%s', $id),
            [
                'allow_redirects' => true,
                'headers' => [
                    'Accept' => 'application/hal+json'
                ]
            ]
        );
        $badges = [];
        if (200 === $response->getStatusCode()) {
            $badges =
                (new UserTranslator())
                    ->toBadgesFromRepresentation(
                        json_decode(
                            $response->getBody(),
                            true
                        )
                    );
        }
        return $badges;
    }
}

如你所见,Adapter 也充当了游戏化上下文的 Facade。我们这样做是因为在游戏化一侧获取用户资源非常简单。Adapter 使用 UserTranslator 执行转换:

namespace Lw\Infrastructure\Service;
use Lw\Infrastructure\Domain\Model\User\FirstWillMadeBadge;
use Symfony\Component\PropertyAccess\PropertyAccess;
class UserTranslator
{
    public function toBadgesFromRepresentation($representation)
    {
        $accessor = PropertyAccess::createPropertyAccessor();
        $points = $accessor->getValue($representation, 'points');
        $badges = [];
        if ($points > 3) {
            $badges[] = new FirstWillMadeBadge();
        }
        return $badges;
    }
}

Translator 专门将游戏化上下文中的积分转换为徽章。

我们已经展示了如何集成两个限界上下文,其中各个团队共享一个客户 - 供应商关系。游戏化上下文通过由 RESTful 协议实现的开放主机服务暴露集成。另一方面,遗愿上下文通过防腐层使用服务,该层负责将模型从一个领域转换为另一个领域,从而确保遗愿上下文的完整性。

消息队列

RESTful 资源并不是实现限界上下文集成的唯一方法。就像我们将看到的那样,消息中间件也可以支持不同上下文之间的解耦集成。

我们可以使用拉(pull)策略来寻求另一个开放主机服务。遗愿上下文定期拉取游戏化上下文,以使徽章同步(例如:通过 cron 这样的调度程序)。这种解决方案将影响用户的体验,并且将浪费大量不必要的资源。

更好的方法是使用消息中间件。使用这种解决方案,上下文可以把消息推送到中间件(通常是消息中间件)。感兴趣的各方都能够按需以解耦的方式进行订阅,检查和使用。为此,我们需要一种专门的,共享的和通用的通信语言,以便所有各方都可以理解所传输的信息。这就是所谓的发布语言(Published Language)。

发布语言

是使用一个良好文档化的共享语言,该共享语言可以将必要的领域信息表示为通用的通信媒介,并在必要时进行该语言的进出翻译。

Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》

在考虑这些消息的格式并仔细研究我们的领域模型时,我们意识到我们已经拥有了所需要的:第 6 章,领域事件。它不必定义限界上下文之间进行通信的新方式。相反,我们可以仅使用领域事件来定义跨上下文的通用语言。领域专家关心的事情的定义恰好符合我们正在寻找的东西:一种正式的发布语言。

在我们的示例中,我们可以使用 RabbitMQ 作为消息中间件。这可能是最可靠,最强壮的 AMQP 协议消息系统之一。我们还将结合广泛使用的库 php-amqplib 和 RabbitMQBundle。

让我们从遗愿上下文开始,因为它是在用户注册或许愿时触发事件。正如我们在第 6 章,领域事件中已经看到的那样,将领域事件存储到持久化机制里是个好主意,因此我们假设已经完成了工作。我们需要一个消息发布者从事件存储中获取领域事件并将其发布到消息中间件。我们已经在第 6 章,领域事件中完成了与 RabbitMQ 的集成,因此我们只需要在游戏化上下文中实现代码即可。我们将监听遗愿上下文触发的事件。由于我们使用 Symfony 框架,我们将利用 RabbitMQBundle 包。

我们将为 User Registered 和 Wish Was Made 事件定义两个消息消费者:

namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib;
use Lw\Gamification\Command\SignupCommand;
use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface;
use PhpAmqpLib\Message\AMQPMessage;
class PhpAmqpLibLastWillUserRegisteredConsumer
    implements ConsumerInterface
{
    private $commandBus;
    public function __construct($commandBus)
    {
        $this->commandBus = $commandBus;
    }
    public function execute(AMQPMessage $message)
    {
        $type = $message->get('type');
        if('Lw\Domain\Model\User\UserRegistered' === $type) {
            $event = json_decode($message->body);
            $eventBody = json_decode($event->event_body);
            $this->commandBus->handle(
                new SignupCommand($eventBody->user_id->id)
            );
            return true;
        }
        return false;
    }
}

注意在这个例子中,我们仅仅用 Lw\Domain\Model\User\UserRegistered 处理消息:

namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib;
use Lw\Gamification\Command\RewardUserCommand;
use Lw\Gamification\Domain\Model\AggregateDoesNotExist;
use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface;
use PhpAmqpLib\Message\AMQPMessage;
class PhpAmqpLibLastWillWishWasMadeConsumer implements ConsumerInterface
{
    private $commandBus;
    public function __construct($commandBus)
    {
        $this->commandBus = $commandBus;
    }
    public function execute(AMQPMessage $message)
    {
        $type = $message->get('type');
        if ('Lw\Domain\Model\Wish\WishWasMade' === $type) {
            $event = json_decode($message->body);
            $eventBody = json_decode($event->event_body);
            try {
                $points = 5;
                $this->commandBus->handle(
                    new RewardUserCommand(
                        $eventBody->user_id->id,
                        $points
                    )
                );
            } catch (AggregateDoesNotExist $e) {
// Noop
            }
            return true;
        }
        return false;
    }
}

同样,我们仅对跟踪 Lw\Domain\Model\Wish\WishWasMade 事件感兴趣。

在两个例子中,我们都使用了命令总线(Command Bus),这我们在第 10 章,应用中讨论过。但是,我们可以将其比喻为解耦命令和接收者的高速公路。执行命令的时间和方式与触发它的人无关。

游戏化上下文使用 Iactician(和 IacticianBundle),一个简单的命令总线,可以扩展并适应你的系统。因此,现在我们几乎准备好开始使用遗愿上下文中的事件。

我们唯一需要做的事就是在 Symonfony 中定义 RabbitMQBundle 的配置文件 config.yml

services:
  last_will_user_registered_consumer:
    class: AppBundle\Infrastructure\Messaging\PhpAmqpLib\PhpAmqpLibLastWillUserRegisteredConsumer
    arguments:
      - @tactician.commandbus
  last_will_wish_was_made_consumer:
    class: AppBundle\Infrastructure\Messaging\PhpAmqpLib\PhpAmqpLibLastWillWishWasMadeConsumer
    arguments:
      - @tactician.commandbus
old_sound_rabbit_mq:
  connections:
    default:
      host: " %rabbitmq_host%"
      port: " %rabbitmq_port%"
      user: " %rabbitmq_user%"
      password: " %rabbitmq_password%"
      vhost: " %rabbitmq_vhost%"
      lazy: true
  consumers:
    last_will_user_registered:
      connection: default
      callback: last_will_user_registered_consumer
      exchange_options:
        name: last-will
        type: fanout
      queue_options:
      name: last-will
    last_will_wish_was_made:
      connection: default
      callback: last_will_wish_was_made_consumer
      exchange_options:
        name: last-will
        type: fanout
      queue_options:
        name: last-wil

RabbitMQ 最方便的配置可能是发布/订阅模式。遗愿上下文发布的所有消息都将分发给所有连接的消费者。这在 RabbitMQ 交换机配置里称为 fanout。

交换机由负责将消息传统到相应队列的代理组成:

> php app/console rabbitmq:consumer --messages=1000
last_will_user_registered
> php app/console rabbitmq:consumer --messages=1000 last_will_wish_was_made

使用这两个命令,Symfony 将同时执行两个消费者,并且他们将开始监听领域事件。我们已指定限制消费 1000 条消息,因为 PHP 并不是执行长时间运行进程的最佳平台。最好使用 Supervisor 之类的工具定期监视和重启进程。

小结

尽管我们只看到它的一小部分,战略设计是领域驱动设计的灵魂和核心。它的精髓部分有助于开发更好和更多的语义化模型。我们推荐使用消息中间件集成限界上下文,因为它自然地产出简单,解耦,和事件驱动的架构。

阅读 361更新于 1月14日

推荐阅读
目录