前言:我试图在关系数据库的MVC架构中使用存储库模式。

我最近开始学习PHP中的TDD,我意识到我的数据库与应用程序的其余部分耦合得太紧密了。我读过关于存储库和使用IoC容器将其“注入”到控制器的文章。非常酷的东西。但是现在有一些关于存储库设计的实际问题。考虑下面的例子。

<?php

class DbUserRepository implements UserRepositoryInterface
{
    protected $db;

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

    public function findAll()
    {
    }

    public function findById($id)
    {
    }

    public function findByName($name)
    {
    }

    public function create($user)
    {
    }

    public function remove($user)
    {
    }

    public function update($user)
    {
    }
}

问题#1:字段太多

所有这些查找方法都使用select所有字段(select *)方法。然而,在我的应用程序中,我总是试图限制我获得的字段数量,因为这通常会增加开销并降低速度。对于使用这种模式的用户,如何处理这种情况?

问题2:方法太多

虽然这个类现在看起来不错,但我知道在真实的应用程序中,我需要更多的方法。例如:

findAllByNameAndStatus findAllInCountry findAllWithEmailAddressSet findAllByAgeAndGender findAllByAgeAndGenderOrderByAge 等。

如你所见,可能有一个非常非常长的方法列表。然后,如果您添加了上述字段选择问题,问题就会恶化。在过去,我通常只是把所有这些逻辑放在我的控制器中:

<?php

class MyController
{
    public function users()
    {
        $users = User::select('name, email, status')
            ->byCountry('Canada')->orderBy('name')->rows();

        return View::make('users', array('users' => $users));
    }
}

使用我的存储库方法,我不想以这样的结果结束:

<?php

class MyController
{
    public function users()
    {
        $users = $this->repo->get_first_name_last_name_email_username_status_by_country_order_by_name('Canada');

        return View::make('users', array('users' => $users))
    }

}

问题3:不可能匹配接口

I see the benefit in using interfaces for repositories, so I can swap out my implementation (for testing purposes or other). My understanding of interfaces is that they define a contract that an implementation must follow. This is great until you start adding additional methods to your repositories like findAllInCountry(). Now I need to update my interface to also have this method, otherwise, other implementations may not have it, and that could break my application. By this feels insane...a case of the tail wagging the dog.

规范模式吗?

这让我相信存储库应该只有固定数量的方法(如save()、remove()、find()、findAll()等)。但是如何运行特定的查找呢?我听说过规范模式,但在我看来,这只减少了整个记录集(通过IsSatisfiedBy()),如果从数据库提取,这显然有主要的性能问题。

帮助吗?

显然,在使用存储库时,我需要重新考虑一些事情。有谁能告诉我这个最好怎么处理吗?


当前回答

我认为graphQL在这种情况下是一个很好的候选者,它可以在不增加数据存储库复杂性的情况下提供大规模查询语言。

但是,如果您现在不想使用graphQL,还有另一种解决方案。通过使用DTO,其中一个对象用于在进程之间传输数据,在本例中是在服务/控制器和存储库之间。

上面已经给出了一个优雅的答案,但我将尝试给出另一个例子,我认为它更简单,可以作为一个新项目的起点。

如代码所示,对于CRUD操作,我们只需要4个方法。find方法将用于通过传递对象参数来列出和读取。 后端服务可以基于URL查询字符串或特定参数构建已定义的查询对象。

如果需要,查询对象(SomeQueryDto)也可以实现特定的接口。并且很容易在不增加复杂性的情况下进行扩展。

<?php

interface SomeRepositoryInterface
{
    public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function delete(int $id): void;

    public function find(SomeEnitityQueryInterface $query): array;
}

class SomeRepository implements SomeRepositoryInterface
{
    public function find(SomeQueryDto $query): array
    {
        $qb = $this->getQueryBuilder();

        foreach ($query->getSearchParameters() as $attribute) {
            $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
        }

        return $qb->get();
    }
}

/**
 * Provide query data to search for tickets.
 *
 * @method SomeQueryDto userId(int $id, string $operator = null)
 * @method SomeQueryDto categoryId(int $id, string $operator = null)
 * @method SomeQueryDto completedAt(string $date, string $operator = null)
 */
class SomeQueryDto
{
    /** @var array  */
    const QUERYABLE_FIELDS = [
        'id',
        'subject',
        'user_id',
        'category_id',
        'created_at',
    ];

