《领域驱动设计之PHP实现》全书翻译 - 仓储

 阅读约 61 分钟

仓储(Repositories)

  1. 《领域驱动设计之PHP实现》全书翻译 - DDD入门
  2. 《领域驱动设计之PHP实现》全书翻译 - 架构风格
  3. 《领域驱动设计之PHP实现》全书翻译 - 值对象
  4. 《领域驱动设计之PHP实现》全书翻译 - 实体
  5. 《领域驱动设计之PHP实现》全书翻译 - 服务
  6. 《领域驱动设计之PHP实现》全书翻译 - 领域事件
  7. 《领域驱动设计之PHP实现》全书翻译 - 模块
  8. 《领域驱动设计之PHP实现》全书翻译 - 聚合
  9. 《领域驱动设计之PHP实现》全书翻译 - 工厂
  10. 《领域驱动设计之PHP实现》全书翻译 - 仓储
  11. 《领域驱动设计之PHP实现》全书翻译 - 应用服务
  12. 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
  13. 《用PHP实现六边形架构》

为了与领域对象交互,你需要保留对它的引用。达到这个目标的途径之一就是通过创建器(creation)。或者你可以遍历关联。在面向对象程序中,对象具有与其他对象的链接(引用),这使它们易于遍历,从而有助于模型的表达能力。但这有一点很重要:你需要一种机制来检索第一个对象:聚合根。

仓储作为存储位置,其检索到的对象以与存储对象完全相同的状态返回。在领域驱动设计中,每个聚合类型通常都有一个唯一的关联存储库,用于持久化和检索需求。但是,在需要共享一个聚合对象层次结构的情况下,这些类型可能共享一个存储库。

只要你从仓储中成功取回聚合,你做出每个更改都是持久的,从而无需再返回仓储。

定义

Martin Fowler 如此定义仓储:

仓储是领域和数据映射层之间的机制,可以说是在内存中的领域对象集合。客户端对象以声明方式构造查询询规格,并将其提交给仓储以满足需要。对象可以添加到仓储以及从其中移除,因为它们可以来自于一个简单的对象集合,并且由仓储封装的映射代码将在后台执行对应的操作。从概念上讲,仓储封闭了被持久化到数据存储中的对象集合以及对其的操作,它提供了关于持久层更面向对象的视角。仓储还支持在领域和数据映射层之间实现清晰的隔离和单向依赖的目标。

仓储不是 DAO

数据访问对象(DAO)是持久化领域对象到数据库的常见模式。这很容易混淆 DAO 模式和仓储。其中最大的不同就是仓储表示集合,而 DAO 更靠近数据库一端并且通常以表为中心。通常,DAO 包含 CRUD 方法,用于特定的领域对象。让我们看看一个常见的 DAO 接口是怎样:

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 通常被视为通向数据库的网关,因此它相对容易使用许多特定方法来大大降低内聚性来查询数据库:

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 中的元素,则不会成功。这在我们的用例中很有用,因为每个聚合都具有与根实体相关联的唯一标识。

考虑一个例子,我们有以下领域模型:

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 的实现。

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

阅读 328更新于 1月14日

推荐阅读
目录