PHP中的正确存储库模式设计?

Jon*_*han 265 php database repository repository-pattern laravel

前言:我正在尝试在具有关系数据库的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)
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

问题#1:字段太多

所有这些find方法都使用select all fields(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));
    }
}
Run Code Online (Sandbox Code Playgroud)

使用我的存储库方法,我不想最终得到这个:

<?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))
    }

}
Run Code Online (Sandbox Code Playgroud)

问题#3:无法匹配界面

我看到使用存储库接口的好处,所以我可以换出我的实现(用于测试目的或其他).我对接口的理解是它们定义了一个实现必须遵循的契约.这很棒,直到你开始向你的存储库添加其他方法findAllInCountry().现在我需要更新我的界面也有这个方法,否则,其他实现可能没有它,这可能会破坏我的应用程序.这感觉很疯狂......一个尾巴摇着狗的情况.

规格模式?

这使我相信,库应该只有方法固定数量(如save(),remove(),find(),findAll(),等).但是,我如何运行特定的查找?我听说过规范模式,但在我看来,这只会减少整个记录集(通过IsSatisfiedBy()),如果您从数据库中提取数据,这显然会产生严重的性能问题.

救命?

显然,在使用存储库时,我需要重新思考一些事情.任何人都可以启发如何最好地处理这个问题?

Jon*_*han 196

我以为我会回答我自己的问题.以下是我原始问题中解决问题1-3的一种方法.

免责声明:在描述模式或技术时,我可能并不总是使用正确的术语.对不起.

目标:

  • 创建用于查看和编辑的基本控制器的完整示例Users.
  • 所有代码必须完全可测试且可模拟.
  • 控制器应该不知道数据的存储位置(意味着可以更改).
  • 显示SQL实现的示例(最常见).
  • 为获得最佳性能,控制器应仅接收所需数据 - 无需额外字段.
  • 实现应该利用某种类型的数据映射器来简化开发.
  • 实现应该能够执行复杂的数据查找.

解决方案

我将持久存储(数据库)交互分为两类:R(读取)和CUD(创建,更新,删除).我的经验是读取确实是导致应用程序变慢的原因.虽然数据操作(CUD)实际上较慢,但它的发生频率要低得多,因此不太重要.

CUD(创建,更新,删除)很简单.这将涉及使用实际模型,然后将其传递给我Repositories的持久性.请注意,我的存储库仍将提供Read方法,但仅用于创建对象,而不是显示.稍后会详细介绍.

R(阅读)并不那么容易.这里没有模型,只是重视对象.如果您愿意,请使用数组.这些对象可能代表单个模型或许多模型的混合,实际上是任何东西.这些并不是很有趣,但它们是如何生成的.我正在使用我正在呼唤的东西Query Objects.

代码:

用户模型

让我们从基本的用户模型开始.请注意,根本没有ORM扩展或数据库内容.只是纯粹的模特荣耀.添加你的getter,setter,验证等等.

class User
{
    public $id;
    public $first_name;
    public $last_name;
    public $gender;
    public $email;
    public $password;
}
Run Code Online (Sandbox Code Playgroud)

存储库接口

在创建用户存储库之前,我想创建我的存储库接口.这将定义存储库必须遵循的"合同",以便我的控制器使用.请记住,我的控制器不知道数据的实际存储位置.

请注意,我的存储库只会包含这三种方法.该save()方法负责创建和更新用户,这取决于用户对象是否具有id集.

interface UserRepositoryInterface
{
    public function find($id);
    public function save(User $user);
    public function remove(User $user);
}
Run Code Online (Sandbox Code Playgroud)

SQL存储库实现

现在创建我的接口实现.如上所述,我的例子将是一个SQL数据库.请注意使用数据映射器来防止必须编写重复的SQL查询.

class SQLUserRepository implements UserRepositoryInterface
{
    protected $db;

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

    public function find($id)
    {
        // Find a record with the id = $id
        // from the 'users' table
        // and return it as a User object
        return $this->db->find($id, 'users', 'User');
    }

    public function save(User $user)
    {
        // Insert or update the $user
        // in the 'users' table
        $this->db->save($user, 'users');
    }

    public function remove(User $user)
    {
        // Remove the $user
        // from the 'users' table
        $this->db->remove($user, 'users');
    }
}
Run Code Online (Sandbox Code Playgroud)

