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

實體

咱們已經討論過優先將領域中全部內容做爲值對象進行建模的好處。但對領域建模時,可能會有一些場景,你會發如今通用語言裏的某些概念須要一根標識線。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;
    }
}

對象 VS. 基本類型

大多數時候,一個實體的標識表示爲一個基本類型 - 一般是一個字符串或者整型。但使用值對象來表示它有更多優點: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;
    }
}

活動記錄 VS 富領域模型數據映射

每一個項目都會面臨應該選擇哪一個 ORM 。PHP 有許多很好的 ORM:Doctrine, Propel, Eloquent, Paris 等等。

他們中的大多數都是活動記錄實現方式。活動記錄實現方式對大多數 CRUD 應用剛恰好,但對富領域模型來講並非一個好的選擇,有以下緣由:

  • 活動記錄模式在實體和數據表間假設了一個一對一的關係。所以它耦合了數據表的設計與對象系統的設計。而在富領域模型裏,一些實體的構建信息可能來自不一樣的數據源。
  • 諸如集合和繼承等高級內容很難實現。
  • 它們大多數的實現,強制使用了一些約定的,繼承的構造方式。因爲 ORM 與領域模型的耦合,可能會致使持久層泄漏到領域模型中。咱們惟一見過的不強制從基類繼承的活動記錄實現是 Castle,來自於 Castle 項目,一個 .NET 框架。儘管這致使了上在生成實體時,領域模型和持久層某些程度上的分離,但它並不會從高層次領域設計解耦低層次的持久化細節。

正如前一章提到過,當前最好的 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 代碼。在這一章,咱們將深刻討論爲何註解不是映射實體最好的方式。

設置 Doctrine

首先,咱們須要從 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() {
        //...
    }
}

用 XML 映射實體

爲了映射 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

因爲 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 值對象必須知足四個先決條件,它們是:

  1. 必須不爲空
  2. 至少 5 個字符
  3. 少於 10 個字符
  4. 必須符合字符加數字或者下劃線格式

若是知足了全部先決條件,屬性就會正確設置,對象也會建立成功。不然,會產生一個 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 對象,包含驗證 CountryCity,以及 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)
        );
    }
}

小結

領域中的一些概念依賴標識 -- 這就是,改變它們的狀態並不會改變它們自身的惟一標識。咱們已經看到除了操做標識自身的邏輯以外,將標識建模爲值對象還能夠帶來像永久性這樣的好處。咱們也展現幾種生成標識的作法,並在如下列表中複述:

  • 持久化機制:容易實現,但在持久化對象前你不會獲得一個標識,它會延遲並使事件傳播複雜化。
  • 代理 ID:一些 ORM 須要一個額外的字段,用持久化機制在實體中映射標識。
  • 客戶端生成:有時標識符合領域概念,你能夠在領域內對其建模。
  • 經過應用程序生成:你可使用一個庫來生成標識。
  • 經過限界上下文生成:多是最複雜的策略。其它限界上下文提供一個接口來生成標識。

咱們已經看到並討論過使用 Doctrine 做爲一種持久化機制,咱們已經研究了使用活動記錄模式的缺點,最後,咱們討論了不一樣級別的實體驗證:

  • 屬性驗證:經過先決條件,後置條件及不變性來檢查對象狀態內部的細節。
  • 對象總體驗證:檢查整個對象的一致性。將驗證提取到外部服務是一個好的實踐。
  • 對象組合:複雜對象組合能夠經過領域服務驗證。與其它應用間通訊的一個好方法就是領域事件。
相關文章
相關標籤/搜索