    /** @var array  */
    const STRING_DB_OPERATORS = [
        'eq' => '=', // Equal to
        'gt' => '>', // Greater than
        'lt' => '<', // Less than
        'gte' => '>=', // Greater than or equal to
        'lte' => '<=', // Less than or equal to
        'ne' => '<>', // Not equal to
        'like' => 'like', // Search similar text
        'in' => 'in', // one of range of values
    ];

    /**
     * @var array
     */
    private $searchParameters = [];

    const DEFAULT_OPERATOR = 'eq';

    /**
     * Build this query object out of query string.
     * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
     */
    public static function buildFromString(string $queryString): SomeQueryDto
    {
        $query = new self();
        parse_str($queryString, $queryFields);

        foreach ($queryFields as $field => $operatorAndValue) {
            [$operator, $value] = explode(':', $operatorAndValue);
            $query->addParameter($field, $operator, $value);
        }

        return $query;
    }

    public function addParameter(string $field, string $operator, $value): SomeQueryDto
    {
        if (!in_array($field, self::QUERYABLE_FIELDS)) {
            throw new \Exception("$field is invalid query field.");
        }
        if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
            throw new \Exception("$operator is invalid query operator.");
        }
        if (!is_scalar($value)) {
            throw new \Exception("$value is invalid query value.");
        }

        array_push(
            $this->searchParameters,
            [
                'field' => $field,
                'operator' => self::STRING_DB_OPERATORS[$operator],
                'value' => $value
            ]
        );

        return $this;
    }

    public function __call($name, $arguments)
    {
        // camelCase to snake_case
        $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));

        if (in_array($field, self::QUERYABLE_FIELDS)) {
            return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
        }
    }

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

使用示例:

$query = new SomeEnitityQuery();
$query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
$entities = $someRepository->find($query);

// Or by passing the HTTP query string
$query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
$entities = $someRepository->find($query);

其他回答

我建议https://packagist.org/packages/prettus/l5-repository作为供应商来实现存储库/标准等…在Laravel5:D

Issue #3: Impossible to match an interface I see the benefit in using interfaces for repositories, so I can swap out my implementation (for testing purposes or other). My understanding of interfaces is that they define a contract that an implementation must follow. This is great until you start adding additional methods to your repositories like findAllInCountry(). Now I need to update my interface to also have this method, otherwise, other implementations may not have it, and that could break my application. By this feels insane...a case of the tail wagging the dog.

我的直觉告诉我,这可能需要一个实现查询优化方法和泛型方法的接口。对性能敏感的查询应该有针对性的方法,而不频繁或轻量级的查询则由通用处理程序处理,这可能会使控制器做更多的杂耍。

泛型方法将允许实现任何查询,因此将防止在过渡期间发生破坏性更改。目标方法允许您在有意义时优化调用,并且它可以应用于多个服务提供者。

这种方法类似于硬件实现执行特定的优化任务,而软件实现只做简单的工作或灵活的实现。

根据我的经验,以下是对你们问题的一些回答:

问:我们如何处理收回我们不需要的土地?

答:从我的经验来看,这实际上可以归结为处理完整实体与临时查询。

一个完整的实体类似于一个User对象。它有属性和方法等。它是代码库中的一等公民。

一个特别查询返回一些数据,但除此之外我们什么都不知道。当数据在应用程序中传递时,是在没有上下文的情况下完成的。它是用户吗?附带一些订单信息的用户?我们真的不知道。

我更喜欢与完整的实体一起工作。

你是对的,你经常会带回你不使用的数据,但你可以通过各种方式解决这个问题:

积极地缓存实体,这样您只需从数据库中支付一次读取代价。 花更多的时间建模你的实体,这样它们之间就有了很好的区别。(考虑将一个大实体分成两个小实体,等等) 考虑拥有多个版本的实体。你可以有一个User用于后端,也可以有一个UserSmall用于AJAX调用。一个可能有10个属性,一个有3个属性。

使用临时查询的缺点:

You end up with essentially the same data across many queries. For example, with a User, you'll end up writing essentially the same select * for many calls. One call will get 8 of 10 fields, one will get 5 of 10, one will get 7 of 10. Why not replace all with one call that gets 10 out of 10? The reason this is bad is that it is murder to re-factor/test/mock. It becomes very hard to reason at a high level about your code over time. Instead of statements like "Why is the User so slow?" you end up tracking down one-off queries and so bug fixes tend to be small and localized. It's really hard to replace the underlying technology. If you store everything in MySQL now and want to move to MongoDB, it's a lot harder to replace 100 ad-hoc calls than it is a handful of entities.