查询对象接口

现在我们的存储库处理了CUD(创建,更新,删除),我们可以专注于R(读取).查询对象只是某种类型的数据查找逻辑的封装.它们不是查询构建器.通过像我们的存储库那样抽象它,我们可以更改它的实现并更容易地测试它.查询对象的例子可能是一个AllUsersQueryAllActiveUsersQuery,甚至MostCommonUserFirstNames.

您可能会想"我不能只在我的存储库中为这些查询创建方法吗?" 是的,但这就是为什么我不这样做:

  • 我的存储库用于处理模型对象.在一个真实世界的应用程序中,password如果我想列出所有用户,为什么我需要获得该字段?
  • 存储库通常是特定于模型的,但查询通常涉及多个模型.那么你把你的方法放在哪个存储库中?
  • 这使我的存储库非常简单 - 不是一个膨胀的方法类.
  • 所有查询现在都组织到自己的类中.
  • 实际上,在这一点上,存储库只是为了抽象我的数据库层而存在.

对于我的例子,我将创建一个查询对象来查找"AllUsers".这是界面:

interface AllUsersQueryInterface
{
    public function fetch($fields);
}
Run Code Online (Sandbox Code Playgroud)

查询对象实现

这是我们可以再次使用数据映射器来帮助加快开发的地方.请注意,我允许对返回的数据集进行一次调整 - 字段.这就是我想要操纵执行的查询.请记住,我的查询对象不是查询构建器.他们只是执行特定的查询.但是,因为我知道我可能会经常使用这个,在许多不同的情况下,我给自己指定字段的能力.我永远不想回到我不需要的领域!

class AllUsersQuery implements AllUsersQueryInterface
{
    protected $db;

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

    public function fetch($fields)
    {
        return $this->db->select($fields)->from('users')->orderBy('last_name, first_name')->rows();
    }
}
Run Code Online (Sandbox Code Playgroud)

在继续讨论控制器之前,我想展示另一个例子来说明这是多么强大.也许我有一个报告引擎,需要为其创建报​​告AllOverdueAccounts.这对我的数据映射器来说可能很棘手,我可能想SQL在这种情况下写一些实际的东西.没问题,这是这个查询对象的样子:

class AllOverdueAccountsQuery implements AllOverdueAccountsQueryInterface
{
    protected $db;

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

    public function fetch()
    {
        return $this->db->query($this->sql())->rows();
    }

    public function sql()
    {
        return "SELECT...";
    }
}
Run Code Online (Sandbox Code Playgroud)

这很好地将我的所有逻辑保存在一个类中,并且很容易测试.我可以嘲笑我的内容,甚至完全使用不同的实现.

控制器

现在是有趣的部分 - 将所有部分组合在一起.请注意,我正在使用依赖注入.通常,依赖项被注入到构造函数中,但实际上我更喜欢将它们注入到我的控制器方法(路由)中.这最小化了控制器的对象图,我实际上发现它更清晰.注意,如果您不喜欢这种方法,只需使用传统的构造方法.

class UsersController
{
    public function index(AllUsersQueryInterface $query)
    {
        // Fetch user data
        $users = $query->fetch(['first_name', 'last_name', 'email']);

        // Return view
        return Response::view('all_users.php', ['users' => $users]);
    }

    public function add()
    {
        return Response::view('add_user.php');
    }

    public function insert(UserRepositoryInterface $repository)
    {
        // Create new user model
        $user = new User;
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the new user
        $repository->save($user);

        // Return the id
        return Response::json(['id' => $user->id]);
    }

    public function view(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('view_user.php', ['user' => $user]);
    }

    public function edit(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('edit_user.php', ['user' => $user]);
    }

    public function update(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Update the user
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the user
        $repository->save($user);

        // Return success
        return true;
    }

    public function delete(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Delete the user
        $repository->delete($user);

        // Return success
        return true;
    }
}
Run Code Online (Sandbox Code Playgroud)

最后的想法:

这里要注意的重要事项是,当我修改(创建,更新或删除)实体时,我正在使用真实的模型对象,并通过我的存储库执行持久性.

但是,当我显示(选择数据并将其发送到视图)时,我不使用模型对象,而是使用普通的旧值对象.我只选择了我需要的字段,并且它的设计使我可以最大化我的数据查找性能.

