《領域驅動設計之PHP實現》- 聚合

聚合

聚合多是領域驅動設計中最難的構建塊了。它們難以理解,而且難以正確設計。但不用擔憂,咱們會幫助你。不過,在進入聚合以前,咱們首先須要深刻了解一些概念:事務和併發策略。php

介紹

若是你使用過電子商務應用,則可能已經遇到過與數據庫中數據不一致有關的錯誤。例如,考慮一個總額爲 99.99 美圓的購物訂單,該訂單與訂單中每行總金額的總和 89.99 美圓不匹配。那這筆額外的 10 美圓來自哪裏?html

或者,考慮一個爲影院售賣電影票的網站。有一個劇院有 100 個可用座位,而且在電影成功上映以後,每一個人都在網站上等待購票。一旦你開售,一切都會快速進行,最終你會以某種方式賣出了 102 張門票。你可能已經指定只有 100 個座位,可是因爲某種緣由你超過了該閾值。web

你可能有使用像 JIRA 或者 Redmine 之類的追蹤系統的經驗。考慮一個開發,QA,以及一個產品經理的小組。若是每一個人都在計劃會議中,圍繞用戶故事分類和移動它們而後保存,這會發生什麼問題?最終的待辦項或者
衝突優先級可能會是團隊中最後保存它的人。sql

通常來講,當咱們用一種非原子方式處理持久化機制時,會發生數據不一致。一個例子就是,當你發送三個查詢請求到數據庫,它們中的一些正常一些不正常。數據庫的最終狀態就會不一致。有時,你但願這三個查詢請求所有成功或者失敗,那麼能夠用事務。不過要注意,正如你將在這章看到的,並非全部非一致性問題都用事務解決。事實上,有時一些數據不一致狀況須要鎖或者併發策略來解決。這些工具可能會影響你的應用性能,因此請注意權衡。數據庫

你可能認爲這些數據不一致狀況僅僅發生在數據庫,但實際不是這樣。例如,若是咱們使用一個面向文檔數據庫(諸如 Elasticsearch),兩個文檔間數據可能會不一致。此外,大多數 NoSQL 持久化存儲系統都不支持 ACID 事務。這意味着你不能在單個操做上持久化或更新多個文檔。所以,若是咱們對 Elasticsearch 做出不一樣請求,則可能會失敗,從而使保存在 Elasticsearch 中的數據不一致。編程

保證數據一致性是一個挑戰。避免將基礎設施問題泄漏到領域中是一個更大的挑戰。聚合能夠幫助你處理這些問題。json

關鍵概念

持久化引擎,特別是數據庫,具備一些解決數據不一致的功能:ACID,約束,引用完整性,鎖,併發控制和事務。在使用聚合以前,讓咱們回一下這些概念。設計模式

這些概念的大多數在互聯網上都是對公開開放的。咱們想感謝在 Oracle,PostgreSQL,以及 Doctrine 的人,用他們的文檔作出了使人驚歎的工做。他們細緻地定義和解釋了這些重要內容,而且不是重複造輪子,咱們整理了一些官方解釋以分享給你。數組

ACID (事務管理)

正如在上一節中討論的,ACID 表明原子性 (atomicity),一致性 (consistency),隔離性 (isolation),以及持久性 (durability)。根據 MySQL 詞彙表:瀏覽器

這些屬性都是數據庫系統所須要的,而且都與事務概念緊密相關。例如,MySQL InnoDB 引擎的事務功能遵循 ACID 原則。

事務是原子工做單元,它能夠被提交和回滾。當一個事務都數據庫作出多種改變,要麼當事務提交時全部改變都成功,要麼當事務回滾時全部改變都失敗。

在每一個提交或回滾以後,以及在事務的進程中,數據庫老是保持一個一致狀態。若是要跨越多個表更新了相關數據,那麼查詢將看到全部舊值或全部新值,而不是新舊值的混合。

事務進程時,受彼此隔離保護。他們互相之間不能干擾,也不能看到彼此未提交的數據。這種隔離是經過鎖定機制實現的。有經驗的用戶在肯定事務不會相互干擾時,能夠調整隔離級別,下降保護措施以提升性能和併發。

事務執行結果是持久的:一旦操做提交成功,不論斷電,系統崩潰,競爭條件,或者其它許多非數據庫應用程序容易受到的潛在危險,事務所做出的改變都是安全的。持久性一般涉及到寫入磁盤存儲,並具備必定數量的冗餘以防止寫做操做期間出現電源故障或軟件崩潰。

事務 (Transactions)

依據 PostgreSQL 8.2.23 文檔:

事務是全部數據庫系統的基礎概念。事務的精髓就是,它將多個步驟捆綁成單個「全有或全無」的操做。步驟之間的中間狀態對於其餘併發事務是不可見的,而且若是發生某些故障致使該事務沒法完成,則全部步驟都不會影響數據庫。

例如,考慮一個銀行數據庫,其包含各類客戶帳戶餘額以及分行的總存儲餘額。假設咱們記錄從 Alice 的帳戶到 Bob 的帳戶之間的 100 美圓轉帳。簡單來講,SQL 命令看起來就像這樣:

UPDATE accounts SET balance = balance - 100.00 WHERE name = 'Alice';

UPDATE branches SET balance = balance - 100.00 WHERE name = (SELECT branch_name FROM accounts WHERE name ='Alice');

UPDATE accounts SET balance = balance + 100.00 WHERE name = 'Bob';

UPDATE branches SET balance = balance + 100.00 WHERE name = (SELECT branch_name FROM accounts WHERE name ='Bob');

這些命令的詳細信息在這裏並不重要,重要的是,要完成這個簡單的操做,須要涉及到幾個獨立的更新。咱們銀行的管理人員但願確保全部的這些更新都發生了,或者什麼都沒發生。系統故障固然不會致使 Bob 收到不是從 Alice 那裏扣除的 100 美圓。若是在沒有 Bob 信用的狀況下借出,Alice 也永遠不會是一個滿意的客戶。咱們須要保證,若是在操做過程當中出現問題,到目前爲止執行的任何操做都不會生效。將更新編排到一個事務中爲咱們提供了保證。事務是原子的:從其它事務的角度來看,它要麼徹底發生,要麼根本不發生。

咱們也想確保,一旦事務完成而且由數據庫系統確認,該事務將肯定被永久記錄,而且以後不久系統發生崩潰也不會丟失。例如,咱們正在記錄 Bob 提取的現金,咱們不但願他帳戶的借方在他走出銀行大門後的崩潰中消失的任何可能性。事務數據庫保證在報告事務完成以前,其所作的全部更新都記錄在永久性的存儲中(即在磁盤上)。

事務型數據庫另外一個重要屬性與原子更新的概念密切相關:當多個事務同時運行時,每一個都不該該看到其它事務未完成的改變。例如,若是一項事務正忙於彙總全部分行的餘額,那麼它將不會包括 Alice 所在分行的這筆借款,也不會包括 Bob 所在分行的這筆貸款,反之亦然。因此事務不只必須是對數據庫的永久影響,還必須視它們發生時的可見性而定。迄今爲止,一個打開的事務進行更新時對其它事務是不可見的,直到該事務完成爲止,隨後全部更新同時可見。

在 PostgreSQL中,例如,一個事務由 BEGIN 和 COMMIT 包裹的 SQL 命令組成。因此咱們的銀行事務實際看起來像這樣:

BEGIN;
UPDATE accounts SET balance = balance - 100.00 WHERE name = 'Alice';
-- etc etc
COMMIT;

若是在事務中途,咱們決定不提交(也許咱們剛剛發現 Alice 的餘額爲負),則能夠發出 ROLLBACK 命令代替 COMMIT,而且到目前爲止全部更新都將被取消。

PostgreSQL 實際上將每一個 SQL 語句都看成事務執行。若是你沒有聲明一個 BEGIN 命令,以後每一個單獨的語句都會被 BEGIN 和 COMMIT 包裹(若是成功的話)。一組用 BEGIN 和 COMMIT 包裹的語句有時候被稱爲事務塊。

全部這些都發生在一個事務塊內,因此任何一個事務對其它數據庫會話都不可見。當你提交事務塊時,提交動做做爲一個單元對其它會話可見,而回滾動做則不可見。

隔離級別 (Isolation Levels)

依據 MySQL Glossary,事務隔離是:

數據庫處理過程的基本功能之一。隔離是縮寫 ACID 中的 "I"。隔離級別是一種設置,用於在多個事務同時進行更新和執行查詢時微調性能與結果可靠性,一致性和可重複性之間的平衡。

