前言:我试图在关系数据库的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()),如果从数据库提取,这显然有主要的性能问题。
帮助吗?
显然,在使用存储库时,我需要重新考虑一些事情。有谁能告诉我这个最好怎么处理吗?
我使用以下接口:
存储库——加载、插入、更新和删除实体
选择器——在存储库中基于过滤器查找实体
过滤器——封装过滤逻辑
我的存储库是数据库不可知的;事实上,它没有指定任何持久性;它可以是任何东西:SQL数据库,xml文件,远程服务,来自外太空的外星人等等。
对于搜索功能,存储库构造了一个选择器,可以过滤、限制、排序和计数。最后,选择器从持久化中获取一个或多个entity。
下面是一些示例代码:
<?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);
}
}
这个想法是,通用的选择器使用过滤器,但实现SqlSelector使用SqlFilter;SqlSelectorFilterAdapter将一个通用的Filter适配到一个具体的SqlFilter。
客户端代码创建Filter对象(通用过滤器),但在选择器的具体实现中,这些过滤器在SQL过滤器中转换。
其他选择器实现,如InMemorySelector,使用它们特定的InMemorySelectorFilterAdapter从Filter转换为InMemoryFilter;因此,每个选择器实现都有自己的过滤器适配器。
使用这种策略,我的客户端代码(在业务层)不关心特定的存储库或选择器实现。
/** @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();
附注:这是对我实际代码的简化
这是我见过的一些不同的解决方案。每一种都有利弊,但这是由你来决定的。
问题#1:字段太多
This is an important aspect especially when you take in to account Index-Only Scans. I see two solutions to dealing with this problem. You can update your functions to take in an optional array parameter that would contain a list of a columns to return. If this parameter is empty you'd return all of the columns in the query. This can be a little weird; based off the parameter you could retrieve an object or an array. You could also duplicate all of your functions so that you have two distinct functions that run the same query, but one returns an array of columns and the other returns an object.
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]
}
}
我希望这能有所帮助。
我认为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);
我只能对我们(在我的公司)处理此事的方式发表评论。首先,性能对我们来说不是太大的问题,但拥有干净/适当的代码才是。
首先,我们定义模型,例如使用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)
{
//...
}
}
In our case $db is an ORM that is able to load entities. The model instructs the ORM to load a set of entities of a specific type. The ORM contains a mapping and uses that to inject all the fields for that entity in to the entity. For foreign fields however only the id's of those objects are loaded. In this case the OrderModel creates OrderEntitys with only the id's of the referenced orders. When PersistentEntity::getField gets called by the OrderEntity the entity instructs it's model to lazy load all the fields into the OrderEntitys. All the OrderEntitys associated with one UserEntity are treated as one result-set and will be loaded at once.
这里的神奇之处在于,我们的模型和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中不是一个很大的问题,但我不建议其他系统也这样做。