首页 文章

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

提问于
浏览
235

前言:我正在尝试在具有关系数据库的MVC架构中使用存储库模式 .

我知道我的数据库与我的应用程序的其余部分过于紧密地联系在一起 . 我已经阅读了有关存储库并使用IoC container将其作为控制器的信息 . 很酷的东西 . 但是现在有一些关于存储库设计的实际问题 . 考虑以下示例 .

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

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

<?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:无法匹配界面

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

规格模式?

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

帮忙?

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

7 回答

  • 174

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

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

    目标:

    • 创建用于查看和编辑 Users 的基本控制器的完整示例 .

    • 所有代码必须完全可测试且可模拟 .

    • 控制器应该不知道数据的存储位置(意味着可以更改) .

    • 显示SQL实现的示例(最常见) .

    • 为获得最佳性能,控制器应仅接收所需数据 - 无需额外字段 .

    • 实施应利用某种类型的数据映射器以便于开发 .

    • 实现应该能够执行复杂的数据查找 .

    解决方案

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

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

    R (阅读)并不那么容易 . 这里没有型号,只是value objects . 使用数组if you prefer . 这些对象可能代表单个模型或许多模型的混合,实际上是任何东西 . 这些并不是很有趣,但它们是如何生成的 . 我'm using what I'米打电话 Query Objects .

    守则:

    用户模型

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

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

    存储库接口

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

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

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

    SQL存储库实现

    现在创建我的接口实现 . 如上所述,我的例子将是一个SQL数据库 . 请注意使用data mapper来防止必须编写重复的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 (读取) . 查询对象只是某种类型的数据查找逻辑的封装 . 它们是 not 查询构建器 . 通过像我们的存储库那样抽象它,我们可以更改它的实现并更容易地测试它 . 查询对象的示例可能是 AllUsersQueryAllActiveUsersQuery ,甚至是 MostCommonUserFirstNames .

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

    • 我的存储库用于处理模型对象 . 在真实世界的应用程序中,如果我想列出所有用户,为什么我需要获取 password 字段?

    • 存储库通常是特定于模型的,但查询通常涉及多个模型 . 那么你把你的方法放在哪个存储库中?

    • 这使我的存储库非常简单 - 不是一个膨胀的方法类 .

    • 现在所有查询都被组织到自己的类中 .

    • 实际上,在这一点上,存储库只是为了抽象我的数据库层而存在 .

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

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

    在继续讨论控制器之前,我想展示另一个例子来说明这是多么强大 . 也许我有一个报告引擎,需要为 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...";
        }
    }
    

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

    控制器

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

    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(复杂的查询,报告等) . 当你这样做时,它很好地隐藏在一个正确命名的类中 .

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


    July 2015 Update:

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

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

  • 42

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

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

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

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

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

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

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

    • 积极地缓存实体,这样您只需从数据库中支付一次读取价格 .

    • 花费更多时间对实体进行建模,以便它们之间有很好的区别 . (考虑将大型实体拆分为两个较小的实体,等等)

    • 考虑拥有多个版本的实体 . 您可以在后端使用 User ,也可以使用 UserSmall 进行AJAX调用 . 一个可能有10个属性,一个有3个属性 .

    使用即席查询的缺点:

    • 您在许多查询中最终得到的数据基本相同 . 例如,对于 User ,您最终会为许多调用编写基本相同的 select * . 一个电话将获得10个字段中的8个,一个将获得10个中的5个,一个将获得7个中的7个 . 为什么不用10个中的10个中的一个调用替换所有字段?这很糟糕的原因是重新分解/测试/模拟是谋杀 .

    • 随着时间的推移,很难对你的代码进行高级别的推理 . 而不是像“为什么_1773361这么慢?”这样的陈述 . 您最终会追踪一次性查询,因此错误修复往往很小并且本地化 .

    • 它更难以替换100个临时呼叫而不是少数几个实体 .

    Q: 我的存储库中的方法太多了 .

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

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

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

  • 4

    我使用以下接口:

    • Repository - 加载,插入,更新和删除实体

    • Selector - 在存储库中查找基于过滤器的实体

    • Filter - 封装过滤逻辑

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

    以下是一些示例代码:

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

    然后,一个实现:

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

    ideea是泛型 Selector 使用 Filter 但实现 SqlSelector 使用 SqlFilter ; SqlSelectorFilterAdapter 将通用 Filter 改编为具体的 SqlFilter .

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

    其他选择器实现,如 InMemorySelector ,使用其特定的 InMemorySelectorFilterAdapterFilter 转换为 InMemoryFilter ;所以,每个选择器实现都有自己的过滤器适配器 .

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

    /** @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();
    

    附:这是我真实代码的简化

  • 0

    我会补充一点,因为我现在正试图自己掌握所有这些 .

    #1和2

    这是您的ORM完成繁重工作的理想场所 . 如果您使用的是实现某种ORM的模型,您可以使用它的方法来处理这些事情 . 如果需要,可以创建自己的orderBy函数来实现Eloquent方法 . 以Eloquent为例:

    class DbUserRepository implements UserRepositoryInterface
    {
        public function findAll()
        {
            return User::all();
        }
    
        public function get(Array $columns)
        {
           return User::select($columns);
        }
    

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

    如果你想避免使用ORM,那么你就必须“自己动手”来获得你正在寻找的东西 .

    #3

    接口不应该是硬性和快速的要求 . 有些东西可以实现一个接口并添加到它 . 它不能做的是无法实现该接口的必需功能 . 您还可以扩展类之类的接口以保持DRY .

    那就是说,我刚刚开始掌握,但这些认识对我有所帮助 .

  • 13

    我只能评论我们(在我的公司)处理这个问题的方式 . 首先,性能对我们来说并不是一个问题,但是拥有干净/正确的代码是 .

    首先,我们定义模型,例如使用ORM创建 UserEntity 对象的 UserModel . 从模型加载 UserEntity 时,将加载所有字段 . 对于引用外部实体的字段,我们使用适当的外部模型来创建相应的实体 . 对于那些实体,数据将按需加载 . 现在你最初的反应可能是...... ??? ... !!!让我举个例子给你一个例子:

    class UserEntity extends PersistentEntity
    {
        public function getOrders()
        {
            $this->getField('orders'); //OrderModel creates OrderEntities with only the ID's set
        }
    }
    
    class UserModel {
        protected $orm;
    
        public function findUsers(IGetOptions $options = null)
        {
            return $orm->getAllEntities(/*...*/); // Orm creates a list of UserEntities
        }
    }
    
    class OrderEntity extends PersistentEntity {} // user your imagination
    class OrderModel
    {
        public function findOrdersById(array $ids, IGetOptions $options = null)
        {
            //...
        }
    }
    

    在我们的例子中, $db 是一个能够加载实体的ORM . 该模型指示ORM加载特定类型的一组实体 . ORM包含一个映射,并使用该映射将该实体的所有字段注入实体 . 但是对于外来字段,只加载这些对象的id . 在这种情况下, OrderModel 仅使用引用订单的ID创建 OrderEntity . 当 PersistentEntity::getFieldOrderEntity 调用时,实体指示它的模型延迟将所有字段加载到 OrderEntity 中 . 与一个UserEntity关联的所有 OrderEntity 被视为一个结果集,并将立即加载 .

    这里的神奇之处在于我们的模型和ORM将所有数据注入到实体中,并且该实体仅为 PersistentEntity 提供的通用 getField 方法提供包装函数 . 总而言之,我们总是加载所有字段,但必要时会加载引用外部实体的字段 . 只是加载一堆字段并不是真正的性能问题 . 加载所有可能的外来实体但是性能会大幅降低 .

    现在开始根据where子句加载一组特定的用户 . 我们提供了一个面向对象的类包,允许您指定可以粘合在一起的简单表达式 . 在示例代码中,我将其命名为 GetOptions . 它是选择查询的所有可能选项的包装器 . 它包含where子句,group by子句和其他所有内容的集合 . 我们的where子句非常复杂,但显然你可以轻松地制作一个更简单的版本 .

    $objOptions->getConditionHolder()->addConditionBind(
        new ConditionBind(
            new Condition('orderProduct.product', ICondition::OPERATOR_IS, $argObjProduct)
        )
    );
    

    该系统的最简单版本是将查询的WHERE部分作为字符串直接传递给模型 .

    对于这个相当复杂的回复,我很抱歉 . 我尽可能快速,清晰地总结了我们的框架 . 如果您有任何其他问题,请随时问他们,我会更新我的答案 .

    编辑:此外,如果您真的不想立即加载某些字段,可以在ORM映射中指定延迟加载选项 . 因为所有字段最终都是通过 getField 方法加载的,所以在调用该方法时,您可以在最后一分钟加载一些字段 . 这在PHP中不是一个很大的问题,但我不推荐其他系统 .

  • 3

    这些是我见过的一些不同的解决方案 . 每个人都有利弊,但是由你来决定 .

    问题#1:字段太多

    这是一个重要的方面,尤其是当您考虑到帐户Index-Only Scans时 . 我看到两个解决方案来解决这个问题 . 您可以更新函数以接受可选的数组参数,该参数将包含要返回的列的列表 . 如果此参数为空,那么你就是返回查询中的所有列 . 这可能有点奇怪;基于参数,您可以检索对象或数组 . 您还可以复制所有函数,以便有两个不同的函数运行相同的查询,但是一个返回一个列数组,另一个返回一个对象 .

    public function findColumnsById($id, array $columns = array()){
        if (empty($columns)) {
            // use *
        }
    }
    
    public function findById($id) {
        $data = $this->findColumnsById($id);
    }
    

    问题#2:方法太多了

    我在一年前与Propel ORM进行了简短的合作,这是基于我从这段经历中记得的东西 . Propel可以选择根据现有的数据库模式生成其类结构 . 它为每个表创建两个对象 . 第一个对象是一个很长的访问函数列表,类似于您目前列出的内容; findByAttribute($attribute_value) . 下一个对象继承自第一个对象 . 您可以更新此子对象以构建更复杂的getter函数 .

    另一种解决方案是使用 __call() 将未定义的函数映射到可操作的东西 . 您的 __call 方法将能够将findById和findByName解析为不同的查询 .

    public function __call($function, $arguments) {
        if (strpos($function, 'findBy') === 0) {
            $parameter = substr($function, 6, strlen($function));
            // SELECT * FROM $this->table_name WHERE $parameter = $arguments[0]
        }
    }
    

    我希望这有助于至少一些什么 .

  • 3

    我建议https://packagist.org/packages/prettus/l5-repository作为供应商在Laravel5:D中实现Repositories / Criterias等

相关问题