paysera / lib-pagination
使用基于游标或偏移量的分页,对 Doctrine QueryBuilder 进行分页
Requires
- php: ^7.1|^8.0
- ext-json: *
- doctrine/orm: ^2.5
- psr/log: ^1.0|^2.0
- symfony/property-access: ^3.0|^4.0|^5.0|^6.0
Requires (Dev)
- doctrine/annotations: ^1.0 || ^2.0
- phpunit/phpunit: ^7.5 || ^8.5 || ^9.5
- symfony/cache: ^3.0|^4.0|^5.0|^6.0
This package is auto-updated.
Last update: 2024-08-27 14:57:48 UTC
README
此组件允许使用基于游标的分页或偏移量分页来分页 Doctrine QueryBuilder
实例。
为什么?
性能
在大型数据集中,游标分页可以更有效率。
例如,为了获取包含 100 项的第 3000 页,使用偏移量你需要执行这样的查询
SELECT * FROM items WHERE field = 'some value' ORDER BY created_at DESC LIMIT 100 OFFSET 29900
即使你在你查询的字段(以及你排序的字段)上有索引,这种类型的查询也需要先遍历 29900 个项,然后才能给你所需的结果。
如果使用基于游标的分页,查询结果可能如下所示
SELECT * FROM items WHERE field = 'some value' AND created_at <= '2018-01-01 00:01:02' AND id < 12345 ORDER BY created_at DESC, id DESC LIMIT 100
这允许向表中添加所需的索引以启用快速查询。请记住,如果没有任何索引,性能可能非常相似。
在这个具体示例中,应该在 field,created_at,id
上有一个多列索引以获得最佳性能(或者如果 created_at
比较独特,则只需 field,created_at
)。
遍历所有项
当使用基于偏移量的分页的查询时,可能会得到相同的项两次或错过一些项。
这是因为当我们获取页面时,可能会添加新的项或删除一些项,这会改变所有其他项的位置。
让我们举一个例子。我们有一些页面,每个页面有 5 个项。我们已经获取了包含项 1 2 3 4 5
的第一页。在我们获取第二页之前,可能会发生以下情况
Original: 1 2 3 4 5 6 7 8 9
Second page: 6 7 8 9
"0" is added: 0 1 2 3 4 5 6 7 8 9
Second page: 5 6 7 8 9 <- 5 is duplicated
"2" was removed: 1 3 4 5 6 7 8 9
Second page: 7 8 9 <- 6 was skipped
如果我们使用基于游标的分页,我们将从某个具体的项之后(包括或排除)或之前开始页面,所以我们不会遇到这些问题。
注意事项
使用游标,很容易移动到下一页和上一页,但无法
- 知道你现在在第几页;
- 直接跳过一些页面或转到页面 X。
为了支持混合情况
- 游标总是提供并可用于分页;
hasNext
和hasPrevious
也总是可用,以便知道你是否在第一页或最后一页;- 偏移量可用于分页,但可能受到某些最大值的限制。这可以用于移动到第 N 页,但不能移动到 100N 页,这种情况并不常见;
- 如果您需要跳到最后一个页面,您应该提供(或自动执行)反转排序并转到第一页;
- 要显示页数,必须计算总计数。这可以完成,但默认情况下是禁用的,再次出于性能原因。您应该避免在每一页都提供总计数 - 而是在需要时或至少将其缓存。
安装
composer require paysera/lib-pagination
基本用法
使用 ResultProvider
类(服务)来提供结果。
要获取结果,需要两个参数
ConfiguredQuery
。这是QueryBuilder
的包装,包含可用的排序字段配置、最大偏移量、结果项转换以及是否计算总计数;Pager
。这包含用户提供的参数:偏移量或游标之后/之前、限制和排序方向。
通常 ConfiguredQuery
包含与 QueryBuilder
相关的内部细节,因此建议直接从 Repository
返回此内容 - 让我们保持实现细节在一起。
分页器
应在输入层中的某个位置创建,通常在控制器或类似位置。
示例用法
<?php use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery; use Paysera\Pagination\Service\Doctrine\ResultProvider; use Paysera\Pagination\Service\CursorBuilder; use Paysera\Pagination\Service\Doctrine\QueryAnalyser; use Paysera\Pagination\Entity\OrderingConfiguration; use Paysera\Pagination\Entity\Pager; use Paysera\Pagination\Entity\OrderingPair; use Symfony\Component\PropertyAccess\PropertyAccess; use Doctrine\ORM\EntityManagerInterface; $resultProvider = new ResultProvider( new QueryAnalyser(), new CursorBuilder(PropertyAccess::createPropertyAccessor()) ); /** @var EntityManagerInterface $entityManager */ $queryBuilder = $entityManager->createQueryBuilder() ->select('m') ->from('Bundle:MyEntity', 'm') ->andWhere('m.field = :param') ->setParameter('param', 'some value') ; $configuredQuery = (new ConfiguredQuery($queryBuilder)) ->addOrderingConfiguration( 'my_field_name', (new OrderingConfiguration('m.field', 'field'))->setOrderAscending(true) ) ->addOrderingConfiguration('my_other_name', new OrderingConfiguration('m.otherField', 'otherField')) ->setTotalCountNeeded(true) // total count will be returned only if this is called ->setMaximumOffset(100) // you can optionally limit maximum offset ->setItemTransformer(function ($item) { // return transformed item if needed }) ; $pager = (new Pager()) ->setLimit(10) ->setOffset(123) // set only one of offset, after or before ->setAfter('Cursor from Result::getNextCursor') ->setBefore('Cursor from Result::getPreviousCursor') ->addOrderBy(new OrderingPair('my_field_name')) // order by default direction (asc in this case) ->addOrderBy(new OrderingPair('my_other_name', true)) // or set direction here ; $result = $resultProvider->getResultForQuery($configuredQuery, $pager); $result->getItems(); // items in the page $result->getNextCursor(); // value to pass with setAfter for next page $result->getPreviousCursor(); // value to pass with setBefore for previous page $result->getTotalCount(); // available only if setTotalCountNeeded(true) was called $totalCount = $resultProvider->getTotalCountForQuery($configuredQuery); // calculate total count directly
使用迭代器
有一些类(服务)可以帮助迭代大结果集
ResultIterator
- 使用分页进行迭代,隐藏分页;FlushingResultIterator
- 与ResultIterator
相同,但在每一页之后刷新并清除EntityManager
。当您需要修改由 Doctrine 管理的实体时使用。这种模式(而不是仅调用$em->flush()
)可以避免内存不足失败并优化进程。
使用 ResultIterator
<?php use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery; use Paysera\Pagination\Service\Doctrine\ResultIterator; use Paysera\Pagination\Service\CursorBuilder; use Paysera\Pagination\Service\Doctrine\QueryAnalyser; use Paysera\Pagination\Service\Doctrine\ResultProvider; use Symfony\Component\PropertyAccess\PropertyAccess; use Psr\Log\NullLogger; /** @var ConfiguredQuery $configuredQuery */ $resultIterator = new ResultIterator( new ResultProvider( new QueryAnalyser(), new CursorBuilder(PropertyAccess::createPropertyAccessor()) ), new NullLogger(), $defaultLimit = 1000 ); foreach ($this->resultIterator->iterate($configuredQuery) as $item) { // process $item where flush is not needed // for example, send ID or other data to working queue, process files etc. }
使用 FlushingResultIterator
<?php use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery; use Paysera\Pagination\Service\Doctrine\FlushingResultIterator; use Paysera\Pagination\Service\CursorBuilder; use Paysera\Pagination\Service\Doctrine\QueryAnalyser; use Paysera\Pagination\Service\Doctrine\ResultProvider; use Symfony\Component\PropertyAccess\PropertyAccess; use Psr\Log\NullLogger; use Doctrine\ORM\EntityManagerInterface; /** @var ConfiguredQuery $configuredQuery */ /** @var EntityManagerInterface $entityManager */ $resultIterator = new FlushingResultIterator( new ResultProvider( new QueryAnalyser(), new CursorBuilder(PropertyAccess::createPropertyAccessor()) ), new NullLogger(), $defaultLimit = 1000, $entityManager ); foreach ($this->resultIterator->iterate($configuredQuery) as $item) { // process $item or other entities where flush will be called after each page // for example: $item->setTitle(formatTitleFor($item)); // keep in mind, that clear is also called – don't reuse other Entities outside of foreach cycle } echo "Updated successfully"; // no need to flush here anymore
如果出现内存不足异常等,请在日志(INFO 级别)中搜索最后一个“继续迭代”消息,查看上下文中的“after”,然后
$lastCursor = '"123"'; // get from logs $startPager = (new Pager()) ->setLimit(500) // can also override default limit ->setAfter($lastCursor) ; foreach ($this->resultIterator->iterate($configuredQuery, $startPager) as $item) { // process $item }
语义版本控制
此库遵循 语义版本控制。
有关 API 中可以更改的内容和不能更改的内容的基本信息,请参阅 Symfony BC 规则。
运行测试
composer update
composer test
贡献
请随意创建问题和发送拉取请求。
您可以使用此命令修复任何代码风格问题
composer fix-cs