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

倉儲(Repositories)

爲了與領域對象交互,你須要保留對它的引用。達到這個目標的途徑之一就是經過建立器(creation)。或者你能夠遍歷關聯。在面向對象程序中,對象具備與其餘對象的連接(引用),這使它們易於遍歷,從而有助於模型的表達能力。但這有一點很重要:你須要一種機制來檢索第一個對象:聚合根。php

倉儲做爲存儲位置,其檢索到的對象以與存儲對象徹底相同的狀態返回。在領域驅動設計中,每一個聚合類型一般都有一個惟一的關聯存儲庫,用於持久化和檢索需求。可是,在須要共享一個聚合對象層次結構的狀況下,這些類型可能共享一個存儲庫。git

只要你從倉儲中成功取回聚合,你作出每一個更改都是持久的,從而無需再返回倉儲。github

定義

Martin Fowler 如此定義倉儲:web

倉儲是領域和數據映射層之間的機制,能夠說是在內存中的領域對象集合。客戶端對象以聲明方式構造查詢詢規格,並將其提交給倉儲以知足須要。對象能夠添加到倉儲以及從其中移除,由於它們能夠來自於一個簡單的對象集合,而且由倉儲封裝的映射代碼將在後臺執行對應的操做。從概念上講,倉儲封閉了被持久化到數據存儲中的對象集合以及對其的操做,它提供了關於持久層更面向對象的視角。倉儲還支持在領域和數據映射層之間實現清晰的隔離和單向依賴的目標。

倉儲不是 DAO

數據訪問對象(DAO)是持久化領域對象到數據庫的常見模式。這很容易混淆 DAO 模式和倉儲。其中最大的不一樣就是倉儲表示集合,而 DAO 更靠近數據庫一端而且一般以表爲中心。一般,DAO 包含 CRUD 方法,用於特定的領域對象。讓咱們看看一個常見的 DAO 接口是怎樣:redis

interface UserDAO
{
    /**
     * @param string $username
     * @return User
     */
    public function get($username);
    public function create(User $user);
    public function update(User $user);
    /**
     * @param string $username
     */
    public function delete($username);
}

DAO 接口能夠有多種實現,範圍從使用 ORM 構造到使用普通 SQL 查詢。使用 DAO 的主要問題是,他們的職責定義並不清晰。DAO 一般被視爲通向數據庫的網關,所以它相對容易使用許多特定方法來大大下降內聚性來查詢數據庫:sql

interface BloatUserDAO
{
    public function get($username);
    public function create(User $user);
    public function update(User $user);
    public function delete($username);
    public function getUserByLastName($lastName);
    public function getUserByEmail($email);
    public function updateEmailAddress($username, $email);
    public function updateLastName($username, $lastName);
}

正如你所見,咱們添加實現的新方法越多,對 DAO 的單元測試就越困難,而且它與 User 對象的耦合越發嚴重。問題隨着時間也愈來愈多,在許多其餘參與者的共同努力下,大泥球(the Big Ball of Mud)變得愈來愈大。數據庫

面向集合的倉儲

倉儲經過實現其公共接口特徵來模擬集合。做爲一個集合,倉儲不該泄漏任何持久化行爲的意圖,例如保存到數據庫的概念。緩存

底層的持久化機制必須支持這種需求。你無需在對象的整個生命期內處理對它們的更改。該集合引用對象最新的更改,這意味着在每次訪問時,你都會得到對象最新的狀態。服務器

倉儲實現了一個具體的集合類型,Set。一個 Set 是具備不包含重複條目的不變量的數據結構。若是你試圖添加已經存在於 Set 中的元素,則不會成功。這在咱們的用例中頗有用,由於每一個聚合都具備與根實體相關聯的惟一標識。session

考慮一個例子,咱們有如下領域模型:

namespace Domain\Model;
class Post
{
    const EXPIRE_EDIT_TIME = 120; // seconds
    private $id;
    private $body;
    private $createdAt;