问:我的存储库中会有太多的方法。

答:除了整合通话之外,我还没有找到其他解决办法。存储库中的方法调用实际映射到应用程序中的功能。特性越多,特定于数据的调用就越多。您可以向后推功能,并尝试将类似的调用合并为一个。

一天结束的时候,复杂性必须存在于某个地方。使用存储库模式,我们将其推入存储库接口,而不是制造一堆存储过程。

有时我不得不告诉自己,“好吧,它必须在某个地方让步!”没有银弹。”

我同意@ryan1234的观点,你应该在代码中传递完整的对象,并且应该使用泛型查询方法来获取这些对象。

Model::where(['attr1' => 'val1'])->get();

对于外部/端点使用,我非常喜欢GraphQL方法。

POST /api/graphql
{
    query: {
        Model(attr1: 'val1') {
            attr2
            attr3
        }
    }
}

我认为graphQL在这种情况下是一个很好的候选者,它可以在不增加数据存储库复杂性的情况下提供大规模查询语言。

但是,如果您现在不想使用graphQL,还有另一种解决方案。通过使用DTO,其中一个对象用于在进程之间传输数据,在本例中是在服务/控制器和存储库之间。

上面已经给出了一个优雅的答案,但我将尝试给出另一个例子,我认为它更简单,可以作为一个新项目的起点。

如代码所示,对于CRUD操作,我们只需要4个方法。find方法将用于通过传递对象参数来列出和读取。 后端服务可以基于URL查询字符串或特定参数构建已定义的查询对象。

如果需要,查询对象(SomeQueryDto)也可以实现特定的接口。并且很容易在不增加复杂性的情况下进行扩展。

<?php

interface SomeRepositoryInterface
{
    public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function delete(int $id): void;

    public function find(SomeEnitityQueryInterface $query): array;
}

class SomeRepository implements SomeRepositoryInterface
{
    public function find(SomeQueryDto $query): array
    {
        $qb = $this->getQueryBuilder();

        foreach ($query->getSearchParameters() as $attribute) {
            $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
        }

        return $qb->get();
    }
}

/**
 * Provide query data to search for tickets.
 *
 * @method SomeQueryDto userId(int $id, string $operator = null)
 * @method SomeQueryDto categoryId(int $id, string $operator = null)
 * @method SomeQueryDto completedAt(string $date, string $operator = null)
 */
class SomeQueryDto
{
    /** @var array  */
    const QUERYABLE_FIELDS = [
        'id',
        'subject',
        'user_id',
        'category_id',
        'created_at',
    ];

    /** @var array  */
    const STRING_DB_OPERATORS = [
        'eq' => '=', // Equal to
        'gt' => '>', // Greater than
        'lt' => '<', // Less than
        'gte' => '>=', // Greater than or equal to
        'lte' => '<=', // Less than or equal to
        'ne' => '<>', // Not equal to
        'like' => 'like', // Search similar text
        'in' => 'in', // one of range of values
    ];

    /**
     * @var array
     */
    private $searchParameters = [];

    const DEFAULT_OPERATOR = 'eq';

    /**
     * Build this query object out of query string.
     * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
     */
    public static function buildFromString(string $queryString): SomeQueryDto
    {
        $query = new self();
        parse_str($queryString, $queryFields);

        foreach ($queryFields as $field => $operatorAndValue) {
            [$operator, $value] = explode(':', $operatorAndValue);
            $query->addParameter($field, $operator, $value);
        }

        return $query;
    }

    public function addParameter(string $field, string $operator, $value): SomeQueryDto
    {
        if (!in_array($field, self::QUERYABLE_FIELDS)) {
            throw new \Exception("$field is invalid query field.");
        }
        if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
            throw new \Exception("$operator is invalid query operator.");
        }
        if (!is_scalar($value)) {
            throw new \Exception("$value is invalid query value.");
        }

        array_push(
            $this->searchParameters,
            [
                'field' => $field,
                'operator' => self::STRING_DB_OPERATORS[$operator],
                'value' => $value
            ]
        );

        return $this;
    }

    public function __call($name, $arguments)
    {
        // camelCase to snake_case
        $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));

        if (in_array($field, self::QUERYABLE_FIELDS)) {
            return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
        }
    }

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

使用示例:

$query = new SomeEnitityQuery();
$query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
$entities = $someRepository->find($query);

// Or by passing the HTTP query string
$query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
$entities = $someRepository->find($query);