從最高級別的一致性和最低程度的保護,InnoDB 支持的隔離級別是:SERIALIZABLE(序列化),REPEATABLE READ(重複讀),READ COMMITTED(讀提交),和 READ UNCOMMITTED(讀未提交)。

對於 InnoDB 表,大多數用戶沿用默認隔離級別 REPEATABLE READ 處理全部操做。資深用戶可能會選擇 READ COMMITTED 級別,這是由於他們在 OLTP 處理中或在數據倉庫操做過程突破了可伸縮性的界限,在這種狀況下,微小的不一致不會影響大量數據的合計結果。邊緣的級別(SERIALIZABLE 和 READ UNCOMMITTED)將處理行爲更改成不多使用的程度。

引用完整性 (Referential Integrity)

依據 MySQL Glossary,引用完整性是:

一種維護數據保持一致格式的技術,是 ACID 哲學的一部分。特別是,不一樣表的數據經過使用外鍵約束來保持一致性,這能夠阻止事件的改變,或者自動廣播這些改變給全部相關的表。相關機制包括一致性約束(防止重複插入錯誤值)以及 NOT NULL 約束(防止空值被錯誤插入)。

鎖 (Locking)

依據 MySQL Glossary,鎖是:

一種保護事務中正在查看或更改的被其它事務查詢和更改的數據的系統(The system of protecting a transaction from seeing or changing data that is being queried or changed by other transactions)。鎖定策略必須在數據庫操做的可靠性和一致性(ACID 哲學原則)與良好併發所需的性能之間取得平衡。調整鎖定策略一般涉及選擇隔離級別,並確保全部數據庫操做對於該隔離級別而言都是可靠的。

併發 (Concurrency)

依據 MySQL Glossary,併發是:

多個操做在相互不干涉的狀況下(在數據庫術語中指事務)同時運行的能力。併發還與性能有關,由於理想狀況下,使用有效的鎖機制來保護多個同時進行的事務時,能夠在最小的性能開銷的狀況下工做。

悲觀併發控制 (PCC)

Clinton Gormley 和 Zachary Tong 在 《Elasticsearch 權威指南》一書中討論過 PCC:

其普遍使用於關係型數據庫,這種方法假設更新有可能發生衝突,並所以阻止對資源訪問以防止衝突。一個典型的例子就是在讀取一行數據以前將其鎖定,以確保只有設置鎖的線程才能夠更新這行數據。
使用 Doctrine

依據 Doctrine 2 ORM Documentation 關於鎖的支持部分:

Doctrine 2 原生地提供悲觀與樂觀鎖策略的支持。這樣能夠對應用程序中的實體所需的鎖種類進行很是細粒度的控制。

依據 Doctrine 2 ORM Documentation 關於悲觀鎖的介紹:

Doctrine 2 在數據庫層面支持悲觀鎖。沒有嘗試在 Doctrine 內部實現悲觀鎖定,而是使用了 vendor-speicific 和 ANSI-SQL 命令來獲取行級鎖。每一個 Doctrine 實體均可以是悲觀鎖的一部分,使用此功能不須要特殊的元數據。

不過,要使悲觀鎖起做用,你必須禁用數據庫的自動提交模式(Auto-Commit Mode),並使用顯式數據劃分(Explicit Transaction Demarcation)在悲觀鎖用例周圍啓動事務。若是你試圖獲取悲觀鎖但事務沒有運行,則 Doctrine 2 會發生異常。

Doctrine 2 當前支持兩種悲觀鎖模式:

  • 悲觀寫模式 Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE,鎖定底層數據庫行以進行併發讀寫操做。
  • 悲觀讀模式 Doctrine\DBAL\LockMode::PESSIMISTIC_READ,鎖定其它嘗試更新或者寫模式行鎖的併發請求。

你能夠在如下三種場景使用悲觀鎖:

  • 使用 EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • 使用 EntityManager#lock($entity,\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 EntityManager#lock($entity,\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • 使用 Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)

樂觀併發控制 (OCC)

依據維基百科:

樂觀併發控制(OCC)是一種併發控制方法,應用於事務系統,例如關係型數據庫以及軟件事務內存。OCC 假設多個事務能夠頻繁完成而不互相干擾。在運行時,事務使用數據資源而不用獲取這些資源的鎖。在提交前,
每一個事務都會驗證沒有其餘事務修改了已讀取的數據。若是檢查顯示有衝突的修改,提交中的事務將回滾並能夠從新啓動。OCC 由 H.T.Kung 首次提出。

OCC 一般用於數據搶佔較少的環境。當衝突不多發生時,事務能夠完成而無需管理鎖。也沒必要讓事務等待其餘事務的鎖被清除,從而致使吞吐量比其餘併發控制方法更高。可是,若是數據資源搶佔頻繁,那麼重複重啓事務的成本會嚴重損害性能。一般認爲其餘併發控制方法在這些條件下有更好的性能。可是,基於鎖的"悲觀"方法也會帶來較差的性能,由於即便避免了死鎖,鎖也會極大地限制有效的併發性。

使用 Elasticsearch

依據 Elasticsearch: The Definitive Guide,當 Elasticsearch 使用 OCC 時:

這種方式假設衝突不可能發生,而且不會阻止嘗試操做。可是,若是在讀寫之間修改了基礎數據,則更新會失敗。而後由應用程序決定如何解決衝突。例如,它可使用新數據從新嘗試更新,也能夠將狀況報告給用戶。

Elasticsearch 是分佈式的。當文檔建立,更新,或者刪除時,新版本的文檔必須複製到集羣中的其它節點。Elasticsearch 同時是異步和併發的,意思就是這些複製請求是並行發送的,而且它們到達目的地是失序的。Elasticsearch 須要一種方法來確保老版本的文檔永遠不會覆蓋更新的版本。

每一個文檔都有一個 _version 數字,不管什麼時候文檔更新,它就會自動增加。Elasticsearch 使用這個 _version 數字來確保按正確的順序應用更改。若是一個更老的版本先於新版本到達,它能夠直接被忽略。

咱們能夠利用 _version 數字來確保應用程序產生的更改衝突不會致使數據丟失。咱們經過指定想要更改的文檔版本數字來作到。若是版本不是當前的,則咱們的請求失敗。

讓咱們新建一個 blog post:

PUT /website/blog/1/_create
{
    "title": "My first blog entry",
    "text": "Just trying this out..."
}

回覆的 body 告訴咱們新建立的文檔有一個 _version 值 1。如今想象咱們須要編輯這個文檔:咱們加載數據到一個 web 表單,作出更改,而且保存新版本。

首先咱們從新查詢該文檔:

GET /website/blog/1

回覆的 body 包含相同的 _version 值 1:

{
    "index": "website",
    "type": "blog",
    "id": "1",
    "version": 1,
    "found": true,
    "_source": {
    "title": "My first blog entry",
    "text": "Just trying this out..."
    }
}

如今,當嘗試經過從新查詢出來的文檔保存咱們的更改,咱們要指定這個將被更改的版本。咱們但願這個更新僅僅在當前文檔的 _version 是 1 時才成功:

PUT /website/blog/1?version=1
{
    "title": "My first blog entry",
    "text": "Starting to get the hang of this..."
}

請求成功後,回覆的 body 告訴咱們 _version 已經增長到 2:

{
    "index": "website",
    "type": "blog",
    "id": "1",
    "version": 2,
    "created": false
}

可是,若是咱們運行同一個索引請求,仍然指定 version=1,Elasticsearch 會回覆一個 409 Conflict HTTP 響應代碼,body 以下:

{
    "error": {
        "root_cause": [{
            "type": "version_conflict_engine_exception",
            "reason":
                "[blog][1]: version conflict,current[2],provided[1]",
            "index": "website",
            "shard": "3"
        }],
        "type": "version_conflict_engine_exception" ,
        "reason": "[blog][1]:version conflict,current [2],provided[1]",
        "index": "website",
        "shard": "3"
    },
    "status": 409
}

它告訴咱們當前文檔的 _version 值在 Elasticsearch 中是 2,但咱們指定更新的版本是 1。

如今咱們要作什麼取決於咱們的應用程序要求。咱們能夠告訴用戶,其餘人已經對文檔進行了更改,並在再次嘗試保存更改以前先進行檢查。另外,就像前面例子的 stock_count widget 同樣,咱們能夠檢索最新的文檔並嘗試從新應用更改。

