每一个企业应用一般由公司运营的多个领域组成。计费,库存,运输管理,产品目录等等领域是常见示例。解决全部问题最简单的方法彷佛倾向于单一系统。可是,你可能想知道,是否必定要这样?若是将这个庞大的总体应用程序分红较小的独立块,能够减小在这些不一样领域工做的团队之间的摩擦,该怎么办?在本章中,咱们将探索如何作到这一点,因此要对战略设计的看法和启发式学习作好准备。php
使用分布式系统用分布式系统处理很困难。把系统分红独立自主的部分有它的好处,但同时带来了复杂性。例如,分布式系统的协调和同步并不是易事,所以要谨慎考虑。正如 Martin Fowler 在 PoEAA 一书中所说的那样,分布式系统的第必定律始终是:不要分布式web
集成一个应用程序不一样部分最多见的一种技术一直是共享相同的数据存储,以及相同的代码库。这一般称为单体应用(monolithic application),它一般以单个数据存储结束,该数据存储托管与应用程序中全部关注事项相关的数据。数据库
考虑一个电子商务应用。共享数据存储将包含围绕目录,帐单,库存等全部关注事项(例如:关系数据库中的表)。这种方法自己没有什么问题,例如,在复杂度不过高的小型线性应用中。可是,在复杂的领域中,可能会出现一些问题。若是你在涉及多个应用程序问题的多个表之间共享数据,则事务将对性能产生重大影响。json
另外一个会出现的技术性较弱的问题是通用语言。分离限界上下文的主要优势是每一个上下文都有一个单一的通用语言。这样,模型将被分离成本身的上下文。在同一个上下文中将全部模型混合在一块儿会致使歧义和混乱。segmentfault
回到电子商务系统,想象一下咱们想引入T恤的概念。在目录上下文,T恤是一种具备颜色,尺寸,材料和一些精美图片等属性的产品。可是,在库存系统中,咱们并不真正关心这些事情。在这里,产品具备不一样的含义,咱们关注的是不一样的属性,例如重量,仓库中的位置或尺寸。将这两个上下文混合在一块儿会使概念纠缠并使设计复杂化。用领域驱动设计的术语来讲,以这种方式混合的概念就是所谓的共享内核(Shared Kernel)。api
共享内核
是指定团队赞成共享的领域模型的子集。固然,这包括与模型的子集一块儿的,与模型的该部分相关联的代码或数据库设计的子集。明确共享的内容具备特殊的地位,在没有与其余团队协商的状况下不该更改。常常集成功能系统,但频率不如团队内部连续集成的速度。在这些集成中,运行两个团队的测试。Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》服务器
咱们不建议使用共享内核,由于多个团队可能会在其开发过程当中发生冲突,这不只会致使维护问题,并且会引发冲突。可是,若是你选择使用共享内核,则应事先在相关各方之间达成一致。从概念上讲,这种方法还存在其余问题,例如人们将其视为放置不属于其余任何地方的东西的袋子,而且这个问题会无限期的增加。处理总体的不断增加的复杂性的一种更好的方法是将其分解为不一样的自治部分,例如经过 REST,RPC 或消息系统进行通讯。这就要求划清界限,每一个上下文最终均可能拥有本身的基础架构(数据存储,服务器,消息中间件等),甚至是本身的团队。架构
正如你想象的那样,这可能致使某种程度的重复,但这是咱们为下降复杂性而愿意作出的权衡。在领域驱动设计中,咱们将这些独立的部分称为限界上下文。app
当两个限界上下文之间存在单向集成时,其中一个充当提供者(上游),另外一个充当客户(下游),咱们最终获得一个客户 - 供应商开发团队。框架
在两个团队之间创建清晰的客户/供应商关系。在计划会议中,让下游团队扮演上游团队的客户角色。协商和预算下游需求的任务,以便每一个人都了解承诺和进度。共同开发自动验收测试,以验证预期的接口。将这些测试添加到上游团队的测试套件中,以做为其持续集成的一部分运行。该测试将使上游团队有能力进行更改,而没必要担忧下游的反作用。Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》
客户 - 供应商开发团队是集成限界上下文最多见的方式,而且当团队紧密工做时一般呈现一个共赢的局面。
继续电子商务的例子,考虑将收入报告给旧的传统零售商财务系统。集成可能会很是昂贵,从而致使不值得进行努力。在领域驱动设计战略术语中,这称为分离方式。
集成问题昂贵的。有时候收益很小。所以,声明一个限界上下文根本不与其余任何上下文关联,从而使开发人员能够在这个很小的范围内找到简单,专业的解决方案。Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》
再次考虑电子商务例子以及与第三方运输服务的集成。这两个领域在模型,团队和基础架构上都不一样。负责维护第三方运输服务的团队将不会参与你的产品计划或为电子商务系统提供任何解决方案。这些团队没有亲密关系。咱们能够选择接受并遵循他们的领域模型。在战略设计中,这就是所谓的追随集成(Conformist Integration)。
经过严格遵照上游团队的模型,消除上绑定上下文之间翻译的复杂性。尽管这限制了下游设计人员的风格,而且应用程序提供理想的模型,可是选择 CONFORMITY 能够极大地简化集成。另外,你将与供应商团队共享通用语言。供应商位于驾驶员座位上,所以使他们之间的交流变得容易。利他主义可能足以使他们与你共享信息。Eric Evans - 《领域驱动设计:软件核心复杂性应对之道》
为了使事情更简单,咱们假设限界上下文存在客户 - 供应商关系。
对于现代 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 之类的工具按期监视和重启进程。
尽管咱们只看到它的一小部分,战略设计是领域驱动设计的灵魂和核心。它的精髓部分有助于开发更好和更多的语义化模型。咱们推荐使用消息中间件集成限界上下文,由于它天然地产出简单,解耦,和事件驱动的架构。