对于构建复杂应用,一个关键点就是得有一个适合应用需求的架构设计。领域驱动设计的一个优点就是没必要绑定到任何特定的架构风格之上。相反的,咱们能够根据每一个核心域内的限界上下文自由选择最佳的架构,限界上下文同时为每一个特定领域问题提供了丰富多彩的架构选择。php
例如,一个订单系统可使用事件源(Event Sourcing)来追踪全部不一样订单的操做;一个产品目录服务可使用 CQRS 来暴露产品细节给不一样客户端;一个内容管理系统可使用通常的六边形架构来暴露如博客(blogs),静态页等服务。html
从传统守旧派的 PHP 代码到更复杂先进的架构,本章将跟随这些历史来对 PHP 圈子内每一个相关的架构风格作一些介绍。请注意尽管已经有许多其它存在的架构风格,例如数据网络架构(Data Fabric)或者面向服务架构(SOA),但咱们发现从 PHP 的视角介绍它们仍是有一些复杂的。前端
在 PHP4 发布以前 ,PHP 尚未拥抱面向对象模式。那时候,写应用的广泛方法就是用面向过程和全局状态。像关注点分离(SoC)和模型-视图-控制器(MVC)的概念是与当时的 PHP 社区相抵触的。mysql
下面的例子就是用传统方式写的一个由许多混合了 HTML 代码前端控制器构成的应用。在那个时代,基础设施层,表现层,UI,及领域层代码都交织在一块儿:web
<?php include __DIR__ . '/bootstrap.php'; $link = mysql_connect('localhost', 'a_username', '4_p4ssw0rd'); if (!$link) { die('Could not connect: ' . mysql_error()); } mysql_set_charset('utf8', $link); mysql_select_db('my_database', $link); $errormsg = null; if (isset($_POST['submit'] && isValid($_POST['post'])) { $post = getFrom($_POST['post']); mysql_query('START TRANSACTION', $link); $sql = sprintf( "INSERT INTO posts (title, content) VALUES ('%s','%s')", mysql_real_escape_string($post['title']), mysql_real_escape_string($post['content'] )); $result = mysql_query($sql, $link); if ($result) { mysql_query('COMMIT', $link); } else { mysql_query('ROLLBACK', $link); $errormsg = 'Post could not be created! :('; } } $result = mysql_query('SELECT id, title, content FROM posts', $link); ?> <html> <head></head> <body> <?php if (null !== $errormsg) : ?> <div class="alert error"><?php echo $errormsg; ?></div> <?php else: ?> <div class="alert success"> Bravo! Post was created successfully! </div> <?php endif; ?> <table> <thead> <tr> <th>ID</th> <th>TITLE</th> <th>ACTIONS</th> </tr> </thead> <tbody> <?php while ($post = mysql_fetch_assoc($result)) : ?> <tr> <td><?php echo $post['id']; ?></td> <td><?php echo $post['title']; ?></td> <td><?php editPostUrl($post['id']); ?></td> </tr> <?php endwhile; ?> </tbody> </table> </body> </html> <?php mysql_close($link); ?>
这种风格的代码就是咱们常说的大泥球,在第一章咱们也说起过。下面的代码就作一些改进,然而仅仅是经过封装 header 和 footer 到单独的文件内,就能够避免重复及有利于重用:redis
<?php include __DIR__ . '/bootstrap.php'; $link = mysql_connect('localhost', 'a_username', '4_p4ssw0rd'); if (!$link) { die('Could not connect: ' . mysql_error()); } mysql_set_charset('utf8', $link); mysql_select_db('my_database', $link); $errormsg = null; if (isset($_POST['submit'] && isValid($_POST['post'])) { $post = getFrom($_POST['post']); mysql_query('START TRANSACTION', $link); $sql = sprintf( "INSERT INTO posts(title, content) VALUES('%s','%s')", mysql_real_escape_string($post['title']), mysql_real_escape_string($post['content']) ); $result = mysql_query($sql, $link); if ($result) { mysql_query('COMMIT', $link); } else { mysql_query('ROLLBACK', $link); $errormsg = 'Post could not be created! :('; } } $result = mysql_query('SELECT id, title, content FROM posts', $link); ?> <?php include __DIR__ . '/header.php'; ?> <?php if (null !== $errormsg) : ?> <div class="alert error"><?php echo $errormsg; ?></div> <?php else: ?> <div class="alert success"> Bravo! Post was created successfully! </div> <?php endif; ?> <table> <thead> <tr> <th>ID</th> <th>TITLE</th> <th>ACTIONS</th> </tr> </thead> <tbody> <?php while ($post = mysql_fetch_assoc($result)): ?> <tr> <td><?php echo $post['id']; ?></td> <td><?php echo $post['title']; ?></td> <td><?php editPostUrl($post['id']); ?></td> </tr> <?php endwhile; ?> </tbody> </table> <?php include __DIR__ . '/footer.php'; ?>
现今,尽管这种方式使人沮丧,但仍有大量应用使用这种方式编写代码。这种风格的架构主要坏处是没有作到真正的关注点分离 - 维护和开发这样一个应用的持续成本与其它已知和已验证的架构相比急剧增加。sql
从代码的可维护性和可重用性角度来看,使代码更容易维护的最好方式就是拆分的思想,即为每一个不一样的关注点分层。在咱们以前的例子中,很是容易造成不一样层次:一个是封装数据访问和操做,另外一个是处理基础设施的关注点,最后一个便是封装前二者的编排。分层架构的一个基本原则就是-每一层都必须与其下一层紧密相连,以下图所示:数据库
分层架构真正寻求的是对应用的不一样组件进行分离。例如,在前面的例子当中,一个博客帖子的表示必须彻底地独立于实体概念的博客帖子。一个博客帖子实体能够与一个或多个表示相关联。这就是一般所说的关注点分离。json
另外一种寻求相同目的的架构模式就是模型-视图-控制器模式。它最初被认为和普遍用于建立桌面 GUI 应用。如今主要应用于 web 应用。这得益于像 Symfony
, Zend Framework
和 CodeIgniter
这些的流行框架。bootstrap
模型-视图-控制器模式将应用划分为三个主要层次,要点描述以下:
继续以前的例子,咱们注意到不一样的关注点须要被分离。为了达到这一点,全部层次都必须从咱们这些原始的混乱代码中识别出来。在这个过程当中,咱们须要特别注意与模型层有关的代码,即应用的核心代码:
class Post { private $title; private $content; public static function writeNewFrom($title, $content) { return new static($title, $content); } private function __construct($title, $content) { $this->setTitle($title); $this->setContent($content); } private function setTitle($title) { if (empty($title)) { throw new RuntimeException('Title cannot be empty'); } $this->title = $title; } private function setContent($content) { if (empty($content)) { throw new RuntimeException('Content cannot be empty'); } $this->content = $content; } } class PostRepository { private $db; public function __construct() { $this->db = new PDO( 'mysql:host=localhost;dbname=my_database', 'a_username', '4_p4ssw0rd', [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', ] ); } public function add(Post $post) { $this->db->beginTransaction(); try { $stm = $this->db->prepare( 'INSERT INTO posts (title, content) VALUES (?, ?)' ); $stm->execute([ $post->title(), $post->content(), ]); $this->db->commit(); } catch (Exception $e) { $this->db->rollback(); throw new UnableToCreatePostException($e); } } }
模型层如今用一个 Post
类和一个 PostRepository
类定义。Post
类表示一个博客帖子,PostRepository
类表示可用博客帖子的整个集合。除此以外,另外一层 - 用来协调和编排这些领域行为 - 也是模型层内须要的。如今进入应用层:
class PostService { public function createPost($title, $content) { $post = Post::writeNewFrom($title, $content); (new PostRepository())->add($post); return $post; } }
PostService
类即咱们所说的应用服务,它的目的是编排和组织领域行为。换句话说,应用服务是领域模型的直接客户端,是那些使业务发生的服务。没有其余类型的对象能够直接与模型层内部直接对话。
视图层能够从模型层和/或者控制层接收数据,也能向其发送数据。它的主要目的是向用户UI层呈现模型,同时在模型每次更新后刷新UI的呈现形式。通常来讲,视图层接收的对象 - 一般是一个数据传输对象(DTO
)而不是模型层实例 - 从而收集被成功呈现的全部必需信息。对于 PHP,这已经有几种模板引擎能够帮助从模型自己和从控制层分离模型的表示。其中最流行的一个叫 Twig
。让咱们看看使用 Gwig
的视图层是怎样的。
为何是数据传输对象(DTO
)而不是模型实例?这是一个古老且有活力的话题。为何要建立一个 DTO 而不是把模型实例直接交给视图层?简短来讲,仍是关注点分离。让视图层方便直接使用模型实例将致使视图层与模型层间的紧耦合。事实上,模型层中的一个改变将可能破坏全部使用改变后的模型的全部视图。
{% extends "base.html.twig" %} {% block content %} {% if errormsg is defined %} <div class="alert error">{{ errormsg }}</div> {% else %} <div class="alert success"> Bravo! Post was created successfully! </div> {% endif %} <table> <thead> <tr> <th>ID</th> <th>TITLE</th> <th>ACTIONS</th> </tr> </thead> <tbody> {% for post in posts %} <tr> <td>{{ post.id }}</td> <td>{{ post.title }}</td> <td><a href="{{ editPostUrl(post.id) }}">Edit Post</a></td> </tr> {% endfor %} </tbody> </table> {% endblock %}
大多数时候,当模型触发一个状态改变,同时也会通知相关视图 UI 已经刷新了。在一个典型的 web 场景中,因为客户端-服务器这一约束,模型和它的表示之间的同步可能会有一点棘手。在这些状况下,一般要用一些 JavaScript 定义的交互方式来维护这些同步。因为这个缘由,近年来 JavaScript MVC 框架开始变得普遍流行,正以下面这些框架:
控制层主要负责组织和编排视图和模型。它接收来自视图层的消息和为了执行指望的动做而触发模型行为。此外,为了呈现模型的表示,它也发送消息给视图。被执行的动做也须要感谢应用层,即负责编排,组织和封装领域行为的这一层。
就一个 PHP 的 web 应用来讲,控制层包括一组类,为了达到它们的目的,叫作 "HTTP" 。换句话说,它们接收一个 HTTP 请求,同时返回一个 HTTP 响应:
class PostsController { public function updateAction(Request $request) { if ( $request->request->has('submit') && Validator::validate($request->request->post) ) { $postService = new PostService(); try { $postService->createPost( $request->request->get('title'), $request->request->get('content') ); $this->addFlash( 'notice', 'Post has been created successfully!' ); } catch (Exception $e) { $this->addFlash( 'error', 'Unable to create the post!' ); } } return $this->render('posts/update-result.html.twig'); } }
依照分层架构的基本思想,当实现包含有关基础设施层的领域接口时,是存在风险的。
以 MVC 为例,先前例子中的 PostRepository
类应该放在领域模型当中。然而,把基础设施细节放在领域之中是违背关注点分离这一原则的.这是有问题的;它很难避免违背分层架构的基本思想,若是模型层有技术实现,这将会致使一种很难测试的代码类型出现。
咱们能够怎样改进呢?因为领域模型层依赖基础设施的具体实现,依赖倒置原则(DIP),能够经过应将基础设施层从新放在其它三层之上来应用。
依赖倒置原则高层次模型不该该依赖于低层次模型。它们都应该依赖于抽象。
抽象不该该依赖于细节,细节应该依赖于抽象。
-- Robert C.Martin
经过使用依赖倒置原则,架构模式改变了,基础设施层 - 能够称为低层次模块 - 如今依赖于 UI,应用层和模型层这些高层次模块。因而依赖被倒置了。
但什么是六边形架构呢?它是怎样适合这里面的全部问题呢?六边形架构(即端口与适配器)是 Alistair Cockburn 在他的书《六边形架构》中定义的。它将应用描述成一个六边形,每条边被表示为一个端口和多个适配器。端口是一个可插拔适配器的链接器件,适配器将外部输入转换为应用内部可理解的数据。就依赖倒置(DIP
)来讲,端口是高层次模块,适配器是低层次模块。此外,若是应用须要发送消息给外部,它能够用一个带适配器的端口来发送和转换能够被外部可理解的数据。正由于如此,六边形架构提出了应用里对称性的概念,这也是为何架构模式发生变化的主要缘由。它常常被表示为六边形,由于讨论顶层或者底层再也不有任何意义。相反,六边形架构主要是外与内部间的对话。
若是你想要了解更多细节,Youtube 上有 Matthias Noback 关于六边形架构的很是好的视频
咱们继续博客应用的例子,首先咱们须要的概念就是端口,即外部世界与应用程序对话的渠道。在这个例子中,咱们使用一个 HTTP 端口及相应的适配器,外部经过端口发送消息给应用程序。博客例子使用数据库存储整个博客帖子集合,因此为了让应用程序从数据库中检索博客帖子数据,端口就是必须的:
interface PostRepository { public function byId(PostId $id); public function add(Post $post); }
该接口暴露有关博客帖子的端口,应用程序经过它检索信息。它也被放置在领域层。如今,则须要这个端口的适配器。该适配器负责定义用特定技术检索博客帖子的方法:
class PDOPostRepository implements PostRepository { private $db; public function __construct(PDO $db) { $this->db = $db; } public function byId(PostId $id) { $stm = $this->db->prepare( 'SELECT * FROM posts WHERE id = ?' ); $stm->execute([$id->id()]); return recreateFrom($stm->fetch()); } public function add(Post $post) { $stm = $this->db->prepare( 'INSERT INTO posts (title, content) VALUES (?, ?)' ); $stm->execute([ $post->title(), $post->content(), ]); } }
只要咱们定义了端口及其适配器,最后就是重构 PostService
从而能够它们。这能够经过依赖注入(Dependency Injection)轻松实现:
class PostService { private $postRepository; public function __construct(PostRepositor $postRepository) { $this->postRepository = $postRepository; } public function createPost($title, $content) { $post = Post::writeNewFrom($title, $content); $this->postRepository->add($post); return $post; } }
这仅仅是六边形架构的一个简单例子,它是一个灵活的,相似分层,有利于关注点分离的架构。因为内部应用经过端口与外部通讯,这也同时提高了对称性。从如今开始,这将做为基本架构来构建和解释 CQRS 及事件源模式。
想了解更多关于这种架构的例子,你能够去查看附录中的 《Hexagonal Architecture with PHP》。对于一个更详细的例子,你能够跳到第 11 章 - 应用程序,此章介绍了一些高级主题,像事务性和其它交叉问题。
六边形架构是一个很好的基础性架构,但它有一些限制。例如,复杂 UI 须要在不一样的表单上显示聚合信息(第八章,聚合),或者它们能够从多个聚合获取数据。在这种场景下,咱们能够在仓储里使用许多查找方法(可能和应用程序里存在的 UI 视图同样多)。或者,也许咱们能够直接将这种复杂性转移到应用服务,使用复杂结构来从多个聚合里积累数据,这里有一个例子:
interface PostRepository { public function save(Post $post); public function byId(PostId $id); public function all(); public function byCategory(CategoryId $categoryId); public function byTag(TagId $tagId); public function withComments(PostId $id); public function groupedByMonth(); // ... }
当这些技术被滥用时,对 UI 视图层的构建将变得很是痛苦。咱们应该权衡是该用应用服务返回领域实例仍是某些 DTO 。后一种选择里,咱们避免了领域模型与基础设施代码( web 控制器,CLI 控制器等等)间的紧耦合。
幸运的是,咱们有另外一种方法。若是需求有许多且独立的视图,咱们能够将它们从领域模型中排除,把它们视为一种纯粹的基础设施问题。这种方法即基于一个设计原则,命令查询分离(CQS
)。这个原则由 Bertrand Meyer 提出,而后,相应地,成长为一个全新的架构模式,叫做命令查询职责分离(CQRS
),CQRS
由 Greg Young 定义。
命令查询分离提出一个问题不该该改变对应的答案 - Bertrand Meyer
这种设计原则提出每一个方法应该要么是执行动做的命令,要么是返回数据给调用者的查询,而不是二者都是 - 维基百科
CQRS
谋求一种更为激进的关注点分离,即将模型分为两部分:
每次只要触发一个命令给写模型,它就会执行渴求数据的存储写入。除此以外,它还会触发读模型的更新,保证在读模型上显示最后一次的更改。
这种严格的分离致使了另外一个问题,最终一致性。读模型的一致性如今受写模型执行的命令的影响。换句话说,读模型是最终一致性的。也就是说,每次当写模型执行一个命令,它就会负责挂起一个进程,依照写模型上最后一次更改,来更新读模型。因此这里存在一个时间窗口,UI可能会向用户展现旧的信息。在 web 场景中,这种状况常常发生,由于咱们受当前技术因素限制。
考虑一个 web 应用的缓存系统,每次用新信息数更新数据库时,缓存层的数据有多是陈旧的,因此每当模型有更新时,也应该同时更新缓存系统。因此 缓存系统是最终一致性的。
这些处理过程,在 CQRS 术语中被称为写模型投影,或者就称做投影。即投影一个写模型到读模型上。这个过程能够是同步或者异步,取决于你的须要,同时它能够用另外一种颇有用的战术设计模式 - 领域事件(本书后面的章节会讲到)来实现。写模型投影的基本过程就是收集全部发布的领域事件,而后用事件中的信息来更新读模型。
写模型是领域行为的真实持有者,继续咱们的例子,仓储接口将被简化以下:
interface PostRepository { public function save(Post $post); public function byId(PostId $id); }
如今 PostRepository
已经从全部读关注点中分离出来,除了一个:byId
方法,负责经过 ID 来加载聚合以便咱们对其进行操做。那么只要这一步完成,全部的查询方法都将从 Post
模型中剥离出来,只留下命令方法。这意味着咱们能够有效地摆脱全部getter方法和任何其它暴露 Post
聚合信息的方法。取而代之的是,经过订阅聚合模型来发布领域事件,以触发写模型投影:
class AggregateRoot { private $recordedEvents = []; protected function recordApplyAndPublishThat( DomainEvent $domainEvent ) { $this->recordThat($domainEvent); $this->applyThat($domainEvent); $this->publishThat($domainEvent); } protected function recordThat(DomainEvent $domainEvent) { $this->recordedEvents[] = $domainEvent; } protected function applyThat(DomainEvent $domainEvent) { $modifier = 'apply' . get_class($domainEvent); $this->$modifier($domainEvent); } protected function publishThat(DomainEvent $domainEvent) { DomainEventPublisher::getInstance()->publish($domainEvent); } public function recordedEvents() { return $this->recordedEvents; } public function clearEvents() { $this->recordedEvents = []; } } class Post extends AggregateRoot { private $id; private $title; private $content; private $published = false; private $categories; private function __construct(PostId $id) { $this->id = $id; $this->categories = new Collection(); } public static function writeNewFrom($title, $content) { $postId = PostId::create(); $post = new static($postId); $post->recordApplyAndPublishThat( new PostWasCreated($postId, $title, $content) ); } public function publish() { $this->recordApplyAndPublishThat( new PostWasPublished($this->id) ); } public function categorizeIn(CategoryId $categoryId) { $this->recordApplyAndPublishThat( new PostWasCategorized($this->id, $categoryId) ); } public function changeContentFor($newContent) { $this->recordApplyAndPublishThat( new PostContentWasChanged($this->id, $newContent) ); } public function changeTitleFor($newTitle) { $this->recordApplyAndPublishThat( new PostTitleWasChanged($this->id, $newTitle) ); } }
全部触发状态改变的动做都经过领域事件来实现。对于每个已发布的领域事件,都有一个对应的 apply 方法负责状态的改变:
class Post extends AggregateRoot { // ... protected function applyPostWasCreated( PostWasCreated $event ) { $this->id = $event->id(); $this->title = $event->title(); $this->content = $event->content(); } protected function applyPostWasPublished( PostWasPublished $event ) { $this->published = true; } protected function applyPostWasCategorized( PostWasCategorized $event ) { $this->categories->add($event->categoryId()); } protected function applyPostContentWasChanged( PostContentWasChanged $event ) { $this->content = $event->content(); } protected function applyPostTitleWasChanged( PostTitleWasChanged $event ) { $this->title = $event->title(); } }
读模型,同时也称为查询模型,是一个纯粹的从领域中提取的非规范化的数据模型。事实上,使用 CQRS,全部的读取侧都被视为基础设施关注的表述过程。通常来讲,当使用 CQRS 时,读模型与 UI 所需有关,与组合视图的 UI 复杂性有关。在一个关系型数据库中定义读模型的状况下,最简单的方法就是创建数据表与 UI 视图一对一的关系。这些数据表和 UI 视图将用写模型投影更新,由写一侧发布的领域事件来触发:
-- Definition of a UI view of a single post with its comments CREATE TABLE single_post_with_comments ( id INTEGER NOT NULL, post_id INTEGER NOT NULL, post_title VARCHAR(100) NOT NULL, post_content TEXT NOT NULL, post_created_at DATETIME NOT NULL, comment_content TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Set up some data INSERT INTO single_post_with_comments VALUES (1, 1, "Layered" , "Some content", NOW(), "A comment"), (2, 1, "Layered" , "Some content", NOW(), "The comment"), (3, 2, "Hexagonal" , "Some content", NOW(), "No comment"), (4, 2, "Hexagonal", "Some content", NOW(), "All comments"), (5, 3, "CQRS", "Some content", NOW(), "This comment"), (6, 3, "CQRS", "Some content", NOW(), "That comment"); -- Query it SELECT * FROM single_post_with_comments WHERE post_id = 1;
这种架构风格的一个重要特征就是,读模型应该彻底是一次性的,由于应用的真实状态是由写模型来处理。这意味着读模型在须要时,能够用写模型投影来移除和重建。
这里咱们能够看到一个博客应用里的一些可能存在的视图的例子:
SELECT * FROM posts_grouped_by_month_and_year ORDER BY month DESC,year ASC; SELECT * FROM posts_by_tags WHERE tag = "ddd"; SELECT * FROM posts_by_author WHERE author_id = 1;
须要特别指出的是,CQRS 并不约束读模型的定义和实现要用关系型数据库,它取决于被构建的应用实际所需。它能够是关系型数据库,面向文档的数据库,键-值型存储,或任意适合应用所需的存储引擎。在博客帖子应用里,咱们使用 Elasticsearch - 一个面向文档的数据库 - 来实现一个读模型:
class PostsController { public function listAction() { $client = new ElasticsearchClientBuilder::create()->build(); $response = $client->search([ 'index' => 'blog-engine', 'type' => 'posts', 'body' => [ 'sort' => [ 'created_at' => ['order' => 'desc'] ] ] ]); return [ 'posts' => $response ]; } }
读模型被完全地简化为针对 Elasticsearch 的单个查询索引。
这代表读模型并不真正须要一个对象关系映射器,由于这是多余的。然而,写模型可能会得益于对象关系映射的使用,由于这容许你根据应用程序所须要来组织和构建读模型。
接下来即是棘手的部分。如何用写模型同步读模型?咱们以前已经说过,经过使用写模型事务中捕获的领域事件来完成它。对于捕获的每种类型的领域事件,将执行一个特定的投影。所以,将设置领域事件和投影间的一个一对一的关系。
让咱们看看配置投影的一个例子,以便咱们获得一个更好的方法。首先,咱们须要定义一个投影接口:
interface Projection { public function listensTo(); public function project($event); }
因此为 PostWasCreated 事件定义一个 Elasticsearch 投影以下述通常简单:
namespace Infrastructure\Projection\Elasticsearch; use Elasticsearch\Client; use PostWasCreated; class PostWasCreatedProjection implements Projection { private $client; public function __construct(Client $client) { $this->client = $client; } public function listensTo() { return PostWasCreated::class; } public function project($event) { $this->client->index([ 'index' => 'posts', 'type' => 'post', 'id' => $event->getPostId(), 'body' => [ 'content' => $event->getPostContent(), // ... ] ]); } }
Projector 的实现就是一种特殊的领域事件监听器。它与默认的领域事件监听器的主要区别在于 Projector 触发了一组领域事件而不是仅仅一个:
namespace Infrastructure\Projection; class Projector { private $projections = []; public function register(array $projections) { foreach ($projections as $projection) { $this->projections[$projection->eventType()] = $projection; } } public function project(array $events) { foreach ($events as $event) { if (isset($this->projections[get_class($event)])) { $this->projections[get_class($event)] ->project($event); } } } }
下面的代码展现了 projector 和事件间的流向:
$client = new ElasticsearchClientBuilder::create()->build(); $projector = new Projector(); $projector->register([ new Infrastructure\Projection\Elasticsearch\ PostWasCreatedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostWasPublishedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostWasCategorizedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostContentWasChangedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostTitleWasChangedProjection($client), ]); $events = [ new PostWasCreated(/* ... */), new PostWasPublished(/* ... */), new PostWasCategorized(/* ... */), new PostContentWasChanged(/* ... */), new PostTitleWasChanged(/* ... */), ]; $projector->project($event);
这里的代码是一种同步技术,但若是须要的话也能够是异步的。你也经过在视图层放置一些警告通知来让客户知道这些不一样步的数据。
对于接下来的例子,咱们将结合使用 amqplib PHP 扩展和 ReactPHP:
// Connect to an AMQP broker $cnn = new AMQPConnection(); $cnn->connect(); // Create a channel $ch = new AMQPChannel($cnn); // Declare a new exchange $ex = new AMQPExchange($ch); $ex->setName('events'); $ex->declare(); // Create an event loop $loop = ReactEventLoopFactory::create(); // Create a producer that will send any waiting messages every half a second $producer = new Gos\Component\React\AMQPProducer($ex, $loop, 0.5); $serializer = JMS\Serializer\SerializerBuilder::create()->build(); $projector = new AsyncProjector($producer, $serializer); $events = [ new PostWasCreated(/* ... */), new PostWasPublished(/* ... */), new PostWasCategorized(/* ... */), new PostContentWasChanged(/* ... */), new PostTitleWasChanged(/* ... */), ]; $projector->project($event);
为了能让它工做,咱们须要一个异步的 projector
。这有一个原生的实现以下:
namespace Infrastructure\Projection; use Gos\Component\React\AMQPProducer; use JMS\Serializer\Serializer; class AsyncProjector { private $producer; private $serializer; public function __construct( Producer $producer, Serializer $serializer ) { $this->producer = $producer; $this->serializer = $serializer; } public function project(array $events) { foreach ($events as $event) { $this->producer->publish( $this->serializer->serialize( $event, 'json' ) ); } } }
在 RabbitMQ 交换机上的事件消费者以下:
// Connect to an AMQP broker $cnn = new AMQPConnection(); $cnn->connect(); // Create a channel $ch = new AMQPChannel($cnn); // Create a new queue $queue = new AMQPQueue($ch); $queue->setName('events'); $queue->declare(); // Create an event loop $loop = React\EventLoop\Factory::create(); $serializer = JMS\Serializer\SerializerBuilder::create()->build(); $client = new Elasticsearch\ClientBuilder::create()->build(); $projector = new Projector(); $projector->register([ new Infrastructure\Projection\Elasticsearch\ PostWasCreatedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostWasPublishedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostWasCategorizedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostContentWasChangedProjection($client), new Infrastructure\Projection\Elasticsearch\ PostTitleWasChangedProjection($client), ]); // Create a consumer $consumer = new Gos\Component\ReactAMQP\Consumer($queue, $loop, 0.5, 10); // Check for messages every half a second and consume up to 10 at a time. $consumer->on( 'consume', function ($envelope, $queue) use ($projector, $serializer) { $event = $serializer->unserialize($envelope->getBody(), 'json'); $projector->project($event); } ); $loop->run();
从如今开始,只需让全部所需的仓储使用 projector
实例,而后让它们调用投影过程就能够了:
class DoctrinePostRepository implements PostRepository { private $em; private $projector; public function __construct(EntityManager $em, Projector $projector) { $this->em = $em; $this->projector = $projector; } public function save(Post $post) { $this->em->transactional( function (EntityManager $em) use ($post) { $em->persist($post); foreach ($post->recordedEvents() as $event) { $em->persist($event); } } ); $this->projector->project($post->recordedEvents()); } public function byId(PostId $id) { return $this->em->find($id); } }
Post
实例和记录事件在同一个事务中触发和持久化。这就确保没有事件丢失,只要事务成功了,咱们就会把它们投影到读模型中。所以,在写模型和读模型之间不存在不一致的状况。
用 ORM 仍是不用 ORM一个很是广泛的问题就是当实现 CQRS 时,是否真正须要一个对象关系映射(ORM)。咱们真的认为,写模型使用 ORM 是极好的,同时有使用工具的全部优势,这将帮助咱们节省大量的工做,只要咱们使用了关系型数据库。但咱们不该该忘了咱们仍然须要在关系型数据库中持久化和检索写模型状态。
CQRS 是一个很是强大和灵活的架构。在收集和保存领域事件(在聚合操做期间发生)这方面,它有一个额外的好处,就是给你领域中发生的事件一个高度的细节。由于领域事件描述了过去发生的事情,它对于领域的意义,使它成为战术模式的一个关键点。
当心记录太多事件愈来愈多的事件是一种坏味道。在领域中记录事件也许是一种成瘾,这也最有可能被企业激励。做为一条经验法则,记住要保持简单。
经过使用 CQRS,咱们能够在领域层记录全部发生的相关性事件。领域的状态能够经过重现以前记录的领域事件来呈现。咱们只须要一个工具,用一致的方法来存储全部这些事件。因此咱们须要储存事件。
事件源背后的基本原理是用一个线性的事件集来表现聚合的状态。
用 CQRS,咱们基本上能够实现以下:Post
实体用领域事件输出他的状态,但它的持久化,能够将对象映射至数据表。
事件源则更进一步。按照以前的作法,若是咱们使用数据表存储全部博客帖子的状态,那么另一个表存储全部博客帖子评论的状态,依次类推。而使用事件源咱们则只须要一张表:一个数据库中附加的单独的一张表,来存储全部领域模型中的全部聚合发布的全部的领域事件。是的,你得看清了,是单独的一张表。
按照这种模型思路,像对象关系映射的工具就再也不须要了。惟一须要的工具就是一个简单的数据抽象层,经过它来附加事件:
interface EventSourcedAggregateRoot { public static function reconstitute(EventStream $events); } class Post extends AggregateRoot implements EventSourcedAggregateRoot { public static function reconstitute(EventStream $history) { $post = new static($history->getAggregateId()); foreach ($events as $event) { $post->applyThat($event); } return $post; } }
如今 Post
聚合有一个方法,当给定一组事件集(或者说事件流)时,能够一步步重现状态直到当前状态,这些都在保存以前。下一步将构建一个 PostRepository
适配器端口从 Post
聚合中获取全部已发布的事件,并将它们添加到数据存储区,全部的事件都存储在这里。这就是咱们所说的事件存储:
class EventStorePostRepository implements PostRepository { private $eventStore; private $projector; public function __construct($eventStore, $projector) { $this->eventStore = $eventStore; $this->projector = $projector; } public function save(Post $post) { $events = $post->recordedEvents(); $this->eventStore->append(new EventStream( $post->id(), $events) ); $post->clearEvents(); $this->projector->project($events); } }
这就是为何 PostRepository
的实现看起来像咱们使用一个事件存储来保存全部 Post
聚合发布的事件。如今咱们须要一个方法,经过历史事件来从新存储一个聚合。Post
聚合实现的 reconsititute
方法,它经过事件触发来重建博客帖子状态,此刻派上用场:
class EventStorePostRepository implements PostRepository { public function byId(PostId $id) { return Post::reconstitute( $this->eventStore->getEventsFor($id) ); } }
事件存储就像是负责关于保存和存储事件流的驮马。它的公共 API 由两个简单方法组成:它们是 append
和 getEventsFrom
. 前者追加一个事件流到事件存储,后者加载全部事件流来重建聚合。
咱们能够经过一个键-值实现来存储全部事件:
class EventStore { private $redis; private $serializer; public function __construct($redis, $serializer) { $this->redis = $redis; $this->serializer = $serializer; } public function append(EventStream $eventstream) { foreach ($eventstream as $event) { $data = $this->serializer->serialize( $event, 'json' ); $date = (new DateTimeImmutable())->format('YmdHis'); $this->redis->rpush( 'events:' . $event->getAggregateId(), $this->serializer->serialize([ 'type' => get_class($event), 'created_on' => $date, 'data' => $data ], 'json') ); } } public function getEventsFor($id) { $serializedEvents = $this->redis->lrange('events:' . $id, 0, -1); $eventStream = []; foreach ($serializedEvents as $serializedEvent) { $eventData = $this->serializerdeserialize( $serializedEvent, 'array', 'json' ); $eventStream[] = $this->serializer->deserialize( $eventData['data'], $eventData['type'], 'json' ); } return new EventStream($id, $eventStream); } }
这里的事件存储的实现是基于 Redis,一个普遍使用的键-值存储器。追加在列表里的事件使用一个 event 前缀:除此以外,在持久化这些事件以前,咱们提取一些像类名或者建立时间之类的元数据,这些在以后会派上用场。
显然,就性能而言,聚合老是经过重现它的历史事件来达到最终状态是很是奢侈的。尤为是当事件流有成百上千个事件。克服这种局面最好的办法就是从聚合中拍摄一个快照,只重现快照拍摄后发生的事件。快照就是聚合状态在给定时刻的一个简单的序列化版本。它能够基于聚合的事件流的事件序号,或者基于时间。第一种方法,每 N 次事件触发时就要拍摄一次快照(例如每20,50,或者200次)。第二种方法,每 N 秒就要拍摄一次。
在下面的例子中,咱们使用第一种方法。在事件的元数据中,咱们添加一个附加字段,版本(version),即从咱们开始重现聚合历史状态之处:
class SnapshotRepository { public function byId($id) { $key = 'snapshots:' . $id; $metadata = $this->serializer->unserialize( $this->redis->get($key) ); if (null === $metadata) { return; } return new Snapshot( $metadata['version'], $this->serializer->unserialize( $metadata['snapshot']['data'], $metadata['snapshot']['type'], 'json' ) ); } public function save($id, Snapshot $snapshot) { $key = 'snapshots:' . $id; $aggregate = $snapshot->aggregate(); $snapshot = [ 'version' => $snapshot->version(), 'snapshot' => [ 'type' => get_class($aggregate), 'data' => $this->serializer->serialize( $aggregate, 'json' ) ] ]; $this->redis->set($key, $snapshot); } }
如今咱们须要重构 EventStore
类,来让它使用 SnapshotRepository
在可接受的次数内加载聚合:
class EventStorePostRepository implements PostRepository { public function byId(PostId $id) { $snapshot = $this->snapshotRepository->byId($id); if (null === $snapshot) { return Post::reconstitute( $this->eventStore->getEventsFrom($id) ); } $post = $snapshot->aggregate(); $post->replay( $this->eventStore->fromVersion($id, $snapshot->version()) ); return $post; } }
咱们只须要按期拍摄聚合快照。咱们能够同步或者异步地经过监视事件存储进程来实现。下面的代码例子简单地演示了聚合快照的实现:
class EventStorePostRepository implements PostRepository { public function save(Post $post) { $id = $post->id(); $events = $post->recordedEvents(); $post->clearEvents(); $this->eventStore->append(new EventStream($id, $events)); $countOfEvents = $this->eventStore->countEventsFor($id); $version = $countOfEvents / 100; if (!$this->snapshotRepository->has($post->id(), $version)) { $this->snapshotRepository->save( $id, new Snapshot( $post, $version ) ); } $this->projector->project($events); } }
是否须要 ORM?
从这种架构风格的用例中明显可知,仅仅使用 ORM 来持久/读取 使用未免太过分了。就算咱们使用关系型数据库来存储它们,咱们也仅仅只是从事件存储中持久/读取事件而已。
在这一章,由于有大量可选的架构风格,你可能会感到一点困惑。为了作出明显的选择,你不得不在它们中考虑和权衡。不过一件事是明确的:大泥球是不可取的,由于代码很快就会变质。分层架构是一个更好的选择,但它也带来一些缺点,例如层与层之间的紧耦合。能够说,最合适的选择就是六边形架构,由于它能够做为一个基础的架构来使用,它能促进高层次的解耦而且带来内外应用间的对称性,这就是为何咱们在大多数场景下推荐使用它。
咱们还能够看到 CQRS 和事件源这些相对灵活的架构,能够帮助你应对严重的复杂性。CQRS 和事件源都有它们的场景,但不要让它的魅力因素分散你判断它们自己提供的价值。因为它们都存在一些开销,你应该有技术缘由来证实你必须得使用它。这些架构风格确实有用,在大量的 CQRS 仓储查找方法中,和事件源事件触发量上,你能够很快受到这些风格的启发。若是查找方法的数量开始增加,仓储层开始变得难以维护,那么是时候开始考虑使用 CQRS 来分离读写关注了。以后,若是每一个聚合操做的事件量趋向于增加,业务也对更细粒度的信息感兴趣,那么一个选项就该考虑,转向事件源是否可以得到回报。
摘自 Brian Foote 和 Joseph Yoder 的一篇论文:大泥球就是杂乱无章的,散乱泥泞的,牵连交织的意大利式面条代码。