全部更新或者刪除一個文檔的 API 接受一個版本參數,它容許你將 OCC 僅應用於有意義的代碼部分。

使用Doctrine

依據 Doctrine 2 ORM Documentation 關於 樂觀鎖的部分:

數據庫事務在單個請求期間的併發控制是沒問題的。可是,一個數據庫事務不該該跨越請求,即所謂的用戶思考時間(user think time)。所以一個跨越多個請求的長時間運行的「業務事務」須要涉及多個數據庫事務中。所以,在這樣長時間運行的業務事務中,僅數據庫事務就沒法再控制併發。併發控制成爲應用程序自己的部分責任。
Doctrine 經過 version 字段集成了自動樂觀鎖的支持。這種方法,在長時間運行的業務事務期間,須要被保護的實體在面對併發的改動時會獲取一個 version 字段,該字段能夠是簡單的數字 (映射類型:integer)或時間戳(映射類型:datetime)。若是長時間運行後仍保留對此類實體的更改,則會將該實體的版本與數據庫中的版本進行比較,若是不匹配,則會引起 OptimisticLockException,代表該實體已被其餘人修改。

你指定一個版本字段在下面的實體中,在這個例子中咱們使用了整數:

class User
{
    // ...
    /** @Version @Column(type="integer") */
    private $version;
    // ...
}

當在 EntityManager#flush 期間發生版本衝突時,會拋出一個 OptimisticLockException 異常,而且活動的事務會回滾(或者標記爲回滾)。這個異常能夠被捕獲並處理。對一個 OptimisticLockException 可能的迴應就是呈現這個衝突給用戶或者在一個新的事務中刷新或重載對象,而後重試事務。

因爲 PHP 促進了 share-nothing 架構,在最壞的狀況下,顯示更新表單與實際修改實體之間的時間,可能與您的應用程序會話超時同樣長。若是實體在那個時間範圍內發生變化,而你想在檢索實體時直接知道您將遇到樂觀鎖異常,則能夠在一個請求期間驗證明體的版本,也能夠調用 EntityManager#find()

use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;
try {
    $entity = $em->find(
        'User',
        $theEntityId,
        LockMode::OPTIMISTIC,
        $expectedVersion
    );
// do the work
    $em->flush();
} catch (OptimisticLockException $e) {
    echo
        'Sorry, someone has already changed this entity.' .
        'Please apply the changes again!';
}

或者你可使用 EntityManager#lock() 找出:

use DoctrineDBALLockMode;
use DoctrineORMOptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;
$entity = $em->find('User', $theEntityId);
try {
// assert version em−>lock(entity, LockMode::OPTIMISTIC,
    $expectedVersion);
} catch (OptimisticLockException $e) {
    echo
        'Sorry, someone has already changed this entity.' .
        'Please apply the changes again!';
}

依據 Doctrine 2 ORM Documentation 的重要實現要點:

只要你對比錯誤的版本,你就會很容易獲得樂觀鎖工做流錯誤。假設 Alice 和 Bob 編輯一個 blog post:

  • Alice 讀取標題爲 "Foo" 的博客,樂觀鎖版本 1 (GET Request)
  • Bob 讀取標題爲 "Foo" 的博客,樂觀鎖版本 1 (GET Request)
  • Bob 更新標題爲 "Bar",升級樂觀鎖版本爲 2 (POST Request of a Form)
  • Alice 更新標題爲 "Baz",... (POST Request of a Form)

如今,該博客的最後一個場景狀態須要在 Alice 修改標題以前從數據庫中再次讀取。這裏你確定想檢查博客是否仍然是版本 1(它並不在這個場景裏)。

正確使用樂觀鎖,你必須增長版本(version)做爲一個額外的字段(或者放到 SESSION 中更安全)。不然你沒法驗證這個版本是不是當 Alice 執行 GET 請求時從數據庫中原始讀取的這個版本。若是發生這種狀況,你可能會丟失一些更改,你須要樂觀鎖來解決這些。

看這個示例代碼,表單(GET Request):

$post = $em->find('BlogPost', 123456);
echo '<input type="hidden" name="id" value="' .
$post->getId() . '"/>';
echo '<input type="hidden" name="version" value="' .
$post->getCurrentVersion() . '" />';

以及修改標題動做(POST Request):

$postId = (int) $_GET['id'];
$postVersion = (int) $_GET['version'];
$post = $em->find(
'BlogPost',
$postId,
DoctrineDBALLockMode::OPTIMISTIC,
$postVersion
);

嗯,這裏有太多信息須要吸取。不過,沒必要擔憂你是否徹底知道全部事情。你使用聚合領域驅動設計越多,須要在設計應用程序時考慮事務問題的狀況就會遇到的更多。

總而言之,若是你想保持數據一致性,就使用事務。可是,當心過分使用事務或者鎖策略,由於這些會下降應用程序速度或使它不可用。若是你想有一個真正快的應用,樂觀鎖能夠幫助你。最後但並不是最重要的一點,有些數據能夠是最終一致性。這意味着咱們能夠容許數據在一些特殊的窗口時間不一致。在這段時間,一些不一致性是可接受的。最終,一個異步進程會執行最後的任務來移除這種不一致性。

什麼是聚合

聚合是持有其它實體和值對象的實體,它有助於保持數據一致性。來自 Vaughn Vernon 《實現領域驅動設計》一書中:

聚合精心製做的將實體和值對象聚在一塊兒的一致性邊界。

另外一本了不得的你應該買來讀的書就是 Pramod J.Sadalage 和 Martin Fowler 的《NoSQL精髓:多元語言持久性的新世界簡要指南》,該書說道:

在領域驅動設計裏,聚合是咱們但願將之視爲一個單元總體對待的相關對象的集合。尤爲,它是數據維護以及一致性管理的單元。一般,咱們喜歡用原子操做來更新聚合,並根據聚合與咱們的數據存儲進行通訊。

Martin Fowler 怎麼說

來自 http://martinfowler.com/bliki...

聚合是一種領域驅動設計模式。一個 DDD 聚合是一個能夠視爲單元總體的領域對象集合。一個例子就是訂單和它的物品項,這些是分離的對象,但將訂單(和它的物品項)視爲單個聚合是有用的。

一個聚合使用它的組件對象中的一個做爲爲聚合根。任何外部引用都只能經過聚合根來獲取。所以聚合根能夠保持聚合的完整性。

聚合是你請求加載或保存整個聚合的數據存儲傳輸的基本元素。事務不能跨越聚合邊界。

DDD 聚合有時候與集合類混淆(列表,映射等等)。DDD 聚合是領域概念(訂單,門診,播放列表),而集合是通用的。一個聚合一般包含多種集合和一些簡單字段。聚合是一個很常見的術語,而且在各類不一樣的上下文(例如:UML)裏使用,在這種狀況下,它與 DDD 聚合所指的概念不一樣。

維基百科 怎麼說

來自 https://en.wikipedia.org/wiki...

聚合:一種用根實體(也稱爲聚合根)綁定的對象集合。聚合根經過禁止外部對象持有其成員的引用來確保聚合中所作的更改的一致性。

例子:當你駕駛一輛汽車,你不須要操心移動輪子前進,用火花和燃油使引擎工做等等。你只是開車而已。在這個上下文裏,汽車是一些其餘對象的集合,並充當全部其餘系統的聚合根。

爲何使用聚合

狂熱的讀者可能會想知道這與聚合和聚合設計有什麼關係。實際上,這是一個很好的問題。這有直接關係,所以讓咱們對其進行探討。關係模型使用表來存儲數據。這些表由行組成,其中每行一般表明應用程序所關注概念的一個實例。此外,每行均可以指向同一數據庫其餘表上的其餘行,而且能夠經過使用引用完整性來保持此關係之間的一致性。這個模型至關好;不過,它缺乏一個很是基本的詞:對象。

確實,當咱們討論關係醋地,咱們是在討論表,行與行之間的關係,當咱們討論面向對象模型時,咱們主要是在討論對象的組成。所以,每次咱們從關係數據庫中獲取數據(一些行)時,咱們都會運行一個翻譯過程,它負責構建咱們可使用的內存表示形式。相反的方向也同樣。每當咱們在數據庫中存儲一個對象時,咱們都應該運行另外一個轉換過程,以將該對象轉換爲給一組行或表。這種從對象到行或表的轉換着你能夠對數據庫運行不一樣的查詢。這樣,在不使用任何特定工具(例如事務)的狀況下,不可能保證數據被一致性持久化。這種問題就是所謂的阻抗失配

阻抗失配

