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

前言:我正在尝试在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 *

问题#2:方法太多

虽然这门课现在看起来不错,但我知道在现实世界的应用中,我需要更多的方法。例如:

  • FindAllByNameAndStatus
  • 查找全部在国内
  • 查找AllWithEmailAddressSet
  • 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:无法匹配接口

我看到了将接口用于存储库的好处,因此我可以交换我的实现(用于测试目的或其他目的)。我对接口的理解是,它们定义了实现必须遵循的协定。这非常有用,直到您开始向存储库添加其他方法,例如 。现在我需要更新我的接口以也具有此方法,否则,其他实现可能没有它,这可能会破坏我的应用程序。这感觉很疯狂...尾巴摇晃狗的情况。findAllInCountry()

规格模式?

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

帮助?

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


答案 1

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

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

目标:

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

解决方案

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

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

R(读取)不是那么容易。这里没有模型,只有值对象如果您愿意,可以使用数组。这些对象可以代表单个模型或许多模型的混合,实际上任何东西。这些本身并不是很有趣,但它们是如何产生的。我正在使用我所说的.Query Objects

代码:

用户模型

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

class User
{
    public $id;
    public $first_name;
    public $last_name;
    public $gender;
    public $email;
    public $password;
}

存储库接口

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

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

interface UserRepositoryInterface
{
    public function find($id);
    public function save(User $user);
    public function remove(User $user);
}

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');
    }
}

查询对象接口

现在,通过我们的存储库处理的CUD(创建,更新,删除),我们可以专注于R(读取)。查询对象只是某种类型的数据查找逻辑的封装。他们不是查询生成器。通过像我们的存储库一样抽象它,我们可以更改它的实现并更轻松地进行测试。查询对象的一个示例可能是 或 ,甚至 。AllUsersQueryAllActiveUsersQueryMostCommonUserFirstNames

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

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

对于我的示例,我将创建一个查询对象来查找“所有用户”。这是界面:

interface AllUsersQueryInterface
{
    public function fetch($fields);
}

查询对象实现

在这里,我们可以再次使用数据映射器来帮助加快开发速度。请注意,我允许对返回的数据集(字段)进行一次调整。这大约是我想要操作执行的查询的极限。请记住,我的查询对象不是查询生成器。它们只是执行特定的查询。但是,由于我知道我可能会经常使用这个,因此在许多不同的情况下,我让自己能够指定字段。我永远不想返回我不需要的字段!

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();
    }
}

在转到控制器之前,我想展示另一个示例来说明它有多强大。也许我有一个报告引擎,需要为 创建一个报告。对于我的数据映射器来说,这可能很棘手,在这种情况下,我可能想写一些实际的东西。没问题,下面是此查询对象的外观:AllOverdueAccountsSQL

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...";
    }
}

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

控制器

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

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;
    }
}

最后的想法:

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

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

我的存储库保持非常干净,相反,这种“混乱”被组织到我的模型查询中。

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

我很想听听你对我的方法的看法!


2015 年 7 月更新:

在评论中有人问我,我最终得到了所有这些。好吧,实际上并不遥远。说实话,我仍然不太喜欢存储库。我发现它们对于基本查找来说有些过分(特别是如果你已经在使用ORM),并且在处理更复杂的查询时很混乱。

我通常使用ActiveRecord样式的ORM,因此大多数情况下,我只会在整个应用程序中直接引用这些模型。但是,在我有更复杂的查询的情况下,我将使用查询对象使这些查询更易于重用。我还应该注意,我总是将模型注入到我的方法中,使它们更容易在我的测试中模拟。


答案 2

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

问:我们如何处理带回我们不需要的田地?

一个:根据我的经验,这实际上归结为处理完整的实体而不是临时查询。

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

即席查询返回一些数据,但我们不知道除此之外的任何内容。当数据在应用程序中传递时,它是在没有上下文的情况下完成的。是吗?A 附带一些信息?我们真的不知道。UserUserOrder

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

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

  1. 主动缓存实体,以便您只需从数据库中支付一次读取费用。
  2. 花更多的时间对实体进行建模,以便它们之间有很好的区别。(考虑将一个大型实体拆分为两个较小的实体,等等。
  3. 考虑拥有多个版本的实体。您可以有一个用于后端,也许还有一个用于AJAX调用。一个可能有 10 个属性,另一个可能有 3 个属性。UserUserSmall

使用即席查询的缺点:

  1. 您最终会在许多查询中获得基本相同的数据。例如,使用 ,对于许多调用,您最终将写出基本相同的内容。一个调用将获得 10 个字段中的 8 个,一个将获得 10 个字段中的 5 个,一个将获得 10 个字段中的 7 个。为什么不用一个得到10分(满分10分)的电话替换所有电话呢?这很糟糕的原因是,重构/测试/模拟是谋杀。Userselect *
  2. 随着时间的推移,很难在高层次上对代码进行推理。而不是像“为什么这么慢?”这样的陈述,你最终会跟踪一次性查询,所以错误修复往往是小的和本地化的。User
  3. 要取代底层技术真的很难。如果你现在把所有东西都存储在MySQL中,并想迁移到MongoDB,那么替换100个临时调用比替换几个实体要困难得多。

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

一个:除了合并呼叫之外,我还没有真正看到任何解决此问题的方法。存储库中的方法调用实际上映射到应用程序中的功能。功能越多,特定于数据的调用就越多。您可以推回功能并尝试将类似的调用合并为一个。

归根结底,复杂性必须存在于某个地方。使用存储库模式,我们将其推送到存储库接口中,而不是创建一堆存储过程。

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


推荐