键集分页
键集分页库可以在 GitLab 项目中的基于 HAML 的视图和 REST API 中使用。
您可以在我们的分页指南页面阅读关于键集分页的信息,以及它与基于偏移量的分页的比较。
API 概述
概述
在 Rails 控制器中使用 ActiveRecord 进行键集分页:
cursor = params[:cursor] # 请求第一页时为 nil
paginator = Project.order(:created_at).keyset_paginate(cursor: cursor, per_page: 20)
paginator.each do |project|
puts project.name # 最多打印 20 个项目
end使用
这个库为 ActiveRecord 关系添加了一个单一的方法:#keyset_paginate。
这在精神上(但不是在实现上)与 Kaminari 的 paginate 方法相似。
对于简单的 ActiveRecord 查询,键集分页无需任何配置即可工作:
- 按单列排序。
- 按两列排序,其中最后一列是主键。
库会检测可空和非唯一的列,并基于这些添加使用主键的额外排序。这是必要的,因为键集分页需要唯一的排序值:
Project.order(:created_at).keyset_paginate.records # ORDER BY created_at, id
Project.order(:name).keyset_paginate.records # ORDER BY name, id
Project.order(:created_at, id: :desc).keyset_paginate.records # ORDER BY created_at, id
Project.order(created_at: :asc, id: :desc).keyset_paginate.records # ORDER BY created_at, id DESCkeyset_paginate 方法返回一个特殊的分页器对象,其中包含已加载的记录和用于请求各种页面的附加信息。
该方法接受以下关键字参数:
cursor- 用于请求下一页的编码排序列值(可以是nil)。per_page- 每页加载的记录数(默认为 20)。keyset_order_options- 用于构建键集分页数据库查询的额外选项,请参阅性能部分中关于UNION查询的示例(可选)。
分页器对象具有以下方法:
records- 返回当前页的记录。has_next_page?- 判断是否有下一页。has_previous_page?- 判断是否有上一页。cursor_for_next_page- 用于请求下一页的编码值(类型为String,可以是nil)。cursor_for_previous_page- 用于请求上一页的编码值(类型为String,可以是nil)。cursor_for_first_page- 用于请求第一页的编码值(类型为String)。cursor_for_last_page- 用于请求最后一页的编码值(类型为String)。- 分页器对象包含了
Enumerable模块,并将可枚举功能委托给records方法/数组。
获取第一页和第二页的示例:
paginator = Project.order(:name).keyset_paginate
paginator.to_a # 与 .records 相同
cursor = paginator.cursor_for_next_page # 下一页的编码列属性
paginator = Project.order(:name).keyset_paginate(cursor: cursor).records # 加载下一页由于键集分页不支持页码,我们只能跳转到以下页面:
- 下一页
- 上一页
- 最后一页
- 第一页
在 REST API 中使用 paginate_with_strategies
对于 REST API,可以在关系上使用 paginate_with_strategies 辅助方法,以使用键集分页或偏移量分页。
desc 'Get the things related to a project' do
detail 'This feature was introduced in GitLab 16.1'
success code: 200, model: ::API::Entities::Thing
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
end
params do
use :pagination
requires :project_id, type: Integer, desc: 'The ID of the project'
optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records'
optional :order_by, type: String, values: %w[id name], default: 'id',
desc: 'Attribute to sort by'
optional :sort, type: String, values: %w[asc desc], default: 'desc', desc: 'Order of sorting'
end
route_setting :authentication
get ':project_id/things' do
project = Project.find_by_id(params[:project_id])
not_found! if project.blank?
things = project.things
present paginate_with_strategies(things), with: ::API::Entities::Thing
end要使用键集分页,必须满足以下条件:
-
params[:pagination]必须返回'keyset' -
params[:order_by]和params[:sort]都必须出现在模型上supported_keyset_orderings类方法返回的对象中。在以下示例中,Thing在按 ID 升序或降序排序时支持键集分页。class Thing < ApplicationRecord def self.supported_keyset_orderings { id: [:asc, :desc] } end end
在 Rails 中使用 HAML 视图
考虑以下控制器操作,其中我们按名称列出项目:
def index
@projects = Project.order(:name).keyset_paginate(cursor: params[:cursor])
end在 HAML 文件中,我们可以渲染记录:
- if @projects.any?
- @projects.each do |project|
.project-container
= project.name
= keyset_paginate @projects性能
键集分页的性能取决于数据库索引配置和我们在 ORDER BY 子句中使用的列数。
如果我们按主键(id)排序,那么生成的查询是高效的,因为主键被数据库索引覆盖。
当在 ORDER BY 子句中使用两列或更多列时,建议检查生成的数据库查询,并确保使用了正确的索引配置。更多信息可以在分页指南页面找到。
虽然第一页的查询性能可能看起来不错,但第二页(在查询中使用游标属性)可能会产生较差的性能。建议始终验证两个查询的性能:第一页和第二页。
带有决胜列(id)的数据库查询示例:
SELECT "issues".*
FROM "issues"
WHERE (("issues"."id" > 99
AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" IS NULL))
ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
LIMIT 20在 PostgreSQL 中,OR 查询难以优化,我们通常建议使用 UNION 查询 代替。当 ORDER BY 子句中存在多列时,键集分页库可以生成高效的 UNION。当我们指定传递给 Relation#keyset_paginate 的选项中的 use_union_optimization: true 选项时,会触发此功能。
示例:
# 为第一页触发简单查询。
paginator1 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, keyset_order_options: { use_union_optimization: true })
cursor = paginator1.cursor_for_next_page
# 为第二页触发 UNION 查询
paginator2 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, cursor: cursor, keyset_order_options: { use_union_optimization: true })
puts paginator2.records.to_a # UNION 查询复杂排序配置
常见的 ORDER BY 配置由 keyset_paginate 方法自动处理,因此无需手动配置。但在一些边缘情况下,需要配置排序对象:
NULLS LAST排序。- 基于函数的排序。
- 使用自定义决胜列的排序,如
iid。
这些排序对象可以作为标准的 ActiveRecord 作用域在模型类中定义,没有特殊行为阻止在其他地方使用这些作用域(Kaminari、后台任务)。
NULLS LAST 排序
考虑以下作用域:
scope = Issue.where(project_id: 10).order(Issue.arel_table[:relative_position].desc.nulls_last)
# SELECT "issues".* FROM "issues" WHERE "issues"."project_id" = 10 ORDER BY relative_position DESC NULLS LAST
scope.keyset_paginate # 抛出错误:Gitlab::Pagination::Keyset::UnsupportedScopeOrder: The order on the scope does not support keyset paginationkeyset_paginate 方法抛出错误,因为查询中的排序值是自定义 SQL 字符串,而不是 Arel AST 节点。键集库无法从这类查询中自动推断配置值。
要使键集分页工作,我们必须配置自定义排序对象,为此,我们必须收集关于排序列的信息:
relative_position可能有重复值,因为没有唯一索引存在。relative_position可能有空值,因为该列没有非空约束。为此,我们必须确定我们在结果集的何处看到NULL值,是在开头还是结尾(NULLS LAST)。- 键集分页需要唯一的排序列,因此我们必须添加主键(
id)使排序唯一。 - 跳转到最后一页并反向分页实际上会反转
ORDER BY子句。为此,我们必须提供反转的ORDER BY子句。
示例:
order = Gitlab::Pagination::Keyset::Order.build([
# 属性在 `lib/gitlab/pagination/keyset/column_order_definition.rb` 文件中有文档说明
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'relative_position',
column_expression: Issue.arel_table[:relative_position],
order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first,
nullable: :nulls_last,
order_direction: :desc
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: Issue.arel_table[:id].asc,
nullable: :not_nullable
)
])
scope = Issue.where(project_id: 10).order(order) # 或 reorder()
scope.keyset_paginate.records # 可工作基于函数的排序
在以下示例中,我们将 id 乘以 10 并按该值排序。因为 id 列是唯一的,所以我们只定义一列:
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_times_ten',
order_expression: Arel.sql('id * 10').asc,
nullable: :not_nullable,
order_direction: :asc,
add_to_projections: true
)
])
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(per_page: 5)
puts paginator.records.map(&:id_times_ten)
cursor = paginator.cursor_for_next_page
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(cursor: cursor, per_page: 5)
puts paginator.records.map(&:id_times_ten)add_to_projections 标志告诉分页器在 SELECT 子句中暴露列表达式。这是必要的,因为键集分页需要以某种方式从记录中提取最后一个值来请求下一页。
基于 iid 的排序
对问题进行排序时,数据库确保我们在项目中具有唯一的 iid 值。如果存在 project_id 过滤器,按单列排序就足以使分页工作:
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'iid',
order_expression: Issue.arel_table[:iid].asc,
nullable: :not_nullable
)
])
scope = Issue.where(project_id: 10).order(order)
scope.keyset_paginate.records # 可工作