Proper Repository Pattern Design in PHP?
前言:我正试图在关系数据库的MVC体系结构中使用存储库模式。
我最近开始用PHP学习TDD,我意识到我的数据库与应用程序的其余部分耦合得太紧密了。我已经阅读过有关存储库的内容,并使用IOC容器将其"注入"到我的控制器中。很酷的东西。但是现在有一些关于存储库设计的实际问题。考虑下面的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | <?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:字段太多
所有这些查找方法都使用全选字段(
虽然这个类现在看起来不错,但我知道在现实世界中,我需要更多的方法。例如:
- findallbyname和status
- 芬兰国家
- 查找电子邮件地址集
- 查找日期和性别
- findallbyage和genderorderbyage
- 等。
正如您所看到的,可能有一个非常,非常长的可能方法列表。然后,如果你加入上面的字段选择问题,问题会恶化。在过去,我通常只是把所有这些逻辑放在我的控制器中:
1 2 3 4 5 6 7 8 9 10 11 12 | <?php class MyController { public function users() { $users = User::select('name, email, status') ->byCountry('Canada')->orderBy('name')->rows(); return View::make('users', array('users' => $users)); } } |
使用我的存储库方法,我不希望以这种方式结束:
1 2 3 4 5 6 7 8 9 10 11 12 | <?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:无法匹配接口
我看到了将接口用于存储库的好处,因此我可以交换我的实现(用于测试或其他目的)。我对接口的理解是,它们定义了一个实现必须遵循的契约。这非常好,直到您开始向存储库中添加其他方法,如
这使我相信知识库应该只有固定数量的方法(如
显然,我需要在使用存储库时重新考虑一些事情。有人能告诉我们如何最好地处理这件事吗?
我想我会设法回答我自己的问题。以下只是解决我原来问题1-3的一种方法。好的。
免责声明:在描述模式或技术时,我可能并不总是使用正确的术语。很抱歉。好的。目标:
- 创建一个基本控制器的完整示例,用于查看和编辑
Users 。 - 所有代码必须是完全可测试和可模拟的。
- 控制器不应该知道数据存储在哪里(意味着可以更改)。
- 显示SQL实现的示例(最常见)。
- 为了获得最佳性能,控制器应该只接收不需要额外字段的数据。
- 实现应该利用某种类型的数据映射器以便于开发。
- 实现应该能够执行复杂的数据查找。
解决方案
我将持久存储(数据库)交互分为两类:R(读)和CUD(创建、更新、删除)。我的经验是,读操作实际上是导致应用程序速度减慢的原因。虽然数据操作(CUD)实际上速度较慢,但发生的频率要低得多,因此也就不那么令人担心了。好的。
CUD(创建、更新、删除)很容易。这将涉及使用实际模型,然后将这些模型传递给我的
读不容易。这里没有模型,只重视对象。如果愿意,可以使用数组。这些对象可以表示单个模型,也可以表示多个模型的混合,实际上是任何东西。它们本身并不是很有趣,但是它们是如何产生的。我用的是我所说的
让我们从我们的基本用户模型开始。请注意,根本没有ORM扩展或数据库内容。纯粹的模特荣耀。添加getter、setter、validation等等。好的。
1 2 3 4 5 6 7 8 9 | class User { public $id; public $first_name; public $last_name; public $gender; public $email; public $password; } |
存储库接口
在创建用户存储库之前,我想创建我的存储库界面。这将定义存储库必须遵循的"契约",以供我的控制器使用。记住,我的控制器将不知道数据实际存储在哪里。好的。
请注意,我的存储库将只包含这三种方法。
1 2 3 4 5 6 | interface UserRepositoryInterface { public function find($id); public function save(User $user); public function remove(User $user); } |
SQL存储库实现
现在来创建接口的实现。如前所述,我的示例将使用SQL数据库。请注意,使用数据映射器可以防止编写重复的SQL查询。好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | 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(读取)。查询对象只是某种数据查找逻辑的封装。它们不是查询生成器。通过像存储库一样抽象它,我们可以更容易地更改它的实现和测试。查询对象的一个例子可能是
您可能会想,"难道我不能在我的存储库中为这些查询创建方法吗?"是的,但这就是我不这么做的原因:好的。
- 我的存储库用于处理模型对象。在现实世界的应用程序中,如果我想列出我的所有用户,为什么我需要获得
password 字段? - 存储库通常是特定于模型的,但是查询通常涉及多个模型。那么,您将方法放在什么存储库中呢?
- 这使我的存储库非常简单,而不是一个膨胀的方法类。
- 所有查询现在都组织到自己的类中。
- 实际上,此时,存储库的存在只是为了抽象我的数据库层。
对于我的示例,我将创建一个查询对象来查找"alluser"。界面如下:好的。
1 2 3 4 | interface AllUsersQueryInterface { public function fetch($fields); } |
查询对象实现
在这里,我们可以再次使用数据映射器来帮助加速开发。注意,我允许对返回的数据集字段进行一次调整。这就是我想要处理执行的查询的范围。记住,我的查询对象不是查询生成器。它们只执行特定的查询。但是,因为我知道在许多不同的情况下,我可能会经常使用这个字段,所以我给自己指定字段的能力。我从不想退回我不需要的田地!好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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(); } } |
在转到控制器之前,我想展示另一个例子来说明这有多强大。也许我有一个报告引擎,需要为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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..."; } } |
这很好地将我的报告逻辑保存在一个类中,并且很容易测试。我可以根据自己的心意来模仿它,甚至可以完全使用不同的实现。好的。控制器
现在,有趣的部分是把所有的部分放在一起。注意,我使用的是依赖注入。通常依赖项被注入到构造函数中,但实际上我更喜欢直接注入到我的控制器方法(路由)中。这使控制器的对象图最小化,我发现它更清晰。注意,如果您不喜欢这种方法,只需使用传统的构造函数方法。好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | 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,所以大多数情况下,我只在整个应用程序中直接引用这些模型。但是,在我有更复杂查询的情况下,我将使用查询对象使这些查询更可重用。我还应该注意到,我总是将我的模型注入我的方法中,使它们更容易在我的测试中模拟。好的。好啊。
根据我的经验,以下是您的一些问题的答案:
问:我们如何处理带回我们不需要的领域?
答:根据我的经验,这实际上可以归结为处理完整的实体和特殊的查询。
一个完整的实体就像一个
特殊查询返回一些数据,但除此之外我们什么都不知道。当数据在应用程序中传递时,它是在没有上下文的情况下完成的。它是一个
我更喜欢与完整的实体合作。
您是对的,您经常会带回您不使用的数据,但您可以通过各种方式解决这一问题:
使用特殊查询的缺点是:
问:我的存储库中的方法太多了。
A:除了整合电话,我真的没见过其他方法。存储库中的方法调用实际上映射到应用程序中的功能。功能越多,特定于数据的调用就越多。您可以推回功能并尝试将类似的调用合并为一个。
一天结束时的复杂性必须存在于某个地方。使用存储库模式,我们将其推送到存储库接口中,而不是创建一组存储过程。
有时我不得不对自己说,"好吧,它必须给某个地方!没有银弹。"
我使用以下接口:
Repository —加载、插入、更新和删除实体Selector —在存储库中查找基于过滤器的实体Filter —封装过滤逻辑
我的
以下是一些示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | <?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(); } |
然后,一个实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | 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); } } |
其思想是,通用
客户机代码创建
其他选择器实现,如
使用这个策略,我的客户机代码(在业务层)不关心特定的存储库或选择器实现。
1 2 3 4 5 | /** @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(); |
P.S.这是对我真正代码的简化
我会在这上面加上一点,因为我目前正试图自己掌握所有这些。
第1和2这是一个完美的地方,让你的ORM做起重。如果您使用的是实现某种ORM的模型,那么您可以使用它的方法来处理这些事情。如果需要的话,可以使用自己的orderby函数来实现雄辩的方法。使用雄辩,例如:
1 2 3 4 5 6 7 8 9 10 11 | class DbUserRepository implements UserRepositoryInterface { public function findAll() { return User::all(); } public function get(Array $columns) { return User::select($columns); } |
你要找的似乎是一个ORM。没有理由您的存储库不能基于一个。这将需要用户进行雄辩的扩展,但我个人并不认为这是一个问题。
然而,如果你确实想避免ORM,那么你就必须"滚动自己的"才能得到你想要的。
α3接口不应该是硬性和快速的需求。有些东西可以实现一个接口并添加到其中。它不能做的是未能实现该接口所需的功能。您还可以扩展类之类的接口来保持干燥。
也就是说,我刚刚开始领会,但这些认识帮助了我。
这些是我见过的不同的解决方案。他们每个人都有利弊,但这是由你来决定的。
问题1:字段太多这是一个重要的方面,尤其是当您考虑到只包含索引的扫描时。我看到了两种解决这个问题的方法。您可以更新函数以接受可选数组参数,该参数将包含要返回的列的列表。如果此参数为空,则返回查询中的所有列。这可能有点奇怪;根据参数,您可以检索对象或数组。您还可以复制所有函数,这样您就有两个不同的函数运行同一个查询,但一个函数返回一个列数组,另一个函数返回一个对象。
1 2 3 4 5 6 7 8 9 |
问题2:方法太多
一年前,我曾短暂地与推进ORM合作过,这是基于我能从中记忆到的经验。推进可以根据现有的数据库模式生成其类结构。它为每个表创建两个对象。第一个对象是一个很长的访问函数列表,类似于您当前列出的;
另一种解决方案是使用
1 2 3 4 5 6 |
我希望这至少能帮助一些什么。
我只能对我们(在我公司)处理这件事的方式发表评论。首先,对我们来说,性能不是太大的问题,但是拥有干净/正确的代码是。
首先,我们定义模型,例如使用ORM创建
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | 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) { //... } } |
在我们的例子中,
这里的神奇之处在于,我们的模型和ORM将所有数据注入到实体中,而实体仅为
现在开始加载基于WHERE子句的特定用户集。我们提供了一个面向对象的类包,允许您指定可以粘合在一起的简单表达式。在示例代码中,我将其命名为
1 2 3 4 5 | $objOptions->getConditionHolder()->addConditionBind( new ConditionBind( new Condition('orderProduct.product', ICondition::OPERATOR_IS, $argObjProduct) ) ); |
这个系统最简单的版本是将查询的where部分作为字符串直接传递给模型。
我对这个相当复杂的回答感到抱歉。我试图尽可能快速、清晰地总结我们的框架。如果您还有其他问题,请随时问他们,我会更新我的答案。
编辑:另外,如果您确实不想立即加载某些字段,可以在ORM映射中指定一个延迟加载选项。因为所有字段最终都是通过
我同意@Ryan1234,您应该在代码中传递完整的对象,并且应该使用通用查询方法来获取这些对象。
1 | Model::where(['attr1' => 'val1'])->get(); |
对于外部/端点使用,我非常喜欢graphql方法。
1 2 3 4 5 6 7 8 9 | POST /api/graphql { query: { Model(attr1: 'val1') { attr2 attr3 } } } |
我建议https://packagist.org/packages/prettus/l5-repository作为供应商来实现存储库/标准等…在拉腊维尔5: D