我的存储库保持非常干净,而这个"混乱"被组织到我的模型查询中.

我使用数据映射器来帮助开发,因为为常见任务编写重复的SQL是非常荒谬的.但是,您绝对可以在需要的地方编写SQL(复杂的查询,报告等).当你这样做时,它很好地隐藏在一个正确命名的类中.

我很想听听你对我的态度的看法!


2015年7月更新:

我在评论中被问到我最终得到了这一切.嗯,实际上并没有那么远.说实话,我仍然不喜欢存储库.我觉得它们对于基本查找来说有点过分(特别是如果你已经在使用ORM),并且在处理更复杂的查询时会很麻烦.

我通常使用ActiveRecord样式的ORM,所以我经常在整个应用程序中直接引用这些模型.但是,在我有更复杂的查询的情况下,我将使用查询对象使这些更可重用.我还应该注意,我总是将我的模型注入到我的方法中,使我们在测试中更容易模拟.

  • @PeeHaa再次,这是为了保持示例简单.如果它们与手头的主题不相关,那么将一些代码留在一个例子中是很常见的.实际上,我会传递我的依赖项. (4认同)
  • 有趣的是,您从"读取"中拆分了"创建","更新"和"删除".认为值得一提的是Command Query Responsibility Segregation(CQRS)正式就是这样做的.http://martinfowler.com/bliki/CQRS.html (4认同)
  • @Jonathan你回答自己的问题已经有一年半了.我想知道你是否仍然对你的答案感到满意,如果这是你现在大多数项目的主要解决方案?过去几周我一直在阅读关于存储库的分配,我看到很多人对如何实现它们有自己的解释.你调用它查询对象,但这是一个现有的模式吗?我想我已经看到它被用在其他语言中. (2认同)

rya*_*234 48

根据我的经验,以下是您的问题的一些答案:

问:我们如何处理带回我们不需要的字段?

答:根据我的经验,这实际上归结为处理完整实体与临时查询.

一个完整的实体就像一个User对象.它具有属性和方法等.它是您的代码库中的一等公民.

ad-hoc查询返回一些数据,但除此之外我们不知道任何事情.当数据在应用程序中传递时,它就是在没有上下文的情况下完成的.是一个User吗?A User附有一些Order信息?我们真的不知道.

我更喜欢使用完整的实体.

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

  1. 积极地缓存实体,因此您只需从数据库中支付一次读取价格.
  2. 花更多时间为您的实体建模,以便它们之间有很好的区别.(考虑将大型实体拆分为两个较小的实体,等等)
  3. 考虑拥有多个版本的实体.您可以拥有一个User用于后端,也可以UserSmall用于AJAX调用.一个可能有10个属性,一个有3个属性.

使用即席查询的缺点:

  1. 您最终会在许多查询中获得基本相同的数据.例如,使用a User,您最终会select *为许多调用编写基本相同的内容.一个电话将获得10个字段中的8个,一个将获得10个中的5个,一个将获得7个中的7个.为什么不用10个中的10个中的一个调用替换所有字段?这很糟糕的原因是重新分解/测试/模拟是谋杀.
  2. 随着时间的推移,很难对您的代码进行高级别的推理.而不是像"为什么User这么慢?"这样的陈述.您最终会追踪一次性查询,因此错误修复往往很小并且本地化.
  3. 替换底层技术真的很难.如果您现在将所有内容存储在MySQL中并希望转移到MongoDB,那么替换100个ad-hoc调用要比少数几个实体要困难得多.

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

答:除了整合电话,我还没有真正看到过这方面的任何方式.存储库中的方法调用实际上映射到应用程序中的功能.功能越多,数据特定的调用越多.您可以推回功能并尝试将类似的呼叫合并为一个.

一天结束时的复杂性必须存在于某个地方.使用存储库模式,我们已将其推送到存储库界面,而不是制作一堆存储过程.

有时候我必须告诉自己,"好吧,它必须放在某个地方!没有银子弹."

  • R-CUD方法实际上是CQRS (3认同)
  • @ ryan1234"一天结束时的复杂性必须存在于某个地方." 这次真是万分感谢.让我感觉更好. (2认同)

Con*_*enu 18

我使用以下接口:

  • Repository - 加载,插入,更新和删除实体
  • Selector - 在存储库中查找基于过滤器的实体
  • Filter - 封装过滤逻辑