對象-關係阻抗失配是一組概念和技術難題,當以面向對象的編程語言或風格編寫的程序在使用關係數據庫管理系統(RDBMS)時,尤爲是在對象或類時,一般會遇到這些問題,以直接的方式映射到數據庫表或關係模式。

來自 維基百科

阻抗失配不是一個容易解決的問題,所以咱們強烈建議你自行解決。這將是一項艱鉅的任務,這根本不值得付出努力。幸運的是,這裏有一些庫負責這個翻譯過程。它們一般稱爲對象關係映射器(Object-Relational Mappers),這些咱們在前面的章節中已經討論過,它們的主要關注點是簡化從關係模型到面向對象模型的轉換過程,反之亦然。

這個問題也影響 NoSQL 持久化引擎,而不只僅是數據庫。大多數 NoSQL 引擎使用文檔(document),例如 JSON, XML, 二進制文件等。而後持久化。與 RDBMS 數據庫不一樣的是,若是主實體(例如訂單 Order)具備其餘相關實體(例如 OrderLines),則能夠更輕鬆地設計一個包含全部信息的 JSON 文檔。經過這種方法,只須要向 NoSQL 引擎發送一個請求,而不須要事務。

可是,若是你使用 NoSQL 或 RDBMS 來獲取和持久化實體,則須要一個或多個查詢。爲了確保數據一致性,這些查詢或請求須要做爲單個操做執行。這能夠保證數據的一致性。

一致性意味着什麼?它意味着全部持久化到數據庫中的數據必須符合全部業務規則,即所說的不變性。一個不變性的業務實例就是 GitHub 上,一個用戶能夠有無限個公開的倉庫但沒有私有倉庫。可是,若是用戶每月付 12 美圓,那麼他們就能夠有上限 10 個私有倉庫。

關係型數據庫提供三個主要工具來幫助咱們處理數據一致性:引用完整性:外鍵,非空檢查等等;事務:將多個查詢做爲單個操做。事務的問題與你代碼倉庫的分支及合併同樣。持有分支會有性能代價(內存,CPU,存儲,索引等等)。若是太多人(併發地)修改相同的數據,衝突就會發生同時提交事務就會失敗;:鎖定行或者表。圍繞相同的表或行的其餘查詢必須等等鎖移除。鎖對你的應用程序有反作用。

假設咱們有一個電子商務應用程序,咱們能夠擴大到其餘國家和地區,而且假設發行良好,銷售也增加了。一個明顯的反作用就是數據庫須要處理額外增加的負擔。如前所述,這有兩種擴展方法:向上或向外。

向上是指提高咱們的硬件設施(例如:更好的 CPU,更多內存,更好的硬盤)。向外是指增長更多機器,這些機器將做爲一個集羣來處理一些特殊的做業。在這種狀況下,咱們能夠有一個數據庫集羣。

可是關係型數據庫並非設計爲水平擴展的,由於咱們不能配置成保存一些數據集到給定的機器,另外一些數據集到另外一臺。關係型數據庫很容易向上(垂直)擴展,但關係模型不能水平擴展。

在 NoSQL 的世界裏,數據一致性有一點困難:事務和引用完整性不被廣泛支持,而鎖是支持的但通常不鼓勵使用。

NoSQL 數據庫不會受到阻抗失配太大的影響。他們與聚合設計徹底匹配,由於它容許咱們輕鬆地自動保存和檢索單個單元。例如,當使用像 Redis 這樣的鍵-值儲存時,一個聚合能夠被序列化而後用一個指定的鍵(key)保存。在 Elasticsearch 這樣的面向文檔存儲器,一個聚合會被序列化到一個 JSON 並持久化爲一個文檔。正如以前提到的,問題是來自於多個文檔須要立馬更新時。

由於這些緣由,當用單個表示(一個文檔,由於不須要多個查詢)持久化任何對象時,是很容易將這些單個單元分佈到不一樣的機器(所謂的節點),而這些機器能夠組成一個 NoSQL 數據庫集羣。這裏的共識就是,這樣的數據庫是容易分佈式,也就是說這種類型的數據庫是容易水平擴展的。

一點歷史

在 21 世紀初,像 Amazon 和 Google 這樣的公司迅速發展。爲了鞏固基的增加,他們使用集羣技術:不只有更好的服務器,並且還依賴於更多的服務器協同工做。

在這樣的一個場景下,決定怎樣儲存你的數據便很關鍵。若是你有一個實體並將其信息分佈在集羣的多個節點上的多個服務器裏,則控制事務所需的工做量很大。獲取一個實體也同樣。所以,若是你能夠以持久化在集羣節點中的方式設計實體,那麼事情就變得容易不少。這就是聚合設計如此重要的緣由之一。

若是你想了解更多關於領域驅動設計以外的聚合設計的歷史,能夠看看《NoSQL精髓:多元語言持久化的新世界簡要指南》

聚合剖析

聚合是能夠持有其餘實體和值對象的實體。父實體即爲根實體。

沒有子實體或者值對象的單個實體自身也是一個聚合。這就是爲何在一些書籍中,聚合這個術語用來代替實體術語。當咱們在這裏這樣使用它們,實體和聚合就表示同一個事物。

聚合的主要目標就是保持領域模型的一致性。聚合使大多數業務規則集中化。聚合在你的持久化機制裏自動持久化。不管多少個子實體和值對象在根實體中,它們都會做爲單個單元自動持久化。讓咱們看一個例子。

考慮一個電子商務應用程序,網站等等。用戶能夠下單,其中有多行來定義所購買的產品,價格,數量和每行總金額。訂單也有一個總金額,這是全部行金額的總和。

若是你更新一行的數量而不是總的訂單數量會發生什麼?數據不一致。爲了修正這個問題,對聚合中任何實體和值對象的全部更改都是經過聚合根來執行的。大多數 PHP 開發者更喜歡構建對象而且用客戶端代碼來處理它們的關係,而不是就業務邏輯放到實體裏:

$order = ...
$orderLine = new OrderLine(
'Domain-Driven Design in PHP', 24.99
);
$order->addOrderLine($orderLine);

正如上面的代碼所示,新手或者中級開發者通常會首先構建子對象而後用一個 setter 方法與其父對象關聯。考慮以下方式:

$order = ...
$orderLine = $order->addOrderLine(
'Domain-Driven Design in PHP', 24.99
);