    public function __construct(PostId $anId, Body $aBody)
    {
        $this->id = $anId;
        $this->body = $aBody;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function editBody(Body $aNewBody)
    {
        if ($this->editExpired()) {
            throw new RuntimeException('Edit time expired');
        }
        $this->body = $aNewBody;
    }

    private function editExpired()
    {
        $expiringTime = $this->createdAt->getTimestamp() +
            self::EXPIRE_EDIT_TIME;
        return $expiringTime < time();
    }

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

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

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

class Body
{
    const MIN_LENGTH = 3;
    const MAX_LENGTH = 250;
    private $content;

    public function __construct($content)
    {
        $this->setContent(trim($content));
    }

    private function setContent($content)
    {
        $this->assertNotEmpty($content);
        $this->assertFitsLength($content);
        $this->content = $content;
    }

    private function assertNotEmpty($content)
    {
        if (empty($content)) {
            throw new DomainException('Empty body');
        }
    }

    private function assertFitsLength($content)
    {
        if (strlen($content) < self::MIN_LENGTH) {
            throw new DomainException('Body is too short');
        }
        if (strlen($content) > self::MAX_LENGTH) {
            throw new DomainException('Body is too long');
        }
    }

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

class PostId
{
    private $id;

    public function __construct($id = null)
    {
        $this->id = $id ?: uniqid();
    }

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

    public function equals(PostId $anId)
    {
        return $this->id === $anId->id();
    }
}

若是咱們想持久化 Post 實體,能夠建立一個像下面的在內存中的 Post 倉儲:

class SimplePostRepository
{
    private $post = [];

    public function add(Post $aPost)
    {
        $this->posts[(string)$aPost->id()] = $aPost;
    }

    public function postOfId(PostId $anId)
    {
        if (isset($this->posts[(string)$anId])) {
            return $this->posts[(string)$anId];
        }
        return null;
    }
}

而且,正如你指望的,它會看成一個集合處理:

$id = new PostId();
$repository = new SimplePostRepository();
$repository->add(new Post($id, 'Random content'));
// later ...
$post = $repository->postOfId($id);
$post->editBody('Updated content');
// even later ...
$post = $repository->postOfId($id);
assert('Updated content' === $post->body());

正如你所見,從集合的視角來看,在倉儲中是不須要一個 save 方法的。影響對象的更改由基礎設施層正確處理。面向集合的倉儲是不須要添加以前持久化存儲的聚合的倉儲。這主要發生在基於內存的倉儲裏,可是咱們也有使用持久化倉儲進行存儲的方法。咱們等下再看。此外,咱們將在第 11 章,應用 中更深刻地介紹這一點。

設計倉儲的第一步就是爲它定義一個相似集合的接口。這個接口須要定義一些經常使用的方法集,像這樣:

interface PostRepository
{
    public function add(Post $aPost);
    public function addAll(array $posts);
    public function remove(Post $aPost);
    public function removeAll(array $posts);
// ...
}

爲了實現這樣的接口,你還可使用抽象類。一般,當咱們討論接口時,咱們指的是通常概念,而不只僅是特定的 PHP 接口。爲了簡化設計,請不要添加沒必要要的方法。Repository 接口定義及其對應的 Aggregate 應放在同一模塊中。

有時 remove 操做並不會從數據庫中物理刪除聚合。這種策略(聚合的狀態字段更新爲 deleted)被軟刪除(soft delete)。爲何這種方法頗有趣?由於這對審覈更改和性能會頗有趣。在這些狀況下,你能夠將聚合標記爲已禁用或邏輯刪除(logically removed)。能夠經過移除刪除方法或在倉儲中提供禁用行爲來相應地更新接口。

倉儲另外一個重要的方面是 finder 方法,像下面這樣:

interface PostRepository
{
// ...
    /**
     * @return Post
     */
    public function postOfId(PostId $anId);
    /**
     * @return Post[]
     */
    public function latestPosts(DateTimeImmutable $sinceADate);
}

正如咱們在第 4 章,實體裏所建議的,咱們更傾向於應用程序生成的標識。爲聚合生成新標識最好的地方就是它的倉儲。所以爲了給 Post 取回全局惟一的 ID,包含它的邏輯位置就是 PostRepository:

interface PostRepository
{
// ...
    /**
     * @return PostId
     */
    public function nextIdentity();
}

負責構建每一個 Post 實例的代碼調用 nextIdentity 來獲取惟一標識,PostId:

$post = newPost($postRepository->nextIdentity(), $body);

一些開發者喜歡把實現放置靠近接口定義的地方,做爲模塊的子包。可是,由於咱們想要一個明確的關注點分離,咱們推薦把它放到基礎設施層。

內存實現

正如 Uncle Bob 在 《Screaming Architecture》中闡述:

良好的軟件架構容許推遲和延遲有關框架,數據庫,web 服務器,以及其餘環境問題和工具的決策。良好的架構讓你沒必要在項目的後續階段就決定使用 Rails,Spring,Hibernate,Tomcat 或 MySql。良好的架構能夠輕鬆地改變你對這些決定的想法。良好的架構會注重用例,並將其與外圍問題分離。

在應用程序的早期階段,可使用快速的內存實現。你可使用它來完善系統的其餘部分,從而使數據庫決策延遲到正確的時刻。內存倉儲很是簡單,快速且易於實現。

對於咱們的 Post 倉儲,一個內存中的哈希表足夠提供咱們所需的功能:

namespace Infrastructure\Persistence\InMemory;

use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;

class InMemoryPostRepository implements PostRepository
{
    private $posts = [];

    public function add(Post $aPost)
    {
        $this->posts[$aPost->id()->id()] = $aPost;
    }

    public function remove(Post $aPost)
    {
        unset($this->posts[$aPost->id()->id()]);
    }

    public function postOfId(PostId $anId)
    {
        if (isset($this->posts[$anId->id()])) {
            return $this->posts[$anId->id()];
        }
        return null;
    }

    public function latestPosts(\DateTimeImmutable $sinceADate)
    {
        return $this->filterPosts(
            function (Post $post) use ($sinceADate) {
                return $post->createdAt() > $sinceADate;
            }
        );
    }

    private function filterPosts(callable $fn)
    {
        return array_values(array_filter($this->posts, $fn));
    }

    public function nextIdentity()
    {
        return new PostId();
    }
}

Doctrine ORM

過去以前的章節中,咱們探討了不少關於 Doctrine。Doctrine 是用於數據庫存儲和對象映射的一些庫。默認狀況下,它與流行的 web 框架 Symfony2 綁定在一塊兒,除其餘功能外,藉助 Data Mapper 模式,它還使你能夠輕鬆地將應用程序與持久層分離。

同時,ORM 位於功能強大的數據庫抽象層之上,該層可經過稱爲 Doctrine Query Language(DQL)的 SQL 方法實現數據庫交互,該言受到著名的 Java Hibernate 框架的啓發。

若是咱們打算使用 Doctrine ORM,首先要完成的的工做就是經過 composer 將依賴添加到咱們的項目裏:

composer require doctrine/orm

對象映射

領域對象和數據庫之間的映射能夠視爲實現細節。領域生命週期不該該知道這些持久化細節。所以,映射信息應定義爲領域以外的基礎設施層的一部分,並定義爲倉儲的實現。

Doctrine 自定義映射類型

因爲咱們的 Post 實體是由諸如 Body 或 PostId 之類的值對象組成的,所以最好建立自定義映射類型或使用 Doctrine Embeddables,如值對象一章中所述。這將使對象映射變得至關容易:

namespace Infrastructure\Persistence\Doctrine\Types;

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Domain\Model\Body;

class BodyType extends Type
{
    public function getSQLDeclaration(
        array $fieldDeclaration, AbstractPlatform $platform
    )
    {
        return $platform->getVarcharTypeDeclarationSQL(
            $fieldDeclaration
        );
    }

    /**
     * @param string $value
     * @return Body
     */
    public function convertToPHPValue(
        $value, AbstractPlatform $platform
    )
    {
        return new Body($value);
    }

    /**
     * @param Body $value
     */
    public function convertToDatabaseValue(
        $value, AbstractPlatform $platform
    )
    {
        return $value->content();
    }

    public function getName()
    {
        return 'body';
    }
}
namespace Infrastructure\Persistence\Doctrine\Types;

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Domain\Model\PostId;

class PostIdType extends Type
{
    public function getSQLDeclaration(
        array $fieldDeclaration, AbstractPlatform $platform
    )
    {
        return $platform->getGuidTypeDeclarationSQL(
            $fieldDeclaration
        );
    }

    /**
     * @param string $value
     * @return PostId
     */
    public function convertToPHPValue(
        $value, AbstractPlatform $platform
    )
    {
        return new PostId($value);
    }

    /**
     * @param PostId $value
     */
    public function convertToDatabaseValue(
        $value, AbstractPlatform $platform
    )
    {
        return $value->id();
    }

    public function getName()
    {
        return 'post_id';
    }
}

不要忘記在 PostId 值對象上實現 __toString 魔術方法,由於 Doctrine 須要這樣:

class PostId
{
    // ...
    public function __toString()
    {
        return $this->id;
    }
}

Doctrine 提供多種映射格式,例如 YAML,XML,或者註解。XML 是咱們傾向的選擇,由於它提供了強大的 IDE 自動補全功能:

<?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
http://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
    <entity name="Domain\Model\Post" table="posts">
        <id name="id" type="post_id" column="id">
            <generator strategy="NONE" />
        </id>
        <field name="body" type="body" length="250" column="body"/>
        <field name="createdAt" type="datetime" column="created_at"/>
    </entity>
</doctrine-mapping>
練習

在使用 Doctrine Embeddables 方式時,寫下來看看映射是什麼樣子。看一看值對象或者實體,若是你須要幫助的話。

實體管理

EntityManager 是 ORM 功能的中心訪問點。引導也很容易:

use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools;

Type::addType(
    'post_id',
    'Infrastructure\Persistence\Doctrine\Types\PostIdType'
);
Type::addType(
    'body',
    'Infrastructure\Persistence\Doctrine\Types\BodyType'
);
$entityManager = EntityManager::create(
    [
        'driver' => 'pdo_sqlite',
        'path' => __DIR__ . '/db.sqlite',
    ],
    Tools\Setup::createXMLMetadataConfiguration(
        ['/Path/To/Infrastructure/Persistence/Doctrine/Mapping'],
        $devMode = true
    )
);

記得根據你的須要和設置來配置它。

DQL 實現

在倉儲的例子中,咱們僅須要 EntityManager 從數據庫裏直接取回領域對象:

namespace Infrastructure\Persistence\Doctrine;

use Doctrine\ORM\EntityManager;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;

class DoctrinePostRepository implements PostRepository
{
    protected $em;

    public function __construct(EntityManager $em)
    {
        $this->em = $em;
    }

    public function add(Post $aPost)
    {
        $this->em->persist($aPost);
    }

    public function remove(Post $aPost)
    {
        $this->em->remove($aPost);
    }

    public function postOfId(PostId $anId)
    {
        return $this->em->find('Domain\Model\Post', $anId);
    }

    public function latestPosts(\DateTimeImmutable $sinceADate)
    {
        return $this->em->createQueryBuilder()
            ->select('p')
            ->from('Domain\Model\Post', 'p')
            ->where('p.createdAt > :since')
            ->setParameter(':since', $sinceADate)
            ->getQuery()
            ->getResult();
    }

    public function nextIdentity()
    {
        return new PostId();
    }
}

若是你在別處看過一些 Doctrine 例子,你會發如今運行持久化或刪除後,應該馬上調用 flush 方法。可是正如咱們所建議的,這裏沒有調用 flush。刷新和處理事務將委派給應用服務。這就是爲何你可使用 Doctrine 的緣由,考慮到刷新實體上的全部更改都將在請求結束時進行。就性能而言,一次刷新的調用是最佳的。

面向持久化倉儲

某些時候,當面向集合的倉儲不能很好的適合咱們的持久化機制時。若是你沒有工做單位,那麼跟蹤聚合更改會是一項艱鉅的任務。持久化這樣的更改惟一的方法就是明確地調用 save 方法。

面向持久化倉儲的接口定義與面向集合倉儲的定義類似:

interface PostRepository
{
    public function nextIdentity();
    public function postOfId(PostId $anId);
    public function save(Post $aPost);
    public function saveAll(array $posts);
    public function remove(Post $aPost);
    public function removeAll(array $posts);
}

在這種狀況下,咱們如今有了 save 和 saveAll 方法,它們提供的功能相似於之前的 add 和 addAll 方法。可是,重要的區別在於客戶端如何使用它們。在面向集合的風格中,僅使用了一次 add 方法:當建立聚合時。在面向持久化風格中,你不只將在建立新的聚合以後使用 save 操做,並且還將在修改現有聚合時使用:

$post = new Post(/* ... */);
$postRepository->save($post);
// later ...
$post = $postRepository->postOfId($postId);
$post->editBody(new Body('New body!'));
$postRepository->save($post);

除了這種差別以外,細節僅在實現中。

Redis 實現

Redis 是一個咱們稱之爲緩存或存儲的內存鍵值對。

根據具體狀況,咱們能夠考慮將 Redis 用做聚合的存儲。

開始前,確保你的 PHP 客戶端鏈接上 Redis。一個好的推薦就是 Predis:

composer require predis/predis:~1.0
namespace Infrastructure\Persistence\Redis;

use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;
use Predis\Client;

class RedisPostRepository implements PostRepository
{
    private $client;

    public function __construct(Client $client)
    {
        $this->client = $client;
    }

    public function save(Post $aPost)
    {
        $this->client->hset(
            'posts',
            (string)$aPost->id(), serialize($aPost)
        );
    }

    public function remove(Post $aPost)
    {
        $this->client->hdel('posts', (string)$aPost->id());
    }

    public function postOfId(PostId $anId)
    {
        if ($data = $this->client->hget('posts', (string)$anId)) {
            return unserialize($data);
        }
        return null;
    }

    public function latestPosts(\DateTimeImmutable $sinceADate)
    {
        $latest = $this->filterPosts(
            function (Post $post) use ($sinceADate) {
                return $post->createdAt() > $sinceADate;
            }
        );
        $this->sortByCreatedAt($latest);
        return array_values($latest);
    }

    private function filterPosts(callable $fn)
    {
        return array_filter(array_map(function ($data) {
            return unserialize($data);
        },
            $this->client->hgetall('posts')), $fn);
    }

    private function sortByCreatedAt(&$posts)
    {
        usort($posts, function (Post $a, Post $b) {
            if ($a->createdAt() == $b->createdAt()) {
                return 0;
            }
            return ($a->createdAt() < $b->createdAt()) ? -1 : 1;
        });
    }

    public function nextIdentity()
    {
        return new PostId();
    }
}

SQL 實現

在一個經典例子中,咱們能夠經過僅使用普通 SQL 查詢來爲咱們的 PostRepository 建立一個簡單的 PDO 實現:

namespace Infrastructure\Persistence\Sql;

use Domain\Model\Body;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;

class SqlPostRepository implements PostRepository
{
    const DATE_FORMAT = 'Y-m-d H:i:s';
    private $pdo;

    public function __construct(\PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function save(Post $aPost)
    {
        $sql = 'INSERT INTO posts ' .
            '(id, body, created_at) VALUES ' .
            '(:id, :body, :created_at)';
        $this->execute($sql, [
            'id' => $aPost->id()->id(),
            'body' => $aPost->body()->content(),
            'created_at' => $aPost->createdAt()->format(
                self::DATE_FORMAT
            )
        ]);
    }

    private function execute($sql, array $parameters)
    {
        $st = $this->pdo->prepare($sql);
        $st->execute($parameters);
        return $st;
    }

    public function remove(Post $aPost)
    {
        $this->execute('DELETE FROM posts WHERE id = :id', [
            'id' => $aPost->id()->id()
        ]);
    }

    public function postOfId(PostId $anId)
    {
        $st = $this->execute('SELECT * FROM posts WHERE id = :id', [
            'id' => $anId->id()
        ]);
        if ($row = $st->fetch(\PDO::FETCH_ASSOC)) {
            return $this->buildPost($row);
        }
        return null;
    }

    private function buildPost($row)
    {
        return new Post(
            new PostId($row['id']),
            new Body($row['body']),
            new \DateTimeImmutable($row['created_at'])
        );
    }

    public function latestPosts(\DateTimeImmutable $sinceADate)
    {
        return $this->retrieveAll(
            'SELECT * FROM posts WHERE created_at > :since_date', [
                'since_date' => $sinceADate->format(self::DATE_FORMAT)
            ]
        );
    }

    private function retrieveAll($sql, array $parameters = [])
    {
        $st = $this->pdo->prepare($sql);
        $st->execute($parameters);
        return array_map(function ($row) {
            return $this->buildPost($row);
        }, $st->fetchAll(\PDO::FETCH_ASSOC));
    }

    public function nextIdentity()
    {
        return new PostId();
    }

    public function size()
    {
        return $this->pdo->query('SELECT COUNT(*) FROM posts')
            ->fetchColumn();
    }
}

因爲咱們沒有任何映射配置,所以在同一個類中爲表提供初始化方法將很是有用。一塊兒改變的事物應該保持在一塊兒

class SqlPostRepository implements PostRepository
{
// ...
    public function initSchema()
    {
        $this->pdo->exec(<<<SQL
DROP TABLE IF EXISTS posts;
CREATE TABLE posts (
id CHAR(36) PRIMARY KEY,
body VARCHAR (250) NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL
        );
    }
}

額外行爲

interface PostRepository
{
    // ...
    public function size();
}

實現以下:

class DoctrinePostRepository implements PostRepository
{
// ...
    public function size()
    {
        return $this->em->createQueryBuilder()
            ->select('count(p.id)')
            ->from('Domain\Model\Post', 'p')
            ->getQuery()
            ->getSingleScalarResult();
    }
}

向倉儲添加其餘行爲可能很是有益。上面這個例子是可以對給定集合中的全部項目進行計數。你可能會想添加一個 count 的方法。可是,當咱們嘗試模仿集合時,更好的名稱應該是 size。

你還能夠將禦寒的計算,計數器,優化的查詢或複雜的命令(INSERT,UPDATE 或 DELETE)放到倉儲中。可是,全部行爲仍應遵循倉儲的集合特徵。咱們鼓勵你將盡量多的邏輯轉移到領域特定的無狀態領域服務當中,而不是簡單地將這些職責添加到倉儲裏。

在某些狀況下,你不須要整個聚合便可簡單地訪問少許信息。要解決此問題,你能夠添加倉儲方法以將其做爲快捷方式訪問。你須要確保公經過瀏覽聚合根來訪問能夠檢索的數據。所以,你不該容許訪問聚合根的私有區域和內部區域,由於這將違反已制的契約。

對於某些用例,你須要很是具體的查詢,這些查詢是由多個聚合類型組成的,每種類型都返回特定的信息。能夠運行這些查詢,而後將其做爲單位值對象返回。倉儲返回值對象是很常見的。

若是發現本身建立了許多最佳用例的查找方法,則多是引入了常見的代碼壞味道。這可能代表聚合邊界判斷錯誤。可是,若是你確信邊界是正確的,那麼多是時候探索 CQRS 了。

倉儲的查詢

通過比較,若是考慮倉儲的查詢能力,它們與集合是不一樣的。倉儲執行查詢時一般處理不在內存中的大量對象。將領域對象的全部實例加載到內存並對它們執行查詢是不可行的。

一個好的解決方案是傳遞一個標準,並讓倉儲處理實現細節以成功執行操做。它可能會將條件轉換爲 SQL 或 ORM 查詢,或者遍歷內存中的集合。可是,這並不重要,由於實現可能處理它。

規格模式

對標準對象的常見實現就是規格模式。規範是一個簡單的謂詞,它接收領域對象並返回一個布爾值。給定一個領域對象,若是指定了規格,它將返回 true,不然返回 false:

interface PostSpecification
{
    /**
     * @return boolean
     */
    public function specifies(Post $aPost);
}

咱們的倉儲須要須要一個 query 方法:

interface PostRepository
{
    // ...
    public function query($specification);
}

內存的實現

做爲一個例子,若是咱們想經過使用內存中實現的規格在 PostRepository 中複製 lastestPost 查詢方法,則它看起來像這樣:

namespace Infrastructure\Persistence\InMemory;
use Domain\Model\Post;
interface InMemoryPostSpecification
{
    /**
     * @return boolean
     */
    public function specifies(Post $aPost);
}

內存實現的 lastestPosts 行爲看起來像這樣:

namespace Infrastructure\Persistence\InMemory;
use Domain\Model\Post;
class InMemoryLatestPostSpecification
    implements InMemoryPostSpecification
{
    private $since;
    public function __construct(\DateTimeImmutable $since)
    {
        $this->since = $since;
    }
    public function specifies(Post $aPost)
    {
        return $aPost->createdAt() > $this->since;
    }
}

倉儲實現的 query 方法看起來像這樣:

class InMemoryPostRepository implements PostRepository
{
// ...
    /**
     * @param InMemoryPostSpecification $specification
     *
     * @return Post[]
     */
    public function query($specification)
    {
        return $this->filterPosts(
            function (Post $post) use($specification) {
                return $specification->specifies($post);
            }
        );
    }
}

從倉儲中取回全部最新的 posts,就像建立上述實現的定製實例同樣簡單。

$latestPosts = $postRepository->query(
    new InMemoryLatestPostSpecification(new \DateTimeImmutable('-24'))
);

SQL 的實現

一個標準的規格很是適合於內存中的實現。可是,因爲咱們沒有對 SQL 實現預先在內存里加載全部領域對象,咱們就須要對這些用例有更明確的規格:

namespace Infrastructure\Persistence\Sql;
interface SqlPostSpecification
{
    /**
    * @return string
    */
    public function toSqlClauses();
}

這個規格的 SQL 實現看起來像這樣:

namespace Infrastructure\Persistence\Sql;
class SqlLatestPostSpecification implements SqlPostSpecification
{
    private $since;
    public function __construct(\DateTimeImmutable $since)
    {
        $this->since = $since;
    }
    public function toSqlClauses()
    {
        return "created_at >'" .
            $this->since->format('Y-m-d H:i:s') .
            "'";
    }
}

以及這裏一個查詢的例子,SQLPostRepository 的實現:

class SqlPostRepository implements PostRepository
{
// ...
/**
 * @param SqlPostSpecification $specification
 *
 * @return Post[]
 */
    public function query($specification)
    {
        return $this->retrieveAll(
            'SELECT * FROM posts WHERE ' .
            $specification->toSqlClauses()
        );
    }
    private function retrieveAll($sql, array $parameters = [])
    {
        $st = $this->pdo->prepare($sql);
        $st->execute($parameters);
        return array_map(function ($row) {
            return $this->buildPost($row);
        }, $st->fetchAll(\PDO::FETCH_ASSOC));
    }
}

事務管理

領域模型不是管理事務的地方。應用在領域模型上的操做對持久化機制應該是不可知的。解決這個問題的一個經常使用方法就是在應用層放置一個 Facade,從而將相關的用例分組在一塊兒。當一個 Facade 的方法從 UI 層調起,業務方法開始一個事務。一旦完成,Facade 經過事務提交結束交互。若是發生任何錯誤,事務就會回滾:

use Doctrine\ORM\EntityManager;

class SomeApplicationServiceFacade
{
    private $em;

    public function __construct(EntityManager $em)
    {
        $this->em = $em;
    }

    public function doSomeUseCaseTask()
    {
        try {
            $this->em->getConnection()->beginTransaction();
// Use domain model
            $this->em->getConnection()->commit();
        } catch (Exception $e) {
            $this->em->getConnection()->rollback();
            throw $e;
        }
    }
}

Facade 帶來的問題是,咱們必須一遍一遍的重複相同的樣板代碼。若是咱們統一執行用例的方式,則可使用裝飾模式(Decorator Pattern)將他們包裝在事務中:

interface TransactionalSession
{
    /**
     * @param callable $operation
     * @return mixed
     */
    public function executeAtomically(callable $operation);
}

裝飾模式可使任何應用服務的事務性像這樣簡單:

class TransactionalApplicationService implements ApplicationService
{
    private $session;
    private $service;
    public function __construct(
        ApplicationService $service,
        TransactionalSession $session
    ) {
        $this->session = $session;
        $this->service = $service;
    }
    public function execute(BaseRequest $request)
    {
        $operation = function() use($request) {
            return $this->service->execute($request);
        };
        return $this->session->executeAtomically(
            $operation->bindTo($this)
        );
    }
}

以後,咱們能夠選擇建立一個 Doctrine 事務性會話實現:

class DoctrineSession implements TransactionalSession
{
    private $entityManager;
    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }
    public function executeAtomically(callable $operation)
    {
        return $this->entityManager->transactional($operation);
    }
}

如今,咱們有在事務中執行用例的全部功能:

$useCase = new TransactionalApplicationService(
    new SomeApplicationService(
// ...
    ),
    new DoctrineSession(
// ...
    )
);
$response = $useCase->execute();

測試倉儲

爲了確保倉儲在生產中工做正常,咱們須要測試其實現。爲了達到這個,咱們必須測試系統邊界,確保咱們全部的指望是正確的。

在 Doctrine 測試的例子中,設置會有一點複雜:

use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools;
use Domain\Model\Post;

class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
    private $postRepository;

    public function setUp()
    {
        $this->postRepository = $this->createPostRepository();
    }

    private function createPostRepository()
    {
        $this->addCustomTypes();
        $em = $this->initEntityManager();
        $this->initSchema($em);
        return new PrecociousDoctrinePostRepository($em);
    }

    private function addCustomTypes()
    {
        if (!Type::hasType('post_id')) {
            Type::addType(
                'post_id',
                'Infrastructure\Persistence\Doctrine\Types\PostIdType'
            );
        }
        if (!Type::hasType('body')) {
            Type::addType(
                'body',
                'Infrastructure\Persistence\Doctrine\Types\BodyType'
            );
        }
    }

    protected function initEntityManager()
    {
        return EntityManager::create(
            ['url' => 'sqlite:///:memory:'],
            Tools\Setup::createXMLMetadataConfiguration(
                ['/Path/To/Infrastructure/Persistence/Doctrine/Mapping'],
                $devMode = true
            )
        );
    }

    private function initSchema(EntityManager $em)
    {
        $tool = new Tools\SchemaTool($em);
        $tool->createSchema([
            $em->getClassMetadata('Domain\Model\Post')
        ]);
    }
// ...
}

class PrecociousDoctrinePostRepository extends DoctrinePostRepository
{
    public function persist(Post $aPost)
    {
        parent::persist($aPost);
        $this->em->flush();
    }

    public function remove(Post $aPost)
    {
        parent::remove($aPost);
        $this->em->flush();
    }
}

一旦咱們把環境設置好,咱們就能夠繼續測試倉儲的行爲:

class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function itShouldRemovePost()
    {
        $post = $this->persistPost('irrelevant body');
        $this->postRepository->remove($post);
        $this->assertPostExist($post->id());
    }
    private function assertPostExist($id)
    {
        $result = $this->postRepository->postOfId($id);
        $this->assertNull($result);
    }
    private function persistPost(
        $body,
        \DateTimeImmutable $createdAt = null
    ) {
        $this->postRepository->add(
            $post = new Post(
                $this->postRepository->nextIdentity(),
                new Body($body),
                $createdAt
            )
        );
        return $post;
    }
}

根據咱們先前的斷言,若是咱們保存一個 Post,咱們但願找到它處於徹底相同的狀態。

如今,咱們能夠經過指定日期查詢最新的 posts,以繼續咱們的測試:

class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function itShouldFetchLatestPosts()
    {
        $this->persistPost(
            'a year ago', new \DateTimeImmutable('-1 year')
        );
        $this->persistPost(
            'a month ago', new \DateTimeImmutable('-1 month')
        );
        $this->persistPost(
            'few hours ago', new \DateTimeImmutable('-3 hours')
        );
        $this->persistPost(
            'few minutes ago', new \DateTimeImmutable('-2 minutes')
        );
        $posts = $this->postRepository->latestPosts(
            new \DateTimeImmutable('-24 hours')
        );
        $this->assertCount(2, $posts);
        $this->assertEquals(
            'few hours ago', $posts[0]->body()->content()
        );
        $this->assertEquals(
            'few minutes ago', $posts[1]->body()->content()
        );
    }
}

用內存實現測試服務

設置徹底持久化的倉儲實現可能會很複雜,而且會致使執行緩慢。你應該關注保持你的測試快速。完成整個數據庫設置,而後查詢將極大地下降你的速度。在內存中實現可能有助於將持久化決策延遲到最後。咱們能夠用以前相同的方式測試,但此次,咱們將使用功能齊全,快速簡單的內存實現:

class MyServiceTest extends \PHPUnit_Framework_TestCase
{
    private $service;
    public function setUp()
    {
        $this->service = new MyService(
            new InMemoryPostRepository()
        );
    }
}

小結

倉儲是扮演存儲位置的一種機制。DAO 和倉儲之間的區別在於,DAO是遵循數據庫優先,用許多底層方法來下降查詢數據庫的內聚性。根據底層的持久化機制,咱們已經看到不一樣的倉儲方法:

  • 面向集合的倉儲(Collection-oriented Repositories) 傾向於使用領域模型,即便他們保留實體。從客戶端的角度來看,面向集合的倉儲看起來像一個集合(Set)。這無需對實體更新進行顯示的持久化調用,由於倉儲能夠更新對象上的更改。咱們也探索瞭如何使用 Doctrine 做爲此類倉儲的基礎持久化機制。
  • 面向持久化的倉儲(Persistence-oriented Repositories) 須要明確持久化調用,由於它們並不跟蹤對象的變化。咱們探索了 Redis 和普通 SQL 的實現。

在此過程當中,咱們發現規格是一種模式,能夠幫助咱們在不犧牲靈活性和內聚性的前提下查詢數據庫。咱們還研究瞭如何經過簡單,快速的內存倉儲實現來管理事務以及如何測試咱們的服務。

相關文章
相關標籤/搜索