咱們已經討論過優先將領域中全部內容做爲值對象進行建模的好處。但對領域建模時,可能會有一些場景,你會發如今通用語言裏的某些概念須要一根標識線。php
對象須要標識符的一些簡單例子:mysql
這些有標識的概念有長期存在的特性。不管概念中的數據發生多少次變化,它們的標識老是相同。這些使得它們成爲實體而不是值對象。就PHP實現而言,它們就是簡單的類。例如,考慮下面狀況下的一個 Person
類:git
namespace Ddd\Identity\Domain\Model; class Person { private $identificationNumber; private $firstName; private $lastName; public function __construct( $anIdentificationNumber, $aFirstName, $aLastName ) { $this->identificationNumber = $anIdentificationNumber; $this->firstName = $aFirstName; $this->lastName = $aLastName; } public function identificationNumber() { return $this->identificationNumber; } public function firstName() { return $this->firstName; } public function lastName() { return $this->lastName; } }
或者,考慮下面狀況的 Order
類:程序員
namespace Ddd\Billing\Domain\Model\Order; class Order { private $id; private $amount; private $firstName; private $lastName; public function __construct( $anId, Amount $amount, $aFirstName, $aLastName ) { $this->id = $anId; $this->amount = $amount; $this->firstName = $aFirstName; $this->lastName = $aLastName; } public function id() { return $this->id; } public function firstName() { return $this->firstName; } public function lastName() { return $this->lastName; } }
大多數時候,一個實體的標識表示爲一個基本類型 - 一般是一個字符串或者整型。但使用值對象來表示它有更多優點:github
讓咱們看看一個 OrderId
的可能實現,這裏 Order
標識進化成一個值對象:sql
namespace Ddd\Billing\Domain\Model; class OrderId { private $id; public function __construct($anId) { $this->id = $anId; } public function id() { return $this->id; } public function equalsTo(OrderId $anOrderId) { return $anOrderId->id === $this->id; } }
你能夠考慮一些不一樣的方式來實現 OrderId
。上面展現的例子至關簡單。正如第三章,值對象所述,你能夠把 __constructor
設爲私有同時使用一個靜態工廠方法來建立新的實例。與你的小組進行討論,體驗,並達成一致。由於實體標識不是複雜的值對象,咱們建議你對此不要有太多顧慮。shell
回到 Order
類,是時候更新引用到 OrderId
了:數據庫
class Order { private $id; private $amount; private $firstName; private $lastName; public function __construct( OrderId $anOrderId, Amount $amount, $aFirstName, $aLastName ) { $this->id = $anOrderId; $this->amount = $amount; $this->firstName = $aFirstName; $this->lastName = $aLastName; } public function id() { return $this->id; } public function firstName() { return $this->firstName; } public function lastName() { return $this->lastName; } public function amount() { return $this->amount; } }
咱們的實體已經有一個使用值對象模型化的標識。讓咱們使用不一樣的方式來建立一個 OrderId
。編程
正如先前聲明,標識定義了實體。所以,處理它是實體重要的一方面。這裏一般有四種途徑來定義實體標識:持久化機制生成標識,客戶端生成標識,應用程序自身生成標識,或者另外一個界限上下文提供標識。安全
一般,生成標識最簡單的方式就是將其委託給持久化機制,由於絕大多數持久化機制都支持某種標識生成 -- 像 MySQL 的自動增加屬性或者 Postgres 和 Oracle 序列。這一點,雖然簡單,但有一個主要缺點:在持久化以前,咱們是不能獲取到實體的標識的。所以,在某種程度上,若是咱們要使用持久化機制生成標識,咱們將把標識操做與基礎性持久化存儲耦合。
CREATE TABLE `orders` ( `id` int(11) NOT NULL auto_increment, `amount` decimal (10,5) NOT NULL, `first_name` varchar(100) NOT NULL, `last_name` varchar(100) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
接着咱們可能考慮以下代碼:
namespace Ddd\Identity\Domain\Model; class Person { private $identificationNumber; private $firstName; private $lastName; public function __construct( $anIdentificationNumber, $aFirstName, $aLastName ) { $this->identificationNumber = $anIdentificationNumber; $this->firstName = $aFirstName; $this->lastName = $aLastName; } public function identificationNumber() { return $this->identificationNumber; } public function firstName() { return $this->firstName; } public function lastName() { return $this->lastName; } }
若是你曾嘗試本身寫過 ORM,那麼你已經經歷過這種狀況。建立一個新的 Person
類的方法又是什麼?若是數據庫要建立標識,咱們要把它傳給構造函數嗎?何處什麼時候咱們用來更新 Person
標識的魔法又是什麼?若是咱們最終不持久化實體,又會發生什麼?
有時當使用 ORM 映射實體到持久存儲區時,會增強一些約束 -- 例如 Doctrine,若是使用了標識生成策略的話, Doctrine 會依賴於一個整型字段。假設領域模型須要另一種標識的話,這會產生衝突。
處理這種狀況最簡單的方法就是使用一個 Layer Supertype
,其中爲持久存儲區建立的標識將被放置在:
namespace Ddd\Common\Domain\Model; abstract class IdentifiableDomainObject { private $id; protected function id() { return $this->id; } protected function setId($anId) { $this->id = $anId; } } namespace Acme\Billing\Domain; use Acme\Common\Domain\IdentifiableDomainObject; class Order extends IdentifiableDomainObject { private $orderId; public function orderId() { if (null === $this->orderId) { $this->orderId = new OrderId($this->id()); } return $this->orderId; } }
每一個項目都會面臨應該選擇哪一個 ORM 。PHP 有許多很好的 ORM:Doctrine, Propel, Eloquent, Paris 等等。
他們中的大多數都是活動記錄實現方式。活動記錄實現方式對大多數 CRUD 應用剛恰好,但對富領域模型來講並非一個好的選擇,有以下緣由:
正如前一章提到過,當前最好的 PHP ORM 就是 Doctrine,它的實現方式就是數據映射模式。數據映射從領域關注點分享了持久化關注點,從而獲得無持久性實體。對某些人但願構建富領域模型這點來講使得該工具成爲最好的選擇。
有時,當處理肯定的領域,在客戶端消費領域模型過程當中天然而然產生了標識。這多是理想的狀況,由於很容易建模標識。如今咱們來看一個圖書售賣系統:
namespace Ddd\Catalog\Domain\Model\Book; class ISBN { private $isbn; private function __construct($anIsbn) { $this->setIsbn($anIsbn); } private function setIsbn($anIsbn) { $this->assertIsbnIsValid($anIsbn, 'The ISBN is invalid.'); $this->isbn = $anIsbn; } public static function create($anIsbn) { return new static($anIsbn); } private function assertIsbnIsValid($anIsbn, $string) { // ... Validates an ISBN code } }
根據維基百科定義:國際標準書號(IBSN)是惟一的數字商業書籍標識。IBSN 分配給每本書的每一個版本和改動(重印除外)。例如,一個電子書,平裝版和精裝版會有各自不一樣的 IBSN。2007 年 1 月後出版的圖書 IBSN 長度爲 13 位數字, 2007 年 1 月以前爲 10 位數字。分配 IBSN 的方法是以國家爲基礎的,每每取決於一個國家出版行業的規模。
IBSN 已經在領域中定義好了,它是一個有效的標識,由於它是惟一的,這一點也很容易驗證。下面是一個客戶端提供標識的好例子:
class Book { private $isbn; private $title; public function __construct(ISBN $anIsbn, $aTitle) { $this->isbn = $anIsbn; $this->title = $aTitle; } }
如今,是關於怎樣使用它:
$book = new Book( ISBN::create('...'), 'Domain-Driven Design in PHP' );
練習思考一下其它內置標識而且實現一個模型。
若是客戶端不能很好的提供標識的話,則更好的辦法是經過應用程序生成標識的方式來處理標識操做問題,一般是經過一個 UUID。這是咱們在這種狀況下建議的方法,若是在以前展現的案例中沒有你的場景的話。
根據維基百科:UUID 的目的是無須在中心調度的狀況下,使分佈式系統可以識別惟一標識信息。在這個上下文中,惟一的含義是實際的惟一而不是保證惟一。因爲標識具備有限大小,這有可能讓兩個不一樣的項共享同一個標識。這即是哈希衝突的一種形式。必須先選好標識大小及其生成過程使得這種方式在生產中極不現實。任何人均可以建立一個 UUID,並使用它來識別合理的內容,相同的標識決不會被任何人用來識別其它內容。所以, 標記爲 UUIDs 的信息能夠之後合併到單個數據庫中, 而無需解析標識 (ID) 衝突。
PHP 中有一些生成 UUID 的庫,能夠在 Packaglist 上找到: https://packagist.org/search/?q=uuid。最好的推薦即是 Ben Ramsey 開發的,能夠經過連接: https://github.com/ramsey/uuid 找到,由於在 github 上已經有上千關注,在 packaglist 也有高達數百萬的安裝。
這彷佛是最複雜的標識生成策略,由於它不只強制一個本地實體依賴於本地界限上下文事件,同時依賴於外部界限上下文事件。因此就維護而言,這代價有點高。
而其它上下文提供一個接口,來從本地實體中選擇標識。這能夠將一些暴露的屬性做爲自身一部分。
當須要在界限上下文的實體中進行同步時,當原始實體發生改變時,在每一個須要被通知到的界限上下文中,一般能夠用一個事件驅動架構來實現。
目前,在本章先前討論過,保存實體狀態至數據庫中最好的工具就是 Doctrine ORM。Doctrine 有幾種方式來指定實體元數據:經過實體代碼中的註解,經過 XML,經過 YAML,或者普通的 PHP 代碼。在這一章,咱們將深刻討論爲何註解不是映射實體最好的方式。
首先,咱們須要從 Composer 拉取 Doctrine。在項目的根目錄下,執行如下命令:
> php composer.phar require "doctrine/orm=^2.5"
接着,用下面的代碼能夠調起 doctrine:
require_once '/path/to/vendor/autoload.php'; use Doctrine\ORM\Tools\Setup; use Doctrine\ORM\EntityManager; $paths = ['/path/to/entity-files']; $isDevMode = false; // the connection configuration $dbParams = [ 'driver' => 'pdo_mysql', 'user' => 'the_database_username', 'password' => 'the_database_password', 'dbname' => 'the_database_name', ]; $config = Setup::createAnnotationMetadataConfiguration($paths, $isDevMode); $entityManager = EntityManager::create($dbParams, $config);
默認狀況下,Doctrine 文檔使用註解來呈現示例代碼。因此咱們也用註解開始咱們的代碼例子,而且討論爲何應該儘量地避免它。
爲此,咱們拿出前面章節中的 Order
類來討論:
當 Doctrine 發佈時,一個引人注目的方式就是使用註解的例子來展現如何映射對象。
什麼是註解?註解是元數據的一種特殊形式,在 PHP 裏,它被置在源代碼註釋裏,例如,PHPDocuemnt 使用註解來構建 API 信息,PHPUnit 使用一些註解來指定數據提供者,或者提供指望的擲出異常的一小段代碼:
class SumTest extends PHPUnit_Framework_TestCase { /** @dataProvider aMethodName */ public function testAddition() { //... } }
爲了映射 Order
實體到持久存儲層,Order
的源代碼須要修改添加 Doctrine 註解:
use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Column; /** @Entity */ class Order { /** @Id @GeneratedValue(strategy="AUTO") */ private $id; /** @Column(type="decimal", precision="10", scale="5") */ private $amount; /** @Column(type="string") */ private $firstName; /** @Column(type="string") */ private $lastName; public function __construct( Amount $anAmount, $aFirstName, $aLastName ) { $this->amount = $anAmount; $this->firstName = $aFirstName; $this->lastName = $aLastName; } public function id() { return $this->id; } public function firstName() { return $this->firstName; } public function lastName() { return $this->lastName; } public function amount() { return $this->amount; } }
接着,要保存實體到持久存儲層,僅須要執行如下操做便可:
$order = new Order( new Amount(15, Currency::EUR()), 'AFirstName', 'ALastName' ); $entityManager->persist($order); $entityManager->flush();
粗看來,這段代碼十分簡潔,而且這也是一種指定映射信息的簡單方法。但它也帶來了代價。最後的代碼有什麼奇怪之處呢?
首先,領域關注點與基礎設施關注點混合了。Order
是領域概念,而表,列等等是基礎設施關注點。
所以,這裏的代碼,實體與註解指定的映射信息緊耦合了。若是實體須要用一個實體管理器和其它不一樣映射元數據來持久化,這將不可能作到。
註解每每會致使反作用和緊耦合,因此最好不要使用它們。
那麼什麼是指定映射信息最好的方式?能讓你從實體自體分享映射信息的就是最好的方式。這能夠用 XML 映射,YAML 映射,或者 PHP 映射來實現。在本書中,咱們將使用 XML 映射。
咱們的標識,OrderId
,是一個值對象。在前一章已經介紹過,Doctrine 有好幾種方法來映射值對象,嵌套值,以及自定義類型。當值對象被看成標識使用時,最好的選擇就是自定義類型。
Doctrine 2.5 裏一個有意思的新特性就是實體如今可使用值對象做爲標識,只須要實現 __toString()
方法。所以咱們可能添加 _toString
到咱們的值對象標識中並在映射裏使用。
namespace Ddd\Billing\Domain\Model\Order; use Ramsey\Uuid\Uuid; class OrderId { // ... public function __toString() { return $this->id; } }
經過查閱 Doctrine 自定義類型的實現,發現它們繼承於 GuidType
,因此它們內部表現爲一個 UUID。咱們須要指明數據庫的原生標識的轉換。而後在使用自定義類型前須要先註冊它們。若是你須要這些步驟的幫助,Custom Mapping Type
是一個好的參考。
use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\GuidType; class DoctrineOrderId extends GuidType { public function getName() { return 'OrderId'; } public function convertToDatabaseValue( $value, AbstractPlatform $platform ) { return $value->id(); } public function convertToPHPValue( $value, AbstractPlatform $platform ) { return new OrderId($value); } }
最後,咱們要設置好自定義類型的註冊。同時,咱們不得再也不一次更新啓動過程:
require_once '/path/to/vendor/autoload.php'; // ... \Doctrine\DBAL\Types\Type::addType( 'OrderId', 'Ddd\Billing\Infrastructure\Domain\Model\DoctrineOrderId' ); $config = Setup::createXMLMetadataConfiguration($paths, $isDevMode); $entityManager = EntityManager::create($dbParams, $config);
隨着全部的變化塵埃落定,咱們終於準備好了。因此讓咱們來看看咱們最終的映射文件。最有趣的細節就是檢查如何用咱們 OrderId
的自定義類型來獲取映射的 id:
<?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\Billing\Domain\Model\Order" table="orders"> <id name="id" column="id" type="OrderId"/> <field name="amount" type="decimal" nullable="false" scale="10" precision="5" /> <field name="firstName" type="string" nullable="false" /> <field name="lastName" type="string" nullable="false" /> </entity> </doctrine-mapping>
測試體相對來講比較容易,由於它們是普通的 PHP 類,它們派生自它們所表明的領域概念。測試的焦點應該是實體所保護的不變量,由於實體的行爲可能會圍繞這些不變量進行建模。
例如,爲了簡單起見,假設須要一個 Post
領域模型,一個可能的實現以下:
class Post { private $title; private $content; private $status; private $createdAt; private $publishedAt; public function __construct($aContent, $title) { $this->setContent($aContent); $this->setTitle($title); $this->unpublish(); $this->createdAt(new DateTimeImmutable()); } private function setContent($aContent) { $this->assertNotEmpty($aContent); $this->content = $aContent; } private function setTitle($aPostTitle) { $this->assertNotEmpty($aPostTitle); $this->title = $aPostTitle; } private function setStatus(Status $aPostStatus) { $this->assertIsAValidPostStatus($aPostStatus); $this->status = $aPostStatus; } private function createdAt(DateTimeImmutable $aDate) { $this->assertIsAValidDate($aDate); $this->createdAt = $aDate; } private function publishedAt(DateTimeImmutable $aDate) { $this->assertIsAValidDate($aDate); $this->publishedAt = $aDate; } public function publish() { $this->setStatus(Status::published()); $this->publishedAt(new DateTimeImmutable()); } public function unpublish() { $this->setStatus(Status::draft()); $this->publishedAt = null; } public function isPublished() { return $this->status->equalsTo(Status::published()); } public function publicationDate() { return $this->publishedAt; } }
class Status { const PUBLISHED = 10; const DRAFT = 20; private $status; public static function published() { return new self(self::PUBLISHED); } public static function draft() { return new self(self::DRAFT); } private function __construct($aStatus) { $this->status = $aStatus; } public function equalsTo(self $aStatus) { return $this->status === $aStatus->status; } }
爲了測試這個領域模型,咱們必須確保測試覆蓋到全部的 Post
不變量:
class PostTest extends PHPUnit_Framework_TestCase { /** @test */ public function aNewPostIsNotPublishedByDefault() { $aPost = new Post( 'A Post Content', 'A Post Title' ); $this->assertFalse( $aPost->isPublished() ); $this->assertNull( $aPost->publicationDate() ); } /** @test */ public function aPostCanBePublishedWithAPublicationDate() { $aPost = new Post( 'A Post Content', 'A Post Title' ); $aPost->publish(); $this->assertTrue( $aPost->isPublished() ); $this->assertInstanceOf( 'DateTimeImmutable', $aPost->publicationDate() ); } }
因爲 DateTimes
在實體中普遍使用,咱們認爲在單元測試實體上指出具備日期類型的特定方法很是重要。例如一個 Post
在 15 天內建立,就認爲它是最新發布的。
class Post { const NEW_TIME_INTERVAL_DAYS = 15; // ... private $createdAt; public function __construct($aContent, $title) { // ... $this->createdAt(new DateTimeImmutable()); } // ... public function isNew() { return (new DateTimeImmutable()) ->diff($this->createdAt) ->days <= self::NEW_TIME_INTERVAL_DAYS; } }
isNew()
方法須要比較兩個 DateTimes
;即比較 Post
發佈當天的日期及當前日期。咱們計算之間的差別並檢查少於指定天數。咱們怎麼對 isNew()
方法作單元測試呢?正如咱們在實現中證實的,在咱們的測試套件裏從新產生指定的流程是很難的。那咱們還有別的選項嗎?
一種可行的方式就是在須要的時候用參數的形式傳遞全部日期:
class Post { // ... public function __construct($aContent, $title, $createdAt = null) { // ... $this->createdAt($createdAt ?: new DateTimeImmutable()); } // ... public function isNew($today = null) { return ($today ?: new DateTimeImmutable()) ->diff($this->createdAt) ->days <= self::NEW_TIME_INTERVAL_DAYS; } }
另外一種選擇是使用測試類模式。咱們的想法是將 Post
類擴展爲一個新類, 能夠經過操做來強制特定的場景。這個新類將僅用於單元測試目的。壞消息是, 咱們必須修改原始的 Post
類, 提取一些方法, 並將一些字段和方法從私有更改成保護。一些開發人員可能會擔憂因爲測試緣由而增長類屬性的可見性。然而, 咱們認爲在大多數狀況下, 這是值得的:
class Post { protected $createdAt; public function isNew() { return ($this->today()) ->diff($this->createdAt) ->days <= self::NEW_TIME_INTERVAL_DAYS; } protected function today() { return new DateTimeImmutable(); } protected function createdAt(DateTimeImmutable $aDate) { $this->assertIsAValidDate($aDate); $this->createdAt = $aDate; } }
正如你所見,咱們把獲取日期的邏輯提取爲一個 today()
方法。這種方式,經過應用模板方法模式,咱們能夠從派生類改變它的行爲。有時候相似的狀況也發生在 createdAt
方法和字段。如今它們是受保護類型的,所以它們能夠在派生類中使用和重寫:
class PostTestClass extends Post { private $today; protected function today() { return $this->today; } public function setToday($today) { $this->today = $today; } }
經過這種更改,咱們如今能夠經過 PostTestClass
測試原來的 Post
類:
class PostTest extends PHPUnit_Framework_TestCase { // ... /** @test */ public function aPostIsNewIfIts15DaysOrLess() { $aPost = new PostTestClass( 'A Post Content', 'A Post Title' ); $format = 'Y-m-d'; $dateString = '2016-01-01'; $createdAt = DateTimeImmutable::createFromFormat( $format, $dateString ); $aPost->createdAt($createdAt); $aPost->setToday( $createdAt->add( new DateInterval('P15D') ) ); $this->assertTrue( $aPost->isNew() ); $aPost->setToday( $createdAt->add( new DateInterval('P16D') ) ); $this->assertFalse( $aPost->isNew() ); } }
最後一個僅有的小細節:使用這種方法,是能夠百分百覆蓋 Post
類的,由於 today()
方法永遠不會執行。然而,它可能會被其它測試覆蓋。
另外一種選擇是把調用包裝到 DateTimeImmutable
構造函數或者使用新類及一些靜態方法來命名構造函數。在這樣作的時候, 咱們能夠靜態地更改這些方法的結果, 以便根據特定的測試方案進行不一樣的行爲:
class Post { // ... private $createdAt; public function __construct($aContent, $title) { // ... $this->createdAt(MyCustomDateTimeBuilder::today()); } // ... public function isNew() { return (MyCustomDateTimeBuilder::today()) ->diff($this->createdAt) ->days <= self::NEW_TIME_INTERVAL_DAYS; } }
對於獲取今天的 DateTime
,咱們如今使用一個靜態調用 MyCustomDateTimeBuilder::today
。這個類也有一些 setter 方法來僞造結果,以便在下一次調用中返回:
class PostTest extends PHPUnit_Framework_TestCase { // ... /** @test */ public function aPostIsNewIfIts15DaysOrLess() { $createdAt = DateTimeImmutable::createFromFormat( 'Y-m-d', '2016-01-01' ); MyCustomDateTimeBuilder::setReturnDates( [ $createdAt, $createdAt->add( new DateInterval('P15D') ), $createdAt->add( new DateInterval('P16D') ) ] ); $aPost = new Post( 'A Post Content', 'A Post Title' ); $this->assertTrue( $aPost->isNew() ); $this->assertFalse( $aPost->isNew() ); } }
這種方法的主要問題是會與一個對象靜態耦合,根據你的用例,建立一個靈活的僞造對象也很棘手。
你一樣只可使用反射技術來構建一個新的具備自定義日期的 Post
類。可使用 Mimic
,一個簡單的對象原型,數據水合,數據展現函數庫:
namespace Domain; use mimic as m; class ComputerScientist { private $name; private $surname; public function __construct($name, $surname) { $this->name = $name; $this->surname = $surname; } public function rocks() { return $this->name . ' ' . $this->surname . ' rocks!'; } } assert(m\prototype('Domain\ComputerScientist') instanceof Domain\ComputerScientist); m\hydrate('Domain\ComputerScientist', [ 'name' => 'John', 'surname' => 'McCarthy' ])->rocks(); //John McCarthy rocks! assert(m\expose( new Domain\ComputerScientist('Grace', 'Hopper')) == [ 'name' => 'Grace', 'surname' => 'Hopper' ] );
分享與討論與你的同事討論如何正確地對具體固定日期的實體進行單元測試,以及提出替代方法。
若是你想知道更多測試模式和方法,去看一看 Gerard Meszaros 的書 xUnit Test Patterns: Refactoring Test
驗證是咱們領域模型裏一個很是重要的過程。它不只是檢查屬性的正確性,同時也對整個對象及組合對象進行驗證。爲了保證模型是可用狀態,不一樣層次的驗證是必要的。僅僅由於對象由可用屬性(在每一個基礎上)組成,並不意味着對象(做爲一個總體)是可用的。相反的是:對象可用並不等於組合可用。
有些人理解的驗證是經過一個服務驗證給定對象的狀態的過程。在這種狀況下,驗證符合契約式設計方法,它由先決條件,後置條件和不變量組成。保護單一屬性的一種方法就是使用第三章提到的值對象。爲了使咱們的設計可以靈活應對變化,咱們只關注斷言必須知足領域的先決條件。這裏,咱們將使用 guards 做爲驗證先決條件的一種簡單方式:
class Username { const MIN_LENGTH = 5; const MAX_LENGTH = 10; const FORMAT = '/^[a-zA-Z0-9_]+$/'; private $username; public function __construct($username) { $this->setUsername($username); } private function setUsername($username) { $this->assertNotEmpty($username); $this->assertNotTooShort($username); $this->assertNotTooLong($username); $this->assertValidFormat($username); $this->username = $username; } private function assertNotEmpty($username) { if (empty($username)) { throw new InvalidArgumentException('Empty username'); } } private function assertNotTooShort($username) { if (strlen($username) < self::MIN_LENGTH) { throw new InvalidArgumentException(sprintf( 'Username must be %d characters or more', self::MIN_LENGTH )); } } private function assertNotTooLong($username) { if (strlen($username) > self::MAX_LENGTH) { throw new InvalidArgumentException(sprintf( 'Username must be %d characters or less', self::MAX_LENGTH )); } } private function assertValidFormat($username) { if (preg_match(self:: FORMAT, $username) !== 1) { throw new InvalidArgumentException( 'Invalid username format' ); } } }
正如你在上面例子中看到的,爲了構建一個 Username
值對象必須知足四個先決條件,它們是:
若是知足了全部先決條件,屬性就會正確設置,對象也會建立成功。不然,會產生一個 InvalidArgumentException
異常,程序會停止,客戶端會顯示一個錯誤。
有些程序員可能認爲這種驗證是一種防護性編程。然而,咱們不是檢查輸入是字符串或者空值是不被容許的。咱們沒法避免人們錯誤地使用咱們的代碼,但咱們能夠控制領域狀態的正確性。正如第三章,值對象所示,驗證也能夠具備安全性。
防護性編程並非壞事。通常來講,當開發組件或者在其它項目中使用的第三方庫時,這會更有意義。不過,在開發你本身的界限上下文時,能夠避免這些額外且偏執的檢查(空值,基本類型,類型提示等等),從而經過依賴單元測試套件的覆蓋來提升開發速度。
有時,一個由可用屬性組成的對象,做爲一個總體,仍然可能視爲無效。添加這種驗證到對象自己可能很誘人,但這通常是一種反模式。高層次驗證的改變節奏不一樣於對象邏輯自己。另外,職責分離也是一種好的實踐。
驗證會通知客戶端已找到的任何錯誤或收集要稍後複查的結果, 由於有時咱們不想在第一次出現故障時中止執行。
一個 abstract
和可重用的 Validator
有時能夠像這樣:
abstract class Validator { private $validationHandler; public function __construct(ValidationHandler $validationHandler) { $this->validationHandler = $validationHandler; } protected function handleError($error) { $this->validationHandler->handleError($error); } abstract public function validate(); }
做爲一個具體的例子,咱們想驗證整個 Location
對象,包含驗證 Country
, City
,以及 Postcode
值對象。然而,在驗證時這些單個值多是不可用的。city 可能不是來自 country 的一部分,郵編可能不符合這個 city 的格式:
class Location { private $country; private $city; private $postcode; public function __construct( Country $country, City $city, Postcode $postcode ) { $this->country = $country; $this->city = $city; $this->postcode = $postcode; } public function country() { return $this->country; } public function city() { return $this->city; } public function postcode() { return $this->postcode; } }
驗證器將檢查整個 Location
對象的狀態,分析屬性之間關係的含義:
class LocationValidator extends Validator { private $location; public function __construct( Location $location, ValidationHandler $validationHandler ) { parent:: __construct($validationHandler); $this->location = $location; } public function validate() { if (!$this->location->country()->hasCity( $this->location->city() )) { $this->handleError('City not found'); } if (!$this->location->city()->isPostcodeValid( $this->location->postcode() )) { $this->handleError('Invalid postcode'); } } }
一旦全部屬性設置好,咱們就能夠驗證明體,最有多是在一些描述性過程以後。表面上,看起來像是 Location
在驗證本身。然而並非這樣,Location
類把驗證代理給一個具體的驗證明例 ,清晰地分離這些職責:
class Location { // ... public function validate(ValidationHandler $validationHandler) { $validator = new LocationValidator($this, $validationHandler); $validator->validate(); } }
經過對現有實現進行一些細微的更改, 咱們能夠將驗證消息與驗證程序解耦:
class LocationValidationHandler implements ValidationHandler { public function handleCityNotFoundInCountry() {} public function handleInvalidPostcodeForCity() {} }
class LocationValidator { private $location; private $validationHandler; public function __construct( Location $location, LocationValidationHandler $validationHandler ) { $this->location = $location; $this->validationHandler = $validationHandler; } public function validate() { if (!$this->location->country()->hasCity( $this->location->city() )) { $this->validationHandler->handleCityNotFoundInCountry(); } if (!$this->location->city()->isPostcodeValid( $this->location->postcode() )) { $this->validationHandler->handleInvalidPostcodeForCity(); } } }
咱們還須要將驗證方法的簽名更改成如下內容:
class Location { // ... public function validate( LocationValidationHandler $validationHandler ) { $validator = new LocationValidator($this, $validationHandler); $validator->validate(); } }
驗證對象組合可能很複雜。所以,實現此目標的首選方法就是經過領域服務。而後,該服務與倉儲通訊以檢索可用的聚合。因爲可能會建立複雜的對象,一個聚合可能處於中間狀態,須要先驗證其它聚合的狀態。咱們可使用領域事件來通知系統其它部分,一個特殊的元素已經驗證了。
咱們將在之後的章節(第 6 章,領域事件)探討;但重要的是要突出實體上執行的操做可能觸發領域事件。這種方法用於領域變化與應用其它部分,或者甚至其它應用間的通訊,正如你將在第 12 章,集成限界上下文中所看到的:
class Post { // ... public function publish() { $this->setStatus( Status::published() ); $this->publishedAt(new DateTimeImmutable()); DomainEventPublisher::instance()->publish( new PostPublished($this->id) ); } public function unpublish() { $this->setStatus( Status::draft() ); $this->publishedAt = null; DomainEventPublisher::instance()->publish( new PostUnpublished($this->id) ); } // ... }
當一個新的實體實例建立時,甚至可能觸發領域事件:
class User { // ... public function __construct(UserId $userId, $email, $password) { $this->setUserId($userId); $this->setEmail($email); $this->setPassword($password); DomainEventPublisher::instance()->publish( new UserRegistered($this->userId) ); } }
領域中的一些概念依賴標識 -- 這就是,改變它們的狀態並不會改變它們自身的惟一標識。咱們已經看到除了操做標識自身的邏輯以外,將標識建模爲值對象還能夠帶來像永久性這樣的好處。咱們也展現幾種生成標識的作法,並在如下列表中複述:
咱們已經看到並討論過使用 Doctrine 做爲一種持久化機制,咱們已經研究了使用活動記錄模式的缺點,最後,咱們討論了不一樣級別的實體驗證: