《领域驱动设计之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 的实现。

在此过程当中,咱们发现规格是一种模式,能够帮助咱们在不牺牲灵活性和内聚性的前提下查询数据库。咱们还研究了如何经过简单,快速的内存仓储实现来管理事务以及如何测试咱们的服务。

相关文章
相关标签/搜索