經過使用 self
關鍵字,咱們不會將值對象做爲領域驅動設計的基本構建塊,在代碼中它們用於對通用語言概念進行建模。值對象不只僅是領域中衡量,量化或者描述事物的東西。值對象能夠被視爲小而簡單的對象 - 例如金錢或者日期範圍 - 它們的相等性不是經過其標識,而是基於其持有的內容體現的。php
例如,產品價格能夠用值對象建模。在這種狀況下,它不表明一個東西,而是一個值,能夠用於衡量產品的價值。這些對象的內存佔用是微不足道的,沒法肯定(經過它們的組成部分計算)且開銷極小。所以,即便表示相同的值 ,建立新實例也優於引用重用,而後再根據兩個實例的字段可比性來檢查是否相等。mysql
Ward Cunningham 定義值對象以下:git
可衡量或者可描述的事物。值對象的例子如數值,日期,貨幣和字符串。一般,它們是一些使用至關普遍的小對象。它們的標識 是基於他們的狀態而不是他們的對象標識。這樣,你能夠擁有同一律念值對象的多個副本。每一個5美圓的鈔票都有自身的標識(多虧了它的序列號),但現金經濟依賴於每5美圓與其它5美圓有相同的價值。
Martin Fowler 定義值對象以下:github
一個小對象,例如貨幣或者日期範圍對象。它們的關鍵屬性是聽從值語義而不是引用語義。你一般能夠說它們概念的等同不是基於它們的標識,而是兩個值對象它們全部字段是否相等。儘管全部字段相等,但若是子集是惟一的,你也不須要比較全部字段 - 例如幣種代碼對於貨幣對象來講足以說明它的相等性。一般的準則的值對象應該是徹底不可變的。若是你想改變一個值對象,應該用一個新的對象來替換它而不容許用值對象自己來更新值 - 值對象的更新會引發混淆問題。
值對象的例子有數值,文本字符,日期,時間,一我的的全名(由姓,中間名,名字,爵位等組成),貨幣,顏色,電話號碼,郵箱地址。sql
考慮下面來自維基百科的例子,以便更好的理解值對象與實體的不一樣點:shell
貨幣和金錢值對象多是解釋值對象最有用的例子了,多虧了金錢模式。這種設計模式提供了一種模型化問題的方法,來避免浮點舍入問題,這又過來又容許執行肯定性運算。數據庫
在現實世界中,貨幣用米和碼的描述距離單位相同的方式來描述貨幣單位。每種貨幣用三個大寫字母的 ISO 代碼來表示:json
class Currency { private $isoCode; public function __construct($anIsoCode) { $this->setIsoCode($anIsoCode); } private function setIsoCode($anIsoCode) { if (!preg_match('/^[A-Z]{3}$/', $anIsoCode)) { throw new InvalidArgumentException(); } $this->isoCode = $anIsoCode; } public function isoCode() { return $this->isoCode; } }
值對象的主要目標之一也是面向對象設計的聖盃:封裝。經過遵循此模式,你將最終獲取一個專用位置,以便將全部驗證,比較邏輯和行爲都放在一塊兒。設計模式
貨幣的擴展驗證器
在以前的代碼示例中,咱們能夠用相似 AAA 的 ISO 代碼來構建一個貨幣類。對於須要編寫一個檢查是否合法的 ISO 代碼的具體規則來講,這沒起什麼做用。這裏有一個完整的 ISO 貨幣代碼類清單。若是你須要幫助,就查看一下 Money packagist 庫。
金錢則用來衡量一個具體的貨幣數量。它模型由金額和貨幣構成。在金錢模式的狀況下,金額都是由貨幣最不值錢的分數來表示實現的 - 例如,在美圓,歐元,美分的狀況下。數組
另外,你可能注意到咱們使用自封裝來設置 ISO 代碼,這使值對象自己的更改集中化了。
class Money { private $amount; private $currency; public function __construct($anAmount, Currency $aCurrency) { $this->setAmount($anAmount); $this->setCurrency($aCurrency); } private function setAmount($anAmount) { $this->amount = (int)$anAmount; } private function setCurrency(Currency $aCurrency) { $this->currency = $aCurrency; } public function amount() { return $this->amount; } public function currency() { return $this->currency; } }
如今你知道了值對象的正式定義了,讓咱們更深刻地瞭解它們提供的最大功能吧。
當用代碼模型化一個通用語言概念時,你應該老是傾向於在實體上使用值對象。值對象問題更容易建立,測試,使用和管理。
正如以前討論的,一個值對象不該該被視你領域中的一個事物。做爲值,它能夠衡量,量化,描述領域內的概念。
在咱們的例子裏,貨幣對象描述了金錢是什麼類型。金錢對象衡量或者量化了一個給定的貨幣單位。
這是須要要掌握的最重要的方面之一。值對象不該該在他們的生命周內改變。因爲這種永久性,值對象易於推導和測試以及沒有不受歡迎/意外的反作用。所以,值對象應該由他們的構造器建立。爲了生成一個值對象,你一般經過構造函數傳遞必要原生類型或者其它值對象。
值對象老是處於有效狀態;這就是爲何咱們在一個原子步驟中建立它們。具備多個 getter
和 setter
方法的空構造函數將建立責任轉移到客戶端,從而致使了貧血模型,這被認爲是一種反模式。
還須要指出的是,咱們不建議在值對象中保留對實體的引用。實體是可變的,而且保留對它們的引用可能致使在值對象中發生一些不可取的反作用。
在具備方法重載的語言(例如Java)中,你能夠用同一個名稱建立多個構造函數 。每一個構造函數都提供不一樣的選項來生成相同類型的對象。在 PHP 裏,咱們能夠經過工廠方法來提供相似的能力。這些 特定的工廠方法也被稱爲構造語義。fromMoney
的主要目標是提供比普通構造函數更多的上下文意義。更激進的方法建議將 __construct
方法私有化, 並使用語義構造函數構建每一個實例。
在咱們的 Money
對象裏,咱們能夠添加以下一些有用的工廠方法:
class Money { // ... public static function fromMoney(Money $aMoney) { return new self( $aMoney->amount(), $aMoney->currency() ); } public static function ofCurrency(Currency $aCurrency) { return new self(0, $aCurrency); } }
經過使用 self
關鍵字,咱們沒必要用類名來耦合代碼。所以,類名或者命名空間的改變不會影響到工廠方法。這個小細節實如今之後重構代碼會起到幫助。
static
vs.self
當一個值對象繼承另外一個值對象時,在 self 上 使用 static 將致使不可預期的問題。
因爲這種不變性,咱們必須考慮如何在有狀態上下文的常見位置處理易變操做。若是咱們須要一個狀態變化,則須要用這個變化來返回一個全新的值對象表述。若是咱們要增長金額,例如,一個金錢值對象,則經過所需改動來返回一個新的 Money
實例。
幸運的是,遵循此規則相對簡單,以下面的例子所示:
class Money { // ... public function increaseAmountBy($anAmount) { return new self( $this->amount() + $anAmount, $this->currency() ); } }
increaseAmountBy
返回的 Money
對象與接收方法調用的 Money
客戶端對象不一樣。在下面的示例可比性檢查中能夠觀察到這一點:
$aMoney = new Money(100, new Currency('USD')); $otherMoney = $aMoney->increaseAmountBy(100); var_dump($aMoney === otherMoney); // bool(false) $aMoney = $aMoney->increaseAmountBy(100); var_dump($aMoney === $otherMoney); // bool(false)
那麼爲何不照下面的例子實現,同時還能夠避免實例化一個新的對象呢?
class Product { private $id; private $name; /** * @var int */ private $amount; /** * @var string */ private $currency; // ... }
這種方法有一些明顯的缺陷,要說的話,例如你想要驗證 ISO. 但對 Product
來講,驗證 ISO 的責任是沒有意義的(從而違反了單一職責原則)。若是你想在其它領域重用這一部分代碼的話,這一點將會更加突出(遵循 DRY 原則)。
考慮到這些因素,這個用例是一個被抽象成值對象的完美候選項,使用此抽象不只讓你有機會將相關屬性組合在一塊兒,還將同時讓你建立更高階的概念和更具體的通用語言。
練習
與你的小夥伴一塊兒討論,一個郵件是否能夠被考慮爲一個值對象?它使用的上下文會對此有影響嗎?
正如本章開頭所討論的,若是兩個值對象的衡量,量化,或描述是相同的,那麼它們就是相等的。
例如,想象兩個表明 1 美圓的 Money
對象。咱們能夠說它們是相等的嗎?在真實世界裏,兩個 1 美圓的硬幣價值是相同的嗎?固然是。咱們回過頭來看代碼,問題中的值對象是指不一樣的 Money
實例。然而,它們都表示相同的值,這使得它們相等。
在 PHP 裏,使用 ==
來比較兩個值對象是司空見慣的。查看 PHP Documentation 裏這個運算符的定義突出了一個更有趣的行爲:
當使用比較運算符
==
時,比較對象的值是一種簡單的方式:若是兩個對象實例有相同的屬性和值,以及是同一個類的實例,那麼他們是相等的。
這個行爲與咱們對值對象的正式定義一致。然而,做爲一個精確的類匹配謂詞,在處理子類型的值對象時你應該當心謹慎。
牢記這一點,更嚴格的 ===
運算符也沒有幫到咱們,不幸的是:
當使用運算標識符
===
,兩個值對象相等,僅僅在它們是指同一個類的同一個實例的狀況時。
下面的例子能夠幫助驗證這些微妙不一樣之處:
$a = new Currency('USD'); $b = new Currency('USD'); var_dump($a == $b); // bool(true) var_dump($a === $b); // bool(false) $c = new Currency('EUR'); var_dump($a == $c); // bool(false) var_dump($a === $c); // bool(false)
一個解決辦法是,在每一個值對象裏實現一個常規的相等斷定方法。這個方法負責檢查它們的複合屬性的類型和相等性。抽象數據類型的比較,用 PHP 內置的類型提示實現是很是容易的。必要的話你也可使用 get_class()
函數來幫助你檢查比較。
然而,語言並不能詮釋你的領域概念中相等的真正意義,也意味着你必須提供答案。爲了比較 Currency
對象,咱們僅須要確認它們關聯的 ISO 代碼是相同的。===
運算符在下面的案例中完美體現:
class Currency { // ... public function equals(Currency $currency) { return $currency->isoCode() === $this->isoCode(); } }
由於 Money 對象使用了 Currency
對象,equals
方法須要將連同金額的比較一塊兒執行。
class Money { // ... public function equals(Money $money) { return $money->currency()->equals($this->currency()) && $money->amount() === $this->amount(); } }
考慮一個 Product
實體,其包含了一個 Money
值對象用來衡量它們的價值。此外,考慮兩個徹底同樣的 Product
實體 - 例如100美圓。此方案可使用兩個單獨的 Money
對象或者兩個指向單個值對象的引用來建模。
共享相同的值對象可能會有風險。若是一個改變了,二者都會發生變化。這種行爲可視爲異常的反作用。例如,若是 Carlos 在 2 月 20 日被錄用,並且咱們知道 Christian 在同一天錄用,咱們可能將 Christian 的錄用日期設置成與 Carlos 的一致。那麼只要 Carlos 以後將他的錄用改爲 5 月,Charistian 的錄用日期也會改變。無論對錯與否,這都不是人們所期待的。
因爲此例中凸顯的問題,當持有一個值對象的引用時,建議將其總體替換,而不是改變其值:
$this−>price = new Money(100, new Currency('USD')); //... $this->price = $this->price->increaseAmountBy(200);
這種行爲與 PHP 中的基本類型(如字符串)的工做方式相似。考慮函數 strtolower
. 它返回一個新的字符串而不是修改原值。不使用引用,而是返回一個新的值。
咱們若是想在 Money 類中引入一些額外的行爲,好比 add
方法,那麼檢查輸入是否符合任何先決條件並保持不變性是很天然的。在咱們的例子中,咱們僅僅但願用一樣的貨幣增長金錢:
class Money { // ... public function add(Money $money) { if ($money->currency() !== $this->currency()) { throw new InvalidArgumentException(); } $this->amount += $money->amount(); } }
若是兩次貨幣不匹配,就會拋出異常。反之金額就會增長。然而,此段代碼仍有一些不可取的缺陷。如今想象一下,在咱們的代碼中有一個神祕的方法 otherMethod
:
class Banking { public function doSomething() { $aMoney = new Money(100, new Currency('USD')); $this->otherMethod($aMoney);//mysterious call // ... } }
一切看起來都很好直到某些緣由,當咱們返回或完成 otherMethod
時,咱們開始看到了意想不到的結果。忽然,$aMoney
再也不包含 100 美圓。發生了什麼?若是 otherMethod
方法內部使用了咱們以前定義的 add
方法又會怎麼樣?也許你不明白是添加了變異的 Currency 實例狀態。這就是咱們所說的反作用。你必須避免產生反作用。你不能讓你的論據變異。若是你這樣作,開發者使用你的對象可能會遇到一些奇怪的行爲。他們就會抱怨,而且他們會是正確的。那麼咱們應該怎樣解決這個問題?簡單來講,經過確保值對象保持不變,咱們就能避免此類異常問題。一個簡單的辦法就是爲每一個可變操做返回一個新的實例,正以下面 add
方法:
class Money { // ... public function add(Money $money) { if (!$money->currency()->equals($this->currency())) { throw new \InvalidArgumentException(); } return new self( $money->amount() + $this->amount(), $this->currency() ); } }
有了這種簡單的改動,就保證了不變性。每次兩個 Money
實例相加時,將會返回一個新的結果實例。其它類能夠在不影響原始副本的狀況下執行任意數量的改變。無反作用的代碼易於理解,便於測試,難以出錯。
考慮下面的代碼片段:
$a = 10; $b = 10; var_dump($a == $b); // bool(true) var_dump($a === $b); // bool(true) $a = 20; var_dump($a); // integer(20) $a = $a + 30; var_dump($a); // integer(50);
儘管 $a
和 $b
是不一樣的變量,存儲在不一樣內存位置,但在比較它們時,它們是相同的。它們有相同的值,因此咱們認爲它們是相等的。你能夠在任什麼時候刻將 $a
的值從 10 改變成 20,你能夠在不考慮上一個值的狀況下儘量多地替換整數值,由於你根本沒有修改它;你只是在替換它。若是對這些變量應用任何操做(例如 $a + $b
),則能夠得到另外一個可分配給另外一個或以前定義的變量的值。當你將 $a
傳給另外一個函數,除非經過引用顯式傳遞,不然你傳遞的就是值。$a
在此函數中的改變與否也無緊要,由於在當前的代碼中,你仍然擁有原始副本。值對象的行爲就是基本類型。
值對象的測試方式與正常對象相同。然而,不變性及無反作用行爲也必須測試。解決方案就是在執行任何改動前建立要測試的值對象副本。斷言都是相等的,使用實現的相等性檢查。執行要測試的操做並斷言結果。最後,斷言原始對象和副本仍然相等。
讓咱們把這個付諸實踐,並在 Money 類中測試咱們實現的無反作用 add
方法:
class MoneyTest extends FrameworkTestCase { /** * @test */ public function copiedMoneyShouldRepresentSameValue() { $aMoney = new Money(100, new Currency('USD')); $copiedMoney = Money::fromMoney($aMoney); $this->assertTrue($aMoney->equals($copiedMoney)); } /** * @test */ public function originalMoneyShouldNotBeModifiedOnAddition() { $aMoney = new Money(100, new Currency('USD')); $aMoney->add(new Money(20, new Currency('USD'))); $this->assertEquals(100, $aMoney->amount()); } /** * @test */ public function moniesShouldBeAdded() { $aMoney = new Money(100, new Currency('USD')); $newMoney = $aMoney->add(new Money(20, new Currency('USD'))); $this->assertEquals(120, $newMoney->amount()); } // ... }
值對象自身並不持久化;它們一般用聚合來持久化。值對象不該做爲一條完整記錄保存,儘管在某些狀況下能夠這樣作。值對象最好以嵌入值或者序列化的 LOB 模式存儲。當你開源的 ORM 例如 Doctrine
或者定製的 ORM 來存儲你的對象時,這兩種模式均可以使用。因爲值對象很小,嵌入值一般是最好的選擇,由於它提供一個簡單的途徑來查詢實體,即經過值對象裏的任意屬性。不過,假如用這些字段來查詢對你並不重要,那麼用序列化策略來持久化將很是容易實現。
考慮下面的 Product
實體,有字符串 id
, name
和 price
(Money 值對象)這些屬性。 咱們有意簡單化實現這些例子,因此用一個字符 id
而不是值對象:
class Product { private $productId; private $name; private $price; public function __construct( $aProductId, $aName, Money $aPrice ) { $this->setProductId($aProductId); $this->setName($aName); $this->setPrice($aPrice); } // ... }
假設你已經學過第 10 章,以 倉儲 來持久 Product
實體,能夠用以下方式來實現建立和持久一個新的 Product
:
$product = new Product( $productRepository->nextIdentity(), 'Domain-Driven Design in PHP', new Money(999, new Currency('USD')) ); $productRepository−>persist(product);
如今咱們看到定製的 ORM 和 Doctrine 的實現均可以用來持久化包含值對象的 Product
實體。咱們將突出嵌入值和序列化 LOB 模式的應用,以及持久化單個值對象和集合之間的不一樣。
爲何用 Doctrine ?Doctrine 是一個強大的 ORM。它解決 80% 以上 了 PHP 應用要面對的問題。同時它有一個強大的社區。只要正確合適的配置好,它能夠產生與定製的 ORM 相同甚至更好的效果(同時不丟失可維護性)。咱們推薦在大多數場景下使用 Doctrine 來處理實體和業務邏輯。它能夠幫你節省大量時間和腦細胞。
持久化單個值對象有許多方法,從用序列化 LOB 或者嵌入式值做爲映射策略,到使用定製 ORM 或者開源方法,好比 Doctrine。咱們考慮到爲了持久化實體到數據庫,大家公司能夠已經開發了自建的定製 ORM。在咱們的方案中,定製的 ORM 應該用 DBAL
庫來實現。根據官方文檔,DBAL 即 Doctrine 數據庫抽象層以及訪問層(The Doctrine Database Abstraction & Access Layer)提供了一個相似 PDO API 的輕量且薄的運行時層次以及許多附加的,水平的功能,例如經過一個面對對象型的 API 來處理數據庫架構及一些操做。
若是咱們要用一個實現嵌入值模式的定製 ORM,則須要爲每一個值對象裏的屬性在實體表中建立一個字段。在這種狀況下,持久久一個 Product
實體須要兩個擴展欄位:一個是值對象的金額,一個是它自身的貨幣 ISO 代碼。
CREATE TABLE `products` ( id INT NOT NULL, name VARCHAR( 255) NOT NULL, price_amount INT NOT NULL, price_currency VARCHAR( 3) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
對於持久化對象到數據庫,咱們第 10 章,倉儲必須映射實體中的每一個字段以及 Money
值對象中的每一個屬性。
若是你使用一個基於 DBAL 定製的 ORM 倉儲(讓咱們稱之爲 DbalProductRepository
),你必須當心地建立 INSERT
語句,構建參數,以及執行語句:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function add(Product $aProduct) { $sql = 'INSERT INTO products VALUES (?, ?, ?, ?)'; $stmt = $this->connection()->prepare($sql); $stmt->bindValue(1, $aProduct->id()); $stmt->bindValue(2, $aProduct->name()); $stmt->bindValue(3, $aProduct->price()->amount()); $stmt->bindValue(4, $aProduct ->price()->currency()->isoCode()); $stmt->execute(); // ... } }
在執行這個代碼片段後,建立了一個 Product
實體,同時將其保存到數據庫,表中的每一列都顯示了期待的結果:
mysql> select * from products \G *************************** 1. row *************************** id: 1 name: Domain-Driven Design in PHP price_amount: 999 price_currency: USD 1 row in set (0.00 sec)
正如你所見,你能夠經過定製方法映射值對象和查詢參數,來持久化值對象。然而,一切並不像看起來那麼簡單。讓咱們嘗試用 Product
關聯的 Money
值對象來獲取它。一般的方法是執行一個 SELECT
語句同時返回一個新實體:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function productOfId($anId) { $sql = 'SELECT * FROM products WHERE id = ?'; $stmt = $this->connection()->prepare($sql); $stmt->bindValue(1, $anId); $res = $stmt->execute(); // ... return new Product( $row['id'], $row['name'], new Money( $row['price_amount'], new Currency($row['price_currency']) ) ); } }
這種方法有幾個好處。首先,你能夠輕鬆地,逐步看懂持久化及其後續創做的發生過程。其次,你能夠基於值對象裏任意的屬性來查詢。最後,持久化實體所需的空間正如咱們所需的,很少也很多。
然而,使用定製的 ORM 方式也有它的缺點,正如第 6 章,領域事件中所解釋的,若是你的領域模型對聚合的建立過程感興趣,實體(以聚合形式)應在構造函數中觸發一個事件。若是你使用 new
運算符,則會在數據庫中查出聚合時屢次觸發該事件。
這就是爲何 Doctrine 使用內部的代理和序列化以及反序列方法在不使用構造函數的狀況下用對象屬性的特定狀態來重建它。一個實體應該僅在其生命週期內使用 new
運算符建立。
構造函數構造函數並不須要爲對象的每一個屬性聲明參數。設想一個博客帖子,構造函數可能須要一個
id
和title
;然而,它內部能夠將其狀態屬性設置爲草稿。當發佈帖子時,爲了將其修改成發佈狀態,則須要調用一個發佈方法。
若是你仍然意圖推出你本身的 ORM,則要準備好解決一些基礎性問題,例如事件,不一樣的構造函數,值對象,惰性加載關係,等等。這就是咱們爲何推薦在領域驅動設計應用中給 Doctrine 一個機會。
除此以外,在本實例中,你須要建立一個繼承於 Product
的 DbalProduct
實體,爲了在不使用 new
運算符的狀況下,使用一個靜態工廠方法從數據庫中重建實體。
最新的 Doctrine 發行版本爲 2.5,同時它支持值對象映射。從而消除了你須要在 2.4 版本中手動完成這些工做。從 2015 年 12 月開始,Doctrine也有了對嵌套值的支持。儘管支持度沒有 100%,但也很是值得嘗試。萬一它對你的場景不適用,則查看下一節。對於官方文檔,則查看 Doctrine Embeddables reference
一節。若是正確實現了這一項,那絕對是咱們最推薦的。這將是最簡單,最優雅的解決方案,同時它經過 DQL
查詢語言提供了搜索功能。
由於 Product
, Money
, 以及 Currency
類已經展現過了,惟一剩下的就是展現 Doctrine 映射文件:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="Product" table="product"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <embedded name="price" class="Ddd\Domain\Model\Money" /> </entity> </doctrine-mapping>
在 Product
映射裏,咱們定義 Price
爲一個持有 Money
對象的實例。同時,Money
被設計爲擁有金額和 Currency
的實例:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <embeddable name="Ddd\Domain\Model\Money"> <field name="amount" type="integer" /> <embedded name="currency" class="Ddd\Domain\Model\Currency" /> </embeddable> </doctrine-mapping>
最後,是時候展現咱們的 Currency
值對象的 Doctrine 映射了:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <embeddable name="Ddd\Domain\Model\Currency"> <field name="iso" type="string" length="3" /> </embeddable> </doctrine-mapping>
正如你所見,上述代碼有一個標準的嵌套定義,經過一個持有 ISO 代碼的字符串類型字段。這種方法是使用嵌套的最簡單的途徑,甚至更高效。默認狀況下,Doctrine 經過在值對象名字前加前綴的方式來命名你的欄位。你能夠經過在 XML 註釋中改變欄位前綴屬性來改變這些行爲,以達到你所需。
若是你還停留在 Doctrine 2.4裏,你可能想知道小於 2.5 版本時,使用嵌套值的可接受方案是什麼。如今,咱們須要代理 Product
實體中的全部值對象屬性,這意味着將建立出擁有值對象信息的新屬性。有了這個,咱們能夠用 Doctrine 映射全部這些新的屬性。讓咱們來看看這對 Product
實體有什麼影響:
<?php class Product { private $productId; private $name; private $price; private $surrogateCurrencyIsoCode; private $surrogateAmount; public function __construct($aProductId, $aName, Money $aPrice) { $this->setProductId($aProductId); $this->setName($aName); $this->setPrice($aPrice); } private function setPrice(Money $aMoney) { $this->price = $aMoney; $this->surrogateAmount = $aMoney->amount(); $this->surrogateCurrencyIsoCode = $aMoney->currency()->isoCode(); } private function price() { if (null === $this->price) { $this->price = new Money( $this->surrogateAmount, new Currency($this->surrogateCurrency) ); } return $this->price; } // ... }
正如你所見,這有兩個新的屬性:一個是 amount,一個是 currency 的 IOS 代碼。咱們已經更新了 setPrice
方法,以便在設置它時保持屬性一致。在上面,咱們咱們更新了 price 的 getter
方法,以便返回重新字段中生成的 Money
值對象。讓咱們看看相應的 Doctrince XML 映射文件應該怎樣改動:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="Product" table="product"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <field name="surrogateAmount" type="integer" column="price_amount" /> <field name="surrogateCurrencyIsoCode" type="string" column="price_currency" /> </entity> </doctrine-mapping>
代理屬性嚴格來講這兩個新字段不屬於此領域模型,由於它們不引用基礎設施的具體信息。相反,因爲在
Doctrine
中缺乏嵌套值的支持,它們必不可少。還有一些替代方法能夠把這兩個屬性置於領域以外;然而,這種方法是最簡單,容易,以及做爲一種權衡,是最能接受的。另外此書還有一處使用代理屬性的例子;你能夠在第 4 章,實體,標識操做的子部分代理標識一節中找到.
若是咱們想將這兩個屬性放到領域以外,這能夠經過使用一個抽象工廠來達到。首先,咱們須要在咱們的基礎設施文件夾裏建立一個新的實體,DoctrineProduct
。它繼承於 Product
實體。全部的代理字段都放在這個新的類中,以及例如 price
或者 setPrice
這些方法須要從新實現。咱們將用 DoctrineProduct
來映射到 Doctrine
而不是 Product
實體。
如今,咱們能夠從數據庫中檢索出實體了,但怎樣新建一個 Product
呢?在某些時候,咱們須要調用一個新的 Product
,但因爲咱們須要用 DoctrineProduct
來處理,同時又不想對應用服務暴露具體的基礎設施,咱們將須要使用工廠來建立 Product
實體。所以,在每一個實體構造新實例的地方,你須要在 ProductFactory
中調用 createProuct
來代替。
這會產生許多附加的類,以免代理屬性污染源實體。因此,咱們推薦用同一實體來代理全部值對象,儘管這無疑會致使一個不太純的解決方案。
若是增長值對象的搜索能力並不重要,則能夠考慮另外一種模式:序列化 LOB。這種模式經過將整個值對象序列化爲一個字符串格式,以便容易存儲和檢索。這種方案與嵌套方案最大的區別在於,後一種選項裏,持久化足跡要求減小到單列:
CREATE TABLE ` products` ( id INT NOT NULL, name VARCHAR( 255) NOT NULL, price TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
爲了使用這種方法來持久化 Product
實體,須要對 DbalProductRepository
作一個改動。在持久化 final
實體前,Money` 值對象須要序列化爲一個字符串:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function add(Product $aProduct) { $sql = 'INSERT INTO products VALUES (?, ?, ?)'; $stmt = $this->connection()->prepare(sql); $stmt->bindValue(1, aProduct−> id()); $stmt->bindValue(2, aProduct−> name()); $stmt->bindValue(3, $this−> serialize($aProduct->price())); // ... } private function serialize($object) { return serialize($object); } }
如今讓咱們看看 Product
在數據庫是如何呈現的。表的 price
列是一個 TEXT 類型的列,它含有一個序列的 Money
對象表明 9.99 USD:
mysql > select * from products \G *************************** 1.row*************************** id : 1 name : Domain-Driven Design in PHP price : O:22:"Ddd\Domain\Model\Money":2:{s:30:"Ddd\Domain\Model\\ Money amount";i : 999;s:32:"Ddd\Domain\Model\Money currency";O : 25:"Ddd\Domain\Model\\ Currency":1:{\ s:34:" Ddd\Domain\Model\Currency isoCode";s:3:"USD";}}1 row in set(\ 0.00 sec)
這就是這種方法所作的。不過,因爲重構這些類時發生的一些問題,咱們並不推薦這種作法。你能想象這些問題嗎?若是咱們決定重命名 Money
類?當你把 Money
類從一個命名空間移動到另外一個時,須要數據庫層面如何呈現?另外一種權衡,前面已經解釋過,就是弱化查詢能力。它與你是否使用 Doctrine 無關;在使用序列化策略時,編寫一個查詢語句使 products 更實惠,比方說,200 USD 是幾乎不可能的。
這種查詢問題只能用嵌套值的方式解決。不過,序列化重構問題能夠用特定的處理序列化過程的庫來解決。
PHP 原生的序列/反序列化策略有一個問題,就是重構命名空間和類。一個替代方法就是使用你本身的序列化機制 -- 例如,用一個字符分割好比 "|" 來串聯金額和貨幣 ISO 代碼。不過,這還有另外一種更受歡迎的方法:使用一個開源的序列化庫,例如 JSM Serializer. 讓咱們看看一個應用它來序列化 Money
對象的例子:
$myMoney = new Money(999, new Currency('USD')); $serializer = JMS\Serializer\SerializerBuilder::create()->build(); $jsonData = $serializer−>serialize(myMoney, 'json');
反序列化對象,過程也是很簡單的:
$serializer = JMS\Serializer\SerializerBuilder::create()->build(); // ... $myMoney = $serializer−>deserialize(jsonData, 'Ddd', 'json');
經過這個例子,你能夠在沒必要更新數據庫的前提下重構你的 Money
類。JMS Serializer 能夠在多種不一樣場景下使用 -- 例如,當使用 REST API 時。其中一個重要的特性就是能夠在序列化過程當中指定對象中應該省略的屬性 -- 例如密碼。
查閱 Mapping Reference 和 Cookbook 以獲取更多資料。JMS Serializer 在任何領域驅動設計項目中是必需的。
在 Doctrine
中,有許多不一樣的方法來序列化對象,以便最終持久化。
Doctrine
對象映射類型Doctrine
支持序列化 LOB 模式。這裏有大量預約義的映射類型,以便將實體屬性匹配到數據庫欄位甚至表中。其中的一種映射類型就是對象類型,它能夠將 SQL CLOB
映射到 PHP 的 serializer()
和 unserialize()
上。
按照 Doctrince DBAL 2, Documentation中的描述:
null
值,若是沒有數據的話。Doctrine
沒法正確地使用那些不支持欄目註釋的數據庫產品來正確地返回值對象類型,而是直接返回文本類型來代替。因爲 PostgreSQL
內置的文本類型不支持 null
字節,對象類型將致使反序列化錯誤。此問題的一個解決辦法就是使用 serialize()
/unserialize()
和 base64_encode()
/bease64_decode()
來處理 PHP 對象,手動將它們存儲到數據庫。
讓咱們看一種可能的使用對象類型的 Product
實體的 XML 映射:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="Product" table="products"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <field name="price" type="object" /> </entity> </doctrine-mapping>
增長的關鍵是 type="object"
,它告訴 Doctrine 咱們將使用一個對象類型映射。接下來看看咱們是怎樣使用 Doctrine 來建立和持久化一個 Product
實體:
// ... $em−>persist($product); $em−>flush($product);
如今咱們檢查下,假如咱們從數據庫中查詢 Product
實體,它是否以期待的狀態返回:
// ... $repository = $em->getRepository('Ddd\\Domain\\Model\\Product'); $item = $repository->find(1); var_dump($item);
最後但不是最重要的,Doctrine DBAL 2 Documentation
聲明:
對象類型經過引用比較,而不是值。若是引用改變,Doctrine 就會更新值,所以這個行爲就像這些對象是不可變的值對象同樣。
這種方法面臨與定製 ORM 同樣的重構問題。對象映射類型在內部使用 serialize/unserialize
。那麼不如使用咱們本身的序列化方式?
另外一種方式就是使用 Doctrine 自定義類型來處理值對象的持久化。自定義類型增長一個新的映射類型到 Doctrine -- 描述了實體字段與數據庫表述間的自定義的轉換,以便持久化前者。
正如 Doctrine DBAL 2 文檔解釋:
僅僅重定義數據字段類型與 doctrine 已存在的類型間的映射是不夠用的。你能夠經過繼承 `DotrineDBALTypes
Type` 來定義你本身的類型。你須要實現 4 個不一樣的方法來完成這項工做。
因爲使用了對象類型,序列化步驟包含了諸如類的一些信息,使用它很是難以安全地重構咱們的代碼。
讓咱們試着優化咱們的解決方案。考慮一下自定義的序列化過程來解決這個問題。
好比其中一個方法就是把 Money
值對象做爲字符串持久到數據庫,編碼爲 amount|isoCode
格式:
use Ddd\Domain\Model\Currency; use Ddd\Domain\Model\Money; use Doctrine\DBAL\Types\TextType; use Doctrine\DBAL\Platforms\AbstractPlatform; class MoneyType extends TextType { const MONEY = 'money'; public function convertToPHPValue( $value, AbstractPlatform $platform ) { $value = parent::convertToPHPValue($value, $platform); $value = explode('|', $value); return new Money( $value[0], new Currency($value[1]) ); } public function convertToDatabaseValue( $value, AbstractPlatform $platform ) { return implode( '|', [ $value->amount(), $value->currency()->isoCode() ] ); } public function getName() { return self::MONEY; } }
你必須註冊全部的自定義類型才能使用 Doctrine 。它一般用一個 EntityMangerFactory
來集中建立 EntityManger
。
或者,你若是經過應用啓動執行這一步驟:
use Doctrine\DBAL\Types\Type; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\Setup; class EntityManagerFactory { public function build() { Type::addType( 'money', 'Ddd\Infrastructure\Persistence\Doctrine\Type\MoneyType' ); return EntityManager::create( [ 'driver' => 'pdo_mysql', 'user' => 'root', 'password' => '', 'dbname' => 'ddd', ], Setup::createXMLMetadataConfiguration( [__DIR__ . '/config'], true ) ); } }
接下來咱們須要在映射中指明咱們的自定義類型:
<?xml version = "1.0" encoding = "utf-8"?> <doctrine-mapping> <entity name = "Product" table = "product"> <!-- ... --> <field name = "price" type = "money" /> </entity> </doctrine-mapping>
爲何使用 XML 映射?多虧了 XML 映射文件頭部中的 XSH 語法驗證,許多集成開發環境(IDE)設置提供了自動完成功能,在映射定義中顯示全部元素和屬性。不過,在本書的其它部分,咱們使用 YAML 來展現不一樣的語法。
讓咱們檢驗數據庫,看看使用這種方法後價格是如何存儲的:
mysql> select * from products \G *************************** 1. row*************************** id: 1 name: Domain-Driven Design in PHP price: 999|USD 1 row in set (0.00 sec)
這種方法在未來的重構方面是一個改進。然而,搜索能力仍然受到列格式的限制。經過 Doctrine 自定義類型,你能夠稍微改善狀況,但它還不是構建 DQL 查詢的最佳選項。能夠參考 Doctrine Cumstom Mapping Types
獲取更多信息。
討論時間與夥伴思考和討論你是怎樣用 JMS 建立自定義類型來序列化和反序列化值對象的。
想象一下咱們如今想要添加一個價格集合到 Product
實體中。這些能夠表示產品生命週期或者不一樣幣種狀況的價格。這些能夠命名爲 HistoricalPrice
,以下:
class HistoricalProduct extends Product { /** * @var Money[] */ protected $prices; public function __construct( $aProductId, $aName, Money $aPrice, array $somePrices ) { parent::__construct($aProductId, $aName, $aPrice); $this->setPrices($somePrices); } private function setPrices(array $somePrices) { $this->prices = $somePrices; } public function prices() { return $this->prices; } }
HistoricalProduct
繼承自 Product
,因此它繼承了相同的行爲,以及價格集合的功能。
如前面部分所述,若是你不關心搜索能力,序列化是一個可行的方法。不過,在咱們明確知道要持久化多少價格,嵌入值方式是能夠的。可是假如咱們想持久化一個不肯定的歷史價格集合又會怎樣呢?
持久化值對象集合到一個列看起來是最簡單的解決方案。全部以前部分解釋過的關於單個值對象的的持久化均可以應用到這種狀況。經過 Doctrine 你可使用一個對象或者自定義類型 -- 同時考慮到一些注意事項:值對象應該很小,但若是你想持久化一個大型集合,必須確保數據庫引擎每行能支持的最大長度及每行支持的最大容量。
練習想出 Doctrine 對象類型和 Doctrine 自定義類型的實現策略,來持久化一個含有價格集合的
Product
。
若是你想經過一個實體關聯的值對象來持久化和查詢,你能夠選擇將值對象以實體形式存儲。就領域而言,這些對象仍然是值對象,但咱們須要賦值它們一個主鍵而且將它們與全部者,一個真正的實體以一對多/一對一的方式關聯起來。總而言之,你的 ORM 以實體形式處理值對象集合,而你的領域,仍然將它們視爲值對象。
聯表策略背後的主要思想是建立一個表來鏈接實體和它的值對象。讓咱們來看數據庫中的呈現:
CREATE TABLE ` historical_products` ( `id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL, `name` varchar( 255) COLLATE utf8mb4_unicode_ci NOT NULL, `price_amount` int( 11 ) NOT NULL, `price_currency` char( 3) COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
historical_products
表看起來與 product
一致。記住一點,HistoricalProduct
繼承 Product
實體一是爲了更容易的展現如何持久化一個集合。如今須要一個新的 prices
表來持久化全部 product
實體中不一樣的 Money
值對象:
CREATE TABLE `prices`( `id` int(11) NOT NULL AUTO_INCREMENT, `amount` int(11) NOT NULL, `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
最後,須要一個關聯 products
和 prices
的表:
CREATE TABLE `products_prices` ( `product_id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL, `price_id` int( 11 ) NOT NULL, PRIMARY KEY (`product_id`, `price_id`), UNIQUE KEY `UNIQ_62F8E673D614C7E7` (`price_id`), KEY `IDX_62F8E6734584665A` (`product_id`), CONSTRAINT `FK_62F8E6734584665A` FOREIGN KEY (`product_id`) REFERENCES `historical_products` (`id`), CONSTRAINT `FK_62F8E673D614C7E7` FOREIGN KEY (`price_id`) REFERENCES `prices`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Doctrine
要求全部數據庫實體都有一個惟一標識符。由於咱們想持久化 Money
值對象的話,就須要人爲地增一個標識,以便 Doctrine
可以處理。這裏有兩個選項:在 Money
值對象中包含這個代理標識,或者將它放置到一個擴展類中。
第一種方式的問題是,新的標識僅僅是由於數據庫持久層的須要。標識並非領域的一部分。
第二種方式的問題是,爲了不所謂的邊界泄漏,須要作大量的變更。用一個類擴展從任意領域對象中建立一個新的 Money
值對象實例,是不推薦的,由於它會破壞依賴倒置原則。解決方法是,再建立一個 Money
工廠,傳遞到應用服務和其它任何領域對象中。
在這種狀況下,咱們推薦使用第一種選項。讓咱們回顧在 Money
值對象中實現它須要作的改動:
class Money { private $amount; private $currency; private $surrogateId; private $surrogateCurrencyIsoCode; public function __construct($amount, Currency $currency) { $this->setAmount($amount); $this->setCurrency($currency); } private function setAmount($amount) { $this->amount = $amount; } private function setCurrency(Currency $currency) { $this->currency = $currency; $this->surrogateCurrencyIsoCode = $currency->isoCode(); } public function currency() { if (null === $this->currency) { $this->currency = new Currency( $this->surrogateCurrencyIsoCode ); } return $this->currency; } public function amount() { return $this->amount; } public function equals(Money $aMoney) { return $this->amount() === $aMoney->amount() && $this->currency()->equals($this->currency()); } }
正如上面看到的,增長了兩個屬性,第一個是 surrogateId
,它不是咱們領域所使用的,可是基礎設施須要它將值對象以實體形式持久化到咱們的數據庫中。第二個是 surrogateCurrencyIsoCode
,持有貨幣的 ISO
代碼。使用這些新的屬性,真的很是容易將咱們的值對象映射到 doctrine裏。
Money
的映射也是直截了當的:
<?xml version = "1.0" encoding = "utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="Ddd\Domain\Model\Money" table="prices"> <id name="surrogateId" type="integer" column="id"> <generator strategy="AUTO"> </generator> </id> <field name="amount" type="integer" column="amount" /> <field name="surrogateCurrencyIsoCode" type="string" column="currency" /> </entity> </doctrine-mapping>
使用 Doctrine,HistoricalProduct
實體將有如下映射:
<?xml version="1.0" encoding="utf-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://doctrine-project.org/schemas/orm/doctrine-mapping https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd"> <entity name="Ddd\Domain\Model\HistoricalProduct" table="historical_products" repository-class=" Ddd\Infrastructure\Domain\Model\DoctrineHistoricalProductRepository "> <many-to-many field="prices" target-entity="Ddd\Domain\Model\Money"> <cascade> <cascade-all/> </cascade> <join-table name="products_prices"> <join-columns> <join-column name="product_id" referenced-column-name="id" /> </join-columns> <inverse-join-columns> <join-column name="price_id" referenced-column-name="id" unique="true" /> </inverse-join-columns> </join-table> </many-to-many> </entity> </doctrine-mapping>
也能夠用定製 ORM 進行相同的處理,這須要用到層疊 INSERTS
和 JION
查詢。重要的是,爲了避免孤立 Money
值對象,怎樣當心地處理值對象的移除工做。
練習爲
DbalHisotircalRepository
能夠處理持久化方法提出一個解決方案。
數據庫實體與聯表是同一種方案,僅添加全部者實體管理的值對象。在當前方案中,考慮到 Money
值對象僅被 HistoricalProduct
實體使用,聯表將過於複雜。由於一樣的結果能夠經過一對多的數據庫關係來實現。
練習若是使用數據庫實體方法,考慮
HistoricalProduct
和Money
之間須要的映射。
若是用相似 Redis
, Mongodb
或者 CouchDB
的 NoSQL
機制會怎樣呢?不幸的是,你不能逃避這些問題,爲了持久化一個聚合而使用Redis,你須要在設置它的值前,用字符串來序列化。若是你使用 PHP 的 serialize/unserialize 方法,則須要再次面對命名空間或者類名重構問題。若是你選擇一條自定義實現道路(JSON, 自定義字符串等等),你將須要再次在 Redis 檢索期間重建值對象。
若是咱們的數據庫引擎不只容許咱們使用序列化 LOB 策略,同時能夠基於它們的值進行搜索,咱們將同時擁有最佳的方法。好消息:如今你能夠作到了。由於 PostGreSQL 9.4
版本,已經增長了對 JSONB 的支持。值對象能夠用 JSON 序列化來存儲,同時能夠在 JSON 序列中進行子查詢。
MySQL 一樣作到了。在 MySQL 5.7.8
版本里,MySQL 支持了一個原生的 JSON 數據類型,可以有效地訪問 JSON(JavaScript 對象表示法) 文檔中的數據。依照 MySQL 5.7 引用手冊
,JSON 數據類型提供了比以字符串形式存儲 JSON 格式方法更多的優點:
若是關係數據庫爲文檔和嵌套文檔搜索添加了高性能的支持, 而且具備原子性、一致性、隔離性、耐久性 (ACID) 哲學的全部好處, 那麼在許多項目中, 它能夠減小大量的複雜性。
另外一個有意思的細節是,使用值對象對領域概念進行建模時的安全性好處。考慮一個售賣航班機票應用的上下文。若是你處理國際航空運輸協會的機場代碼,也稱爲 IATA 代碼,你能夠決定使用字符串或者用值對象來建模概念。若是你選擇字符串方式,則要考慮到全部你須要檢驗一個字符串是否爲 IATA 代碼的地方。若是你可能在某個重要的地方忘記了怎麼辦?另外一方面,考慮嘗試實例化一個 IATA("BCN'; DROP TABLE users;--")
。若是你在構造函數裏集中守衛,而後將一個 IATA 值對象傳遞進去,則避免 SQL 注入或者相似的攻擊將更加容易。
若是你想知道領域驅動設計安全性方面的更多細節,你能夠關注 Dan Bergh Johnsson
或者讀他的博客。
強烈建議使用值對象對領域概念進行建模,如上所述,值對象易於建立,維護和測試。爲了在一個領域驅動設計中處理持久化問題,使用 ORM 是必須的。不過,爲了持久化值對象而使用 Doctrine,更優的選擇是使用 embeddables
。若是你困在 2.4 版本中,有兩個選擇:直接在你的實體中添加值對象字段並映射好它們(不那麼優雅,但更容易),或者擴展你的實體(更優化,但更復雜)。