對於構建複雜應用,一個關鍵點就是得有一個適合應用需求的架構設計。領域驅動設計的一個優點就是沒必要綁定到任何特定的架構風格之上。相反的,咱們能夠根據每一個核心域內的限界上下文自由選擇最佳的架構,限界上下文同時爲每一個特定領域問題提供了豐富多彩的架構選擇。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 的一篇論文:大泥球就是雜亂無章的,散亂泥濘的,牽連交織的意大利式麪條代碼。