這些方法頗有意思,由於他們遵循了兩種軟件設計原則:命令-不要詢問(Tell, Don't Ask)原則和迪米特原則(Law of Demeter)。

按照 Martin Fowler 闡述:

命令-不要詢問原則幫助人們記住,面向對象是將數據與對該數據進行操做的功能綁定在一塊兒。它提醒咱們,與其向對象詢問數據並對該數據執行操做,不如告訴對象該怎麼作。這鼓勵將行爲轉移到與數據一塊兒使用的對象當中。

根據維基百科解釋:

迪米特法則(LoD)或者說最少知識原則,是開發軟件的一種設計指導,尤爲是面向對象程序。在它的普通形式裏,LoD 是一種特殊的鬆耦合例子,並能夠簡要總結爲下面的每一個方式:

1 每一個單元應該僅保有其餘單元的最少知識:即與當前單元相關聯的「最近的」單元。
2 每一個單元應該僅與它的友元通訊,而不是其它單元。
3 僅與你當即要通訊友元進行通訊。

基本概念是,根據「信息隱藏」的原理,給定的對象應儘量少地考慮其餘任何事物(包括其子組件)的結構和屬性。

讓咱們繼續用訂單例子。你已經學過了怎樣經過實體根來運行操做。如今讓咱們更新訂單中的一行產品的數量。這個操做會增長數量,這一行的總數量,以及訂單總數量。很好!如今是時候用這些更改來持久化訂單了。

若是你使用 MySQL,你能夠想象咱們須要兩個 UPDATE 語句:一個是給訂單表,一個是給 order_line 表。若是這兩個查詢不在同一個事務裏會發生什麼?

讓咱們假設更新訂單行的 UPDATE 語句工做正確。可是,因爲網絡鏈接緣由更新訂單總數量的 UPDATE 失敗了。在這個場景下,你會在你的領域模型裏獲得一個數據不一致的結果。事務能夠幫你保持一致性。

若是你使用 Elasticsearch,這種場景有點不同。你能夠用一個 JSON 文檔映射這個訂單,它內部持有訂單行。所以只須要單個請求。可是,若是你用一個 JSON 映射訂單用另外一個 JSON 映射訂單行,你就會陷入麻煩,由於 Elasticsearch 不支持事務!

一個聚合用它本身的倉儲(Repositories, 第 10 章)來獲取和持久化。若是兩個實體不屬於同一個聚合,那麼它們都有本身的倉儲。若是一個真正不變的業務存在而且兩個實體屬於同一個聚合,你就只有一個倉儲。它就是根實體的倉儲。

聚合的缺點是什麼?問題就是當處理事務時可能的性能問題和操做錯誤。咱們會很快深刻這些問題。

聚合設計原則

當設計一個聚合時,爲了得到最大的收益和最小化反作用,有一些要遵循的原則和考慮。不要擔憂太多,若是你如今還不知道什麼。做爲一個例子,咱們會展現一個小應用程序,在該應用程序中咱們將會引用向您介紹的規則。

基於業務真正不變條件設計聚合

首先,什麼是不變性?不變性是在代碼執行期間必須爲真且一致的規則。例如,棧(stack)是一種 LIFO(後進先出)的數據結構,咱們能夠將元素壓入和彈出。咱們也能夠詢問棧中有多少元素;這就是所謂的堆棧大小。考慮一個不用任何特定的 PHP 數組函數(例如 array_pop)的純 PHP 實現:

class Stack
{
    private $data;

    public function __construct()
    {
        $this->data = [];
    }

    public function push($value)
    {
        $this->data[] = $value;
    }

    public function size()
    {
        $size = 0;
        for ($i = 0; $i < count($this->data); $i++) {
            $size++;
        }
        return $size;
    }

    /**
     * @return mixed
     */
    public function pop()
    {
        $topIndex = $this->size() - 1;
        $top = $this->data[$topIndex];
        unset($this->data[$topIndex]);
        return $top;
    }
}

考慮上面的 size 方法的實現。它離完美還差很遠,但它能工做。可是,由於用上面的代碼實現,它是 CPU 密集且高昂的調用。幸運的是,這有一個選擇來優化這個方法,經過引入一個私有屬性來跟蹤數組內部元素的數量:

class Stack
{
    private $data;
    private $size;

    public function __construct()
    {
        $this->data = [];
        $this->size = 0;
    }

    public function push($value)
    {
        $this->data[] = $value;
        $this->size++;
    }

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

    /**
     * @return mixed
     */
    public function pop()
    {
        $topIndex = $this->size--;
        $top = $this->data[$topIndex];
        unset($this->data[$topIndex]);
        return $top;
    }
}

經過這些修改,size 方法如今更快,所以它僅僅返回 size 字段的值。爲了達到這個目標,咱們引入一個新的整型屬性叫作 size。當一個新的棧(Stack)建立時,size 的值爲0,而且沒有棧內沒有任何元素。當咱們用 push 方法添加一個新的元素到棧中,同時會增長 size 字段的值。類似的,用 pop 方法從棧內彈出元素時咱們會減小 size 的值。

經過增長和減小 size 的值,咱們保證了棧了元素真實數量的一致性。size 值的在調用棧全部公開方法以前和以後都是一致的。結果是,size 的值老是等於棧內元素的數量。這就是不變性!咱們能夠這樣寫: $this->size === count($this->data)

真正的業務不變性是一個業務規則,它必須老是爲真而且在一個聚合裏是事務一致性。由於事務一致性,咱們更新聚合時必須爲一個原子操做。全部包含在聚合內的數據必須是原子持久化。若是不遵循這個規則,咱們可能會持久一個不合法的聚合表示。

根據 Vaughn Vernon 所述:

一個設計正確的聚合能夠用任何業務所需的方式進行修改,其不變性在單個事務中徹底一致。在全部狀況下,通過適當設計的限界上下文在每一個事務中僅修改一個聚合實例。並且,若是不該用事務分析,咱們就沒法正確地推斷聚合設計。

正如介紹中討論的,在一個電子商務應用裏,訂單的總數量必須匹配每一個訂單行的數量總和。這就是不變性,或業務規則。咱們必須在同一個事務裏持久化 Order 和 OrderLines 到數據庫裏。它約束咱們把 Order 和 OrderLine 做爲同一聚合的一部分。而 Order 是聚合根。由於 Order 是根,全部與 OrderLines 有關的操做必須經過 Order 執行。所以再也不須要在 Order 外部實例化 OrderLine 對象,而後使用 setter 方法將 OrderLines 添加到 Order。相反,咱們必須在訂單上使用工廠方法(Factory Method)。

經過這種方法,咱們在聚合上有單點入口來執行操做:訂單(Order)。它意味着這裏沒有機會調用一個方法來破壞規則。每次你經過 Order 添加或者更新 OrderLine,Order 的總數量會在內部從新計算。使全部操做必須通過根,有助於咱們保持聚合一致性。在這種方式中,它很難打破任何不變性。

小聚合 Vs. 大聚合

對於咱們經歷過的大多數網站和項目,幾乎 95% 的聚合由單個根實體和一些值對象組成。在同一個聚合裏沒有其它所必需的。所以在大多數案例中,是沒有真正不變業務規則來保持一致性的。

須要當心處理 has-a/has-many 關係,即有沒有必要將兩個實體變成一個聚合,其中一個做爲根。關係,正如咱們所見,能夠經過引用實體標識處理。

正如介紹裏所解釋,一個聚合就是一個事務邊界。邊界越小,在提交多個併發事務時發生衝突的機會就越小。在設計聚合時,你應該努力把設計得越小。若是項目裏沒有真正不變性,這意味着全部單個實體自身就是聚合。這就很棒,由於這是獲取最好的性能的最好場景。爲何?由於鎖問題和事務失敗問題最小化了。

若是你決定設計大聚合,保持數據一致性將很是容易但這可能不切實際。當大聚合應用運行在生產中,當大量用戶執行操做時就會開始遇到問題。當使用樂觀鎖時,主要的問題就是事務失敗。只要使用鎖,就會有變慢和超時的問題。

讓咱們考慮一些基本例子。當使用樂觀併發時,想象整個領域是版本化的,而且任何實體上的每一個操做爲整個領域建立一個新版本。在這種場景下,若是兩個用戶在兩個絕不相干的實體上執行不一樣的操做,第二個請求就會遇到因爲不一樣版本引發的事務失敗。換句話說,當使用悲觀併發時,想象一個咱們在每一個操做上加鎖的場景。這會阻塞全部用戶直到鎖釋放。這意味着許多請求會處於等待中,而且在某個時間點,可能會超時。二者均可以保持數據一致性,但應用不能被超過一個用戶來使用。

最後但並不是最不重要的一點,當設計大聚合時,因爲它們可能持有實體集合,考慮加載如此大的集合到內存中的性能意義就很重要。甚至使用像 Doctrine 這樣有懶加載(在須要時加載數據)的 ORM 來加載集合,若是集合過大,就不能放到內存裏。

經過標識引用其它實體

當兩個實體不造成一個聚合但它們相關聯,最好的選擇就是經過標識來相互引用。標識(Identity)已經在第 4 章,實體中闡述。

考慮一個 User 和它們的 Orders,而且假設咱們沒有發現真正不變性。 User 和 Order 不會是同一聚合的一部分。若是你想知道哪一個 User 擁有一個指定的 Order,你可能須要詢問 Order 的 UserId 是什麼。UserId 是一個持有 User 標識的值對象。咱們能夠經過它的倉儲(UserRepository)得到整個 User。這些代碼在應用服務(Application Service)裏有呈現。

正如通常的解釋,每一個聚合都有本身的倉儲。若是你查詢一個指定的聚合而且你須要查詢另外一個相關聯的聚合,你會在應用服務或者領域服務裏操做。應用服務依賴華聯倉儲來查詢所需聚合。

從一個聚合跳到另外一個就是所謂的領域遍歷或者領域導航。使用 ORM,很容易經過在實體間映射全部關係來作到。可是,這樣真的很危險,你能夠輕鬆地在特定功能中運行無數查詢。一般,你不該該這樣作。不要映射全部實體之間的關係,僅僅是由於你能。相反,僅當兩個實體造成一個聚合時,才映射 ORM 中一個聚合內的實體之間的關係。若是不是這種狀況,則使用倉儲來獲取引用的聚合。

每一個事務和請求只更新一個聚合

考慮以下場景:你作出一個請求,它進入你的控制器(controller),而且它意圖更新兩個不一樣的聚合。每一個聚合都在那個聚合內保持數據一致性。然而,若是第一個聚合上的更新請求忽然中止(服務器重啓,從新加載,內存溢出等等)而且第二個沒有更新會發生什麼後果?這是否是數據一致性問題?多是,讓咱們考慮一些解決方案。

來自於 Vaughn Vernon 的 《實現領域驅動設計》:

在一個設計正確的限界上下文裏,全部狀況下,每一個事務僅修改一個聚合實例。並且,若是不該用事務分析,咱們就沒法推斷聚合的設計。限制每一個事務修改一個聚合實例可能聽起來過於嚴格。可是,這是經驗法則,在大多數狀況下應該是目標。它點破了使用聚合的根本緣由。

若是在單個請求裏,你須要更新兩個聚合,也許這兩個聚合就應該是一個,而且須要在同一事務更新二者。若是不是,你能夠在一個事務裏包裹整個請求,但咱們不推薦這種方式做爲主要選擇,由於涉及到性能問題和事務錯誤。

若是聚合上更新都不須要包裹到事務中,這意味着咱們能夠假定更新之間有一些延遲。在這種狀況下,須要使用另外一個領域驅動設計方法,就是領域事件。當這樣作時,第一個聚合更新會觸發一個領域事件。這個事件會持久化到同一事務做爲一個聚合更新事件,而後發佈到消息隊列。以後,一個訂閱者會從隊列取出並執行第二個聚合更新。這樣的方式就是最終一致性(Eventual Consistency),減少了事務邊界大小,提升了性能,而且下降了事務錯誤。

應用服務示例:用戶(User)與願望(Wish)

如今你知道了聚合設計的一些基本規則。

學習聚合最好的方式就是看代碼。所以讓咱們考慮一個 web 應用場景,用戶能夠在發生某種事情(相似於遺囑)時實現願望。例如,我但願發送一封電子郵件給個人妻子說明如何處理個人 GitHub 帳號,若是我在一場可怕的意外中喪生的話,或者我想發一封郵件告訴她我有多愛她。確認我是否還活着的方法就是回覆平臺發送給的郵件。(若是你想了解關於這個應用更多信息,你能夠訪問咱們的 GitHub 帳號)因此咱們有了用戶以及他們的願望。讓咱們僅考慮一個用例:「做爲一個用戶(User),我想許願(Wish)」。咱們能夠怎樣對此建模?當設計聚合時使用好的實踐,讓咱們試着設計一些小聚合。在這種狀況下,這意味着使用兩個不一樣聚合,User 和 Wish。對於它們之間的關係,咱們應該使用一個標識,例如 UserId。

非不變性,兩個聚合

咱們會在後面的章節討論應用服務,但如今,讓咱們檢查許願(making a wish)的不一樣方法。第一種,特別是對於新手,可能與此相似:

class MakeWishService
{
    private $wishRepository;

    public function __construct(WishRepository $wishRepository)
    {
        $this->wishRepository = $wishRepository;
    }

    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            new UserId($userId),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

這段代碼可能會有最好的性能。你幾乎能夠看到這個場景後的 INSERT 語句;這種用例的最小操做數是 1,這和奶好,經過當前的實現,咱們能夠根據業務需求建立任意數量的願意,這也很好。

可是,這可能有個潛在的問題:在這個領域內咱們能夠爲一個不存在的用戶建立願望。無論咱們持久化聚合使用的是何種技術,這都是個問題。即便在內存中實現,也能夠建立沒有對應用戶的願望。

這是一個破壞性的業務邏輯。固然,這能夠在數據庫中用一個外鍵來修正,從 wish (user_id) 到 user (id),可是若是秩不使用數據庫外鍵會發生什麼?以及是 NoSQL 數據庫時會發生什麼?例如 Redis 或者 Elasticsearch?

若是咱們想解決這個問題,使相同代碼在不一樣的基礎設施中工做正確,咱們須要檢查用戶是否存在。在同一個應用服務裏這彷佛是最簡單的方法:

class MakeWishService
{
// ...
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            $user->id(),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

上面代碼能夠工做,但在應用服務裏執行檢查會有一個問題:這個檢查在委託鏈中很高。若是不是該應用服務的任何其餘代碼段(例如領域服務或其餘實體)想建立一個無用戶的願望,則能夠執行此操做。看下面的代碼:

// Somewhere in a Domain Service or Entity
$nonExistingUserId = new UserId('non-existing-user-id');
$wish = new Wish(
    $this->wishRepository->nextIdentity(),
    $nonExistingUserId,
    $address,
    $content
);

若是你已經讀了第 9 章,工廠,那麼你已經有了解決方案。工廠(Factories)幫助咱們保持業務不變性,而這正是咱們此處所需的。

這裏有一個明顯的不變性,咱們不容許一個不存在的用戶許願。讓咱們看看工廠是如何幫助咱們的:

abstract class WishService
{
    protected $userRepository;
    protected $wishRepository;

    public function __construct(
        UserRepository $userRepository,
        WishRepository $wishRepository
    )
    {
        $this->userRepository = $userRepository;
        $this->wishRepository = $wishRepository;
    }

    protected function findUserOrFail($userId)
    {
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        return $user;
    }

    protected function findWishOrFail($wishId)
    {
        $wish = $this->wishRepository->ofId(new WishId($wishId));
        if (!$wish) {
            throw new WishDoesNotExistException();
        }
        return $wish;
    }

    protected function checkIfUserOwnsWish(User $user, Wish $wish)
    {
        if (!$wish->userId()->equals($user->id())) {
            throw new \InvalidArgumentException(
                'User is not authorized to update this wish'
            );
        }
    }
}

class MakeWishService extends WishService
{
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $wish = $user->makeWish(
            $this->wishRepository->nextIdentity(),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

正如你所見,用戶許願(Users make Wishes),而且代碼也是這樣。 makeWish 是一個用來構建願望的工廠方法(Factory Method)。這個方法返回一個新的願望,來自於咱們自身 UserId 所構建:

class User
{
// ...
    /**
     * @return Wish
     */
    public function makeWish(WishId $wishId, $address, $content)
    {
        return new Wish(
            $wishId,
            $this->id(),
            $address,
            $content
        );
    }
// ...
}

爲何咱們要返回願望,而不是像對 Doctrine 那樣將新的願望添加到內部集合中?總而言之,在這種狀況下,User 和 Wish 不屬於一個聚合,由於沒有真正的業務不變性能夠保護。用戶能夠根據須要添加和刪除任意數量的願望。若是須要,能夠在數據庫中以不一樣的事務方式獨立更新願望及其用戶。

遵循前面闡述的有關聚合設計的原則,咱們能夠針對小聚合,這就是這裏的結果。每一個實體都有其本身的倉儲。願望(Wish)使用標識(在這種狀況下爲 UserId)引用擁有它的用戶。能夠經過 WishRepository 中的 finder 獲取它們,而且能夠輕鬆分頁,而不會出現任何性能問題:

interface WishRepository
{
    /**
     * @param WishId $wishId
     *
     * @return Wish
     */
    public function ofId(WishId $wishId);

    /**
     * @param UserId $userId
     *
     * @return Wish[]
     */
    public function ofUserId(UserId $userId);

    /**
     * @param Wish $wish
     */
    public function add(Wish $wish);

    /**
     * @param Wish $wish
     */
    public function remove(Wish $wish);

    /**
     * @return WishId
     */
    public function nextIdentity();
}

這種方法有趣的一面是,咱們沒必要在咱們最喜歡的 ORM 中映射用戶(User)和願望(Wish)之間的關係。由於咱們使用 UserId 從願望中引用了用戶,因此咱們只須要倉儲。讓咱們考慮如何使用 Doctrine 映射此類實體:

Lw\Domain\Model\User\User:
  type: entity
  id:
    userId:
      column: id
      type: UserId
  table: user
  repositoryClass: Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
  fields:
  email:
    type: string
  password:
    type: string
Lw\Domain\Model\Wish\Wish:
  type: entity
  table: wish
  repositoryClass: Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
  id:
    wishId:
      column: id
      type: WishId
  fields:
    address:
      type: string
    content:
      type: text
    userId:
      type: UserId
    column: user_id

沒有關係定義。在許願以後,讓咱們寫一些更新一個存在的願望的代碼:

class UpdateWishService extends WishService
{
    public function execute(UpdateWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $email = $request->email();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        $wish->changeContent($content);
        $wish->changeAddress($email);
    }
}

由於 User 和 Wish 並不造成一個聚合,爲了更新 Wish,咱們首先須要從 WishRepository 中查詢它。一些額外的檢查就是隻有本身能更新願望。正如你可能看到的,$wish 是已經咱們中存在的實體,所以不須要再用倉儲把它加回來。可是,爲了使更改持久,咱們的 ORM 必須在更新和沖刷任何到數據庫中殘餘的改變以後顯示這些信息。不用擔憂;咱們看一下第 11 章,應用。爲了完成這個例子,讓咱們看看怎樣刪除一個願望:

class RemoveWishService extends WishService
{
    public function execute(RemoveWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        $this->wishRepository->remove($wish);
    }
}

如你所見,你能夠重構代碼的某些部分,例如構造函數和全部權檢查,以便在這兩個應用服務中重用。隨時考慮你將如何作。最後但並不是最不重要的一點是,咱們如何得到指定用戶的全部願望:

class ViewWishesService extends WishService
{
    /**
     * @return Wish[]
     */
    public function execute(ViewWishesRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        return $this->wishRepository->ofUserId($user->id());
    }
}

這很簡單。可是,在相應的章節中,咱們將更深刻地介紹如何從應用服務呈現和返回信息。就目前而言,返回願望集合就能夠了。

讓咱們總結一下這種非聚合方法。咱們找不到任何真正的業務不變性能夠將用戶(User)和願望(Wish)視爲一個聚合,這就是爲何它們每一個都是聚合的緣由。用戶具備本身的 UserRepository,願望也有本身的 WishRepository。每一個願望都擁有對全部者的 UserId 引用。即便這樣,咱們也不須要事務。就性能和可伸縮性而言,這是最佳方案。然而,生活並不老是那麼美好。考慮真正的業務不變性會發生什麼?

每一個用戶不超過三個願望

咱們的應用程序取得了巨大的成功,如今是時候從中得到一些收益了。咱們但願新用戶最多擁有三個願望。做爲用戶,若是你但願有更多的願望,未來須要爲高級帳戶付費。讓咱們看看怎樣更改代碼以符合關於最大但願數量的新業務規則(在這種狀況下,不考慮高級用戶)。

考慮下面的代碼。除了上一節中有關將邏輯放入咱們的實體中所解釋的內容以外,如下代碼還能夠工做:

class MakeWishService
{
// ...
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->email();
        $content = $request->content();
        $count = $this->wishRepository->numberOfWishesByUserId(
            new UserId($userId)
        );
        if ($count >= 3) {
            throw new MaxNumberOfWishesExceededException();
        }
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            new UserId($userId),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

看起來能夠。那很容易(可能太容易了)。在這裏,咱們遇到了不一樣的問題。首先是應用服務必須協調,但不該包含業務邏輯。相反,更好的方法是將最多三個願望的檢查放到用戶中,在這裏咱們能夠更好地控制用戶和願望之間的關係。可是,對於此處展現的方法,該代碼看起來彷佛有效。

第二個問題是它在競爭條件下不起做用。暫時忘了領域驅動設計。在通信繁忙的狀況下,這代碼有什麼問題?思考一分鐘。是否有可能違反用戶規則從而擁有三個以上的願望?爲何進行一些壓力測試後你的 QA 會如此開心?

你的 QA 會嘗試兩次建立一個願望功能,最終致使一個有兩個願望的用戶。沒錯你的 QA 正在進行功能測試。想象一下,他們在瀏覽器中打開了兩個選項卡,填寫了每一個選項卡的每一個表彰,並高潮同時提交了兩個按鈕。忽然,在再次請求以後,用戶最終在數據庫中獲得了四個願望。錯了!發生了什麼?

以調試的角度考慮兩個不一樣的請求同時獲取 if ($count > 3) { 這一行。由於用戶只有兩個願望,因此兩個請求都將返回 false。所以,兩個請求都將建立願望(Wish),而且兩個請求都將其添加到數據庫中。結果一個用戶擁有四個願望。太矛盾了!

咱們知道你在想什麼。這裏由於咱們沒有將全部東西都放到事務中。好吧,假設ID 爲 1 的用戶已經兩個願望,所以還有一個願望。建立兩個不一樣願望的兩個 HTTP 請求同時到達。咱們爲每一個請求啓動一個數據庫事務(咱們將在第 11 章,應用中介紹如何處理事務和請求)。考慮一下以前的 PHP 代碼將對咱們的數據庫運行的全部查詢。請記住,若是使用任何數據庫可視化工具(Visual Database Tool),則須要禁用任何自動提交標誌:
圖1

圖2

ID 爲 1 的用戶有多少個願望?是的,四個。這怎麼發生的?若是您使用此 SQL 塊並在兩個不一樣的鏈接中逐行執行它,你將看到願望(wishes)表在兩個執行結束時將如何具備四行。所以,這彷佛與事務保護無關。咱們如何解決這個問題?如簡介中所述,併發控制可能會有所幫助。

對於那些在數據技術方面更高級的開發人員,能夠調整隔離級別。可是,咱們認爲該選項太複雜了,由於能夠經過其餘方法解決該問題,並且咱們並不老是處理數據庫。

悲觀併發控制

設置鎖時,有一個重要的考慮因素:試圖更新或查詢相同數據的任何其餘鏈接都將掛起,直到鎖釋放爲止。鎖很容易產生大多數性能問題。例如,在 MySQL 中,有多種不一樣的方式設置鎖:顯示鎖表 UN/LOCK tables, 以及讀鎖 SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE。

正如咱們在開始時分享的那樣,根據 Clinton 和 Zachary Tong 撰寫的《Elasticsearch 權威指南》一書所述:

關係數據庫普遍使用此方法,它假定可能發生衝突的更改,所以阻止對資源的訪問以防止衝突。一個典型的示例是在讀取一行數據以前將其鎖定,以確保只有設置了鎖的線程才能夠更改該行數據。

圖3

如你所見,在第一個請求 COMMIT 以後,第二個請求的願望數爲 3。這是一致的,可是第二個請求正在等待,而鎖沒有釋放。這意味着在有大量請求的環境中,它可能會產生性能問題。若是第一個請求花費太多時間來釋放鎖,則第二個請求可能因爲超時而失敗:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

上面的代碼看起來是一個有效的選項,但咱們須要注意可能的性能問題。還有其餘選擇嗎?

樂觀併發控制

還有另外一種方法:根本不使用鎖。考慮將版本屬性添加到咱們的聚合中。當咱們持久化它們時,持久化引擎將 1 設置爲被持久化的聚合的版本。稍後,咱們檢索相同的聚合並對其進行一些更改。咱們持久化聚合。持久化引擎檢查咱們擁有的版本是否與當前持久化的版本 1 相同。持久化引擎用新的狀態持久化聚合並更新版本爲 2。若是多個請求查詢相同的聚合,請作一些改變,而後持久化它,第一個請求會起做用,第二個則會出錯。最後一個請求只是更改了一個過期的版本,所以持久化引擎將引起錯誤。可是,第二個請求能夠嘗試再次檢查聚合,合併新狀態,嘗試執行更改,而後持久化聚合。

根據 《Elasticsearch 權威指南》所述:

該方法假定衝突不太可能發生,而且不會阻止嘗試操做。可是,若是在讀寫之間修改了基礎數據,則更新將失敗。而後由應用程序決定如何解決衝突。例如,它可使用新數據從新嘗試更新,也能夠將狀況報告給用戶。

這個方法以前有說起,可是有必要再囉嗦一下。若是你嘗試將樂觀併發應用於這種狀況,在這種狀況下咱們正在檢查應用服務中最大的願望(Wish)值,那麼它將沒法工做。爲何?咱們正在生成一個新的願望,因此兩個請求將建立兩個不一樣的願望。咱們如何使其工做?好吧,咱們須要一個對象來集中添加願望。咱們能夠在該對象上應用樂觀併發技巧,所以看起來咱們須要一個能夠保存願望的父對象。有任何想法嗎?

總而言之,在檢查併發控制以後,有一個悲觀的選擇正在起做用,可是對性能影響存在一些擔心。有一個樂觀的選擇,可是咱們須要找到一個父對象。讓咱們考慮最終的 MakeWishService,但須要一些修改:

class WishAggregateService
{
    protected $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    protected function findUserOrFail($userId)
    {
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        return $user;
    }
}

class MakeWishService extends WishAggregateService
{
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $user->makeWish($address, $content);
// Uncomment if your ORM can not flush
// the changes at the end of the request
// $this->userRepository->add($user);
    }
}

咱們不傳遞 WishId,由於它應該是用戶內部的東西。makeWish 也不返回願望;它在內部存儲新的願望。執行完應用服務以後,咱們的 ORM 會將在 $user 上執行的更改刷新到數據庫。根據咱們的 ORM 的好壞,咱們可能須要使用倉儲再次顯式添加用戶實體。須要對 User 類進行哪些更改?首先,應該有一個能夠容納用戶內部全部願望的集合:

class User
{
// ...
    /**
     * @var ArrayCollection
     */
    protected $wishes;

    public function __construct(UserId $userId, $email, $password)
    {
// ...
        $this->wishes = new ArrayCollection();
// ...
    }
// ...
}

願望(Wish)屬性必須在 User 構造函數中初始化。咱們可使用普通的 PHP 數組,可是咱們選擇使用 ArrayCollection。ArrayCollection 是一個 PHP 數組,具備 Doctrine Common Library 提供的一些其餘功能,能夠與 ORM 分開使用。咱們知道大家中的某些人可能認爲這多是邊界泄漏,而且這裏沒有任何基礎設施的引用,但咱們確實認爲並不是如此。實際上,相同的代碼可使用純 PHP 數組工做。讓咱們看看 makeWish 實現如何受到影響:

class User
{
// ...
    /**
     * @return void
     */
    public
    function makeWish($address, $content)
    {
        if (count($this->wishes) >= 3) {
            throw new MaxNumberOfWishesExceededException();
        }
        $this->wishes[] = new Wish(
            new WishId,
            $this->id(),
            $address,
            $content
        );
    }
// ...
}

到目前爲止還挺好。如今,該回顧一下其他操做的實現方式了。

追求最終一致性
業務彷佛不但願用戶擁有三個以上願望。這將使咱們把 User 視爲內部包含 Wish 的根聚合。這會影響咱們的設計,性能,可伸縮性問題等等。考慮一下,若是咱們只容許用戶添加想要的願望,而超出了限制,將會發生什麼。咱們能夠檢查誰超出了該限制,並讓他們知道他們須要購買高級帳戶。容許用戶超過限制並隨後經過電話警告他們將是一個很是不錯的商業策略。這甚至可能使你的團隊中的開發人員避免將 User 和 Wish 設計爲同一聚合(以 User 爲根)的一部分。你已經看到了不設計單個聚合的好處:最高性能。
class UpdateWishService extends WishAggregateService
{
    public function execute(UpdateWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $email = $request->email();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $user->updateWish(new WishId($wishId), $email, $content);
    }
}

因爲 User 和 Wish 如今是一個聚合,所以再也不使用 WishRepository 查詢要更新的願望。咱們使用 UserRepository 獲取用戶。更新願望的操做是經過根實體(在這種狀況下爲用戶)執行的。WishId 是必需的,以便標識咱們要更新的願望:

class User
{
// ...
    public function updateWish(WishId $wishId, $email, $content)
    {
        foreach ($this->wishes as $wish) {
            if ($wish->id()->equals($wishId)) {
                $wish->changeContent($content);
                $wish->changeAddress($address);
                break;
            }
        }
    }
}

根據你框架的功能,執行此任務可能便宜也可能不會便宜。遍歷全部的願望可能意味着進行過多的查詢,甚至更糟的是,獲取太多的行,這將對內存產生巨大影響。事實上,這是大聚合的主要問題之一。所以,讓咱們考慮如何刪除願望(Wish):

class RemoveWishService extends WishAggregateService
{
    public function execute(RemoveWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $user->removeWish($wishId);
    }
}

正如前面看到的,WishRepository 再也不須要。咱們使用 User 倉儲來獲取它,並執行刪除願望(Wish)的操做。爲了刪除一個 Wish。咱們須要從內部集合中刪除它。一種選擇是遍歷全部元素,並將其與相同的 WishId 匹配:

class User
{
// ...
    public function removeWish(WishId $wishId)
    {
        foreach ($this->wishes as $k => $wish) {
            if ($wish->id()->equals($wishId)) {
                unset($this->wishes[$k]);
                break;
            }
        }
    }
// ...
}

那多是最不瞭解 ORM 的代碼。可是,在場景背後,Doctrine 正在獲取全部 Wish 並遍歷全部。僅獲取不是 ORM 不可知的所需實體的一種更具體的方法以下:Doctrine 映射也必須更新,以使全部魔術工做都按預期進行。雖然 Wish 映射保持不變,但 User 映射具備新的 oneToMany 單向關係:

Lw\Domain\Model\Wish\Wish:
  type: entity
  table: lw_wish
  repositoryClass: Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
  id:
    wishId:
      column: id
      type: WishId
  fields:
    address:
      type: string
    content:
      type: text
    userId:
      type: UserId
    column: user_id

Lw\Domain\Model\User\User:
  type: entity
  id:
    userId:
      column: id
      type: UserId
  table: user
  repositoryClass: Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
  fields:
    email:
      type: string
    password:
      type: string
  manyToMany:
    wishes:
      orphanRemoval: true
      cascade: ["all"]
      targetEntity: Lw\Domain\Model\Wish\Wish
    joinTable:
      name: user_wishes
      joinColumns:
        user_id:
          referencedColumnName: id
      inverseJoinColumns:
        wish_id:
          referencedColumnName: id
          unique: true

在上述代碼中,有兩個重要的配置:orphanRemoval 和 cascade。依據 Doctrine 2 ORM Documentation 關於 orphan removal 和 transitive persistence / cascade operations:

若是類型 A 實體包含對私有實體 B 的引用,則若是從 A 到 B 的引用被刪除,則實體 B 也應被刪除,由於再也不使用它。 OrphanRemoval 與一對一,一對多以及多對多關係一塊兒使用。當使用 orphanRemoval=true 選項時,Doctrine 會假設實體是私有的,不會被其餘實體重用。若是你忽略這一假設,則即便你將孤立的實體分配給另外一個實體,你的實體也會被 Doctrine 刪除。持久化,刪除,分離,刷新和合並單個實體可能變得很是麻煩,尤爲是在涉及到高度交織的對象圖時,所以,Doctrine 2 經過級聯這些操做提供了傳遞傳遞持久化的機制。與另外一個實體或實體集合的每一個關聯均可以配置爲自動級聯某些操做。默認狀況下,沒有級聯操做。

有關更多信息,請仔細閱讀有關使用 Doctrine 2 ORM 2 Documentation 關於使用關聯的部分。

最後,讓咱們看看怎樣從 User 獲取 Wish 的:

class ViewWishesService extends WishService
{
    /**
     * @return Wish[]
     */
    public function execute(ViewWishesRequest $request)
    {
        return $this
            ->findUserOrFail($request->userId())
            ->wishes();
    }
}

正如前面提到的,尤爲是在使用聚合的狀況下,返回 Wish 集合不是最佳解決方案。你永遠不要返回領域實體,由於這阻止應用服務以外的代碼(例如控制器或 UI)意外修改它們。使用聚合,這更有意義。不屬於根的實體(屬於集合但不屬於根的實體)應對外部的其餘人顯示爲私有。

咱們將在第 11 章,應用 對此進行更深刻的研究。總結一下,如今你有不一樣的選擇:

  • 應用服務返回訪問聚合信息的 DTO 構建塊。
  • 應用服務返回聚合返回的 DTO。
  • 應用服務使用一個寫入聚合的輸出依賴,這樣的輸出依賴將處理 DTO 或其餘格式的轉換。
渲染 Wish 的數量做爲練習,請考慮咱們要渲染用戶在其帳戶頁面上的 Wish 的數量。考慮到 User 和 Wish 不會造成聚合,你將如何實現這一目標?若是 User 和 Wish 確實造成了聚合,你將如何實施?考慮最終一致性如何爲你的解決方案提供幫助。

事務

在任何示例中,咱們都沒有展現 beginTransaction,commit 或者 rollback。這是由於事務是在應用服務級別處理的。如今不用擔憂;你將在第 11 章,應用中找到有關此內容的更多詳細信息。

小結

聚合都是關於持久化和事務的。事實上,你必須在不考慮如何持久化的狀況下設計聚合。設計合適的聚合的基本規則是:使它們變小,找到真正的業務不變量,使用 Domain Event 推進最終一致性,按標識引用其餘聚合,以及每一個請求只修改一個聚合。審查兩個實體造成單個聚合時代碼會變成怎樣。使用工廠來豐富你的實體。最後,放鬆一下。在咱們看到的大多數 PHP 應用程序中,只有 5% 的實體是由兩個或更多實體組成的集合。在設計和實現聚合時與你的同事討論。

相關文章
相關標籤/搜索