Repository的数据库不可知; 实际上它没有指定任何持久性; 它可以是任何东西:SQL数据库,xml文件,远程服务,来自外太空的外星人等.对于搜索功能,可以过滤,编码,排序和计算的Repository构造.最后,选择器从持久性中提取一个或多个.SelectorLIMITEntities

以下是一些示例代码:

<?php
interface Repository
{
    public function addEntity(Entity $entity);

    public function updateEntity(Entity $entity);

    public function removeEntity(Entity $entity);

    /**
     * @return Entity
     */
    public function loadEntity($entityId);

    public function factoryEntitySelector():Selector
}


interface Selector extends \Countable
{
    public function count();

    /**
     * @return Entity[]
     */
    public function fetchEntities();

    /**
     * @return Entity
     */
    public function fetchEntity();
    public function limit(...$limit);
    public function filter(Filter $filter);
    public function orderBy($column, $ascending = true);
    public function removeFilter($filterName);
}

interface Filter
{
    public function getFilterName();
}
Run Code Online (Sandbox Code Playgroud)

然后,一个实现:

class SqlEntityRepository
{
    ...
    public function factoryEntitySelector()
    {
        return new SqlSelector($this);
    }
    ...
}

class SqlSelector implements Selector
{
    ...
    private function adaptFilter(Filter $filter):SqlQueryFilter
    {
         return (new SqlSelectorFilterAdapter())->adaptFilter($filter);
    }
    ...
}
class SqlSelectorFilterAdapter
{
    public function adaptFilter(Filter $filter):SqlQueryFilter
    {
        $concreteClass = (new StringRebaser(
            'Filter\\', 'SqlQueryFilter\\'))
            ->rebase(get_class($filter));

        return new $concreteClass($filter);
    }
}
Run Code Online (Sandbox Code Playgroud)

ideea是泛型Selector用途,Filter但实现SqlSelector使用SqlFilter; 使SqlSelectorFilterAdapter通用适应Filter具体SqlFilter.

客户端代码创建Filter对象(即通用过滤器),但在选择器的具体实现中,这些过滤器在SQL过滤器中进行转换.

其他选择器实现,比如InMemorySelector,转换FilterInMemoryFilter使用它们的特定InMemorySelectorFilterAdapter; 所以,每个选择器实现都有自己的过滤器适配器.

使用此策略,我的客户端代码(在bussines层中)不关心特定的存储库或选择器实现.

/** @var Repository $repository*/
$selector = $repository->factoryEntitySelector();
$selector->filter(new AttributeEquals('activated', 1))->limit(2)->orderBy('username');
$activatedUserCount = $selector->count(); // evaluates to 100, ignores the limit()
$activatedUsers = $selector->fetchEntities();
Run Code Online (Sandbox Code Playgroud)

PS这是我的真实代码的简化


Wil*_*don 5

我将在此方面加一点,因为我目前正试图自己掌握所有这些。

#1和2

这是您的ORM进行繁重工作的理想场所。如果您使用的是实现某种ORM的模型,则可以使用它的方法来处理这些事情。根据需要制作实现Eloquent方法的orderBy函数。例如,使用Eloquent:

class DbUserRepository implements UserRepositoryInterface
{
    public function findAll()
    {
        return User::all();
    }

    public function get(Array $columns)
    {
       return User::select($columns);
    }
Run Code Online (Sandbox Code Playgroud)

您似乎正在寻找的是ORM。没有理由您的存储库不能基于一个。这需要用户扩展才能,但我个人认为这不是问题。

但是,如果您确实想避免使用ORM,则必须“自己动手”以获取所需的内容。

#3

接口不是硬性要求和快速要求。可以实现某个接口并将其添加到其中。它不能做的是无法实现该接口的必需功能。您还可以扩展类之类的接口,以保持事物干燥。

就是说,我才刚刚开始了解,但是这些认识帮助了我。

  • 我不喜欢这种方法的是,如果您有 MongoUserRepository,那么它和您的 DbUserRepository 将返回不同的对象。Db 返回一个 Eloquent\Model,而 Mongo 有自己的东西。当然,更好的实现是让两个存储库返回单独的 Entity\User 类的实例/集合。这样,当您切换到使用 MongoRepository 时,您就不会错误地依赖 Eloquent\Model 的 DB 方法 (2认同)