我使用 graphql-ruby 轻松实现了分页功能,因此我查看了 Gem 做了什么
如果在Rails应用中使用GraphQL,有一个称为`graphql-ruby`的强大的Gem可供使用。
https://github.com/rmosolgo/graphql-ruby
GraphQL通过称为connections的机制来实现分页功能。在connections中,您可以指定要获取的数量和开始获取的位置。详情请参考官方网站:https://facebook.github.io/relay/graphql/connections.htm。
用graphql-ruby实现了分页功能后,我感到非常震撼,几乎不需要编写任何代码就可以完成实现。所以我决定查看graphql-ruby的源码以了解它的工作原理。
环境
环境变动如下所列。
Ruby: 2.6.5
Rails: 6.0.0
graphql-ruby: 1.9.12
Mysql: 5.7.27
红宝石:2.6.5
Rails:6.0.0
graphql-ruby:1.9.12
Mysql:5.7.27
实施
在实现Rails本身以及connection的实现方法中,省略了与其直接相关的部分。
安装GraphQL-Ruby
将其添加到Gemfile并进行安装。详细信息可以在Github的README等文档中找到。
gem 'graphql'
bundle install
rails generate graphql:install
创建表格
使用具有标题(title)和正文(body)栏的Review表格。
生成模型(Model)和迁移文件(Migration)以创建表格。
rails g model Review title:string body:text
由于想要观察分页功能,我会在控制台上随意创建一些数据来填充表格。
200.times do |i|
Review.create!(title: "title#{i}", body: "body#{i}")
end
GraphQL相关的实现
创建对应于先前创建的reviews表的review_type。
module Types
class ReviewType < BaseObject
field :id, ID, null: false
field :title, String, null: true
field :body, String, null: true
end
end
接下来,请创建获取reviews的查询类型。
module Types
class QueryType < Types::BaseObject
field :reviews, Types::ReviewType.connection_type, null: true do
description 'select reviews'
end
def reviews
Review.all.order(id: :desc)
end
end
end
只需在指定字段类型的位置写上ReviewType.connection_type,就可以指定之前定义的ReviewType返回多个连接类型的connections。
在数据获取中,会调用名字与字段名相同的reviews方法中的处理。
由于会自动添加所需的限制等分页信息,所以在这里只记录基本的获取处理。
这次我们将获取所有评审并按照id降序排序的规格。
有的就是这样了。啊,太简单了!那么我们接下来来看看下一章的内容吧。
确认动作
我将使用GraphiQL进行操作确认。
首先,我将尝试获取前10条记录。
{
reviews(first: 10) {
edges {
cursor
node {
id
title
body
}
}
pageInfo{
endCursor
hasNextPage
startCursor
hasPreviousPage
}
}
}
查询(首先:10)设置了查询类型query_type。首先是要获取的数量。
在返回的reviews项目中,指定在edges>node下。作为示例,我们指定了id、title和body,但只返回指定的项目。
顺便提一下,项目名称需要用驼峰命名法来指定。我认为当你用Ruby编写时,可能会不小心使用蛇形命名法,请注意。
(如果在示例中包含了这样的项目就好了……)
游标(cursor)和页面信息(pageInfo)是在连接(connections)中提供的字段。在解释响应的同时进行说明。
{
"data": {
"reviews": {
"edges": [
{
"cursor": "MQ",
"node": {
"id": "200",
"title": "title199",
"body": "body199"
}
},
# ...(略)
{
"cursor": "MTA",
"node": {
"id": "191",
"title": "title190",
"body": "body190"
}
}
],
"pageInfo": {
"endCursor": "MTA",
"hasNextPage": true,
"startCursor": "MQ",
"hasPreviousPage": false
}
}
}
}
cursor是附加在每个项目上的唯一值。我们将在获取第二页以及之后的页面时使用它。
pageInfo正如其名,会返回当前页面的信息。如果不需要该信息,可以省略。
从名称就可以推测出来,我也会简要解释每个项目。
endCursor:将设置为获取到的最后一个项目的cursor。
hasNextPage:判断是否有在获取的数据之后还有数据?本次是有数据的,所以设置为true。
startCursor:将设置为获取到的第一个项目的cursor。
hasPreviousPage:判断是否有在获取的数据之前还有数据?本次获取的是首个项目,所以设置为false。
可以通过将after指定为第一页的最后一个cursor来获取第二页的内容,从指定的cursor下一个项目开始获取。
{
reviews(first: 10, after: "MTA") {
edges {
cursor
node {
id
title
body
}
}
pageInfo{
endCursor
hasNextPage
startCursor
hasPreviousPage
}
}
}
{
"data": {
"reviews": {
"edges": [
{
"cursor": "MTE",
"node": {
"id": "190",
"title": "title189",
"body": "body189"
}
},
# ...(略)
{
"cursor": "MjA",
"node": {
"id": "181",
"title": "title180",
"body": "body180"
}
}
],
"pageInfo": {
"endCursor": "MjA",
"hasNextPage": true,
"startCursor": "MTE",
"hasPreviousPage": false
}
}
}
}
几乎如预期的结果返回了,但前一页明明还存在却显示hasPreviousPage为false。我猜测在参数中指定了first+after时,明确存在前一页,所以这个功能可能无效。我很在意,所以让我们在下一章中看一下源代码。
阅读源代码
评审类型.connection_type
我們來看一下只需寫”ReviewType.connection_type”就可以使用ReviewType的connection_type的處理方式。
我追蹤了connection_type方法的源碼。
def connection_type
@connection_type ||= define_connection
end
def define_connection(**kwargs, &block)
GraphQL::Relay::ConnectionType.create_type(self, **kwargs, &block)
end
请以汉语进行简述
def self.create_type(wrapped_type, edge_type: nil, edge_class: GraphQL::Relay::Edge, nodes_field: ConnectionType.default_nodes_field, &block)
custom_edge_class = edge_class
# Any call that would trigger `wrapped_type.ensure_defined`
# must be inside this lazy block, otherwise we get weird
# cyclical dependency errors :S
ObjectType.define do
type_name = wrapped_type.is_a?(GraphQL::BaseType) ? wrapped_type.name : wrapped_type.graphql_name
edge_type ||= wrapped_type.edge_type
name("#{type_name}Connection")
description("The connection type for #{type_name}.")
field :edges, types[edge_type], "A list of edges.", edge_class: custom_edge_class, property: :edge_nodes
if nodes_field
field :nodes, types[wrapped_type], "A list of nodes.", property: :edge_nodes
end
field :pageInfo, !PageInfo, "Information to aid in pagination.", property: :page_info
relay_node_type(wrapped_type)
block && instance_eval(&block)
end
end
似乎在ObjectType.define中定义了一个具有edges、nodes和pageInfo字段的connection_type。通常情况下,我们需要定义相同的字段,但ReviewType.connection_type的一行代码使得我们可以使用它,这种实现非常棒。
光标
光标中包含一个唯一的字符串,但是它是如何生成的呢?我尝试追踪了返回光标的方法来查看源代码。
def cursor
@cursor ||= connection.cursor_from_node(node)
end
请在我回来之前给我发一封电子邮件。
def cursor_from_node(item)
item_index = paged_nodes.index(item)
if item_index.nil?
raise("Can't generate cursor, item not found in connection: #{item}")
else
offset = item_index + 1 + ((paged_nodes_offset || 0) - (relation_offset(sliced_nodes) || 0))
if after
offset += offset_from_cursor(after)
elsif before
offset += offset_from_cursor(before) - 1 - sliced_nodes_count
end
encode(offset.to_s)
end
end
请将以下内容以中文进行释义:↓
def encode(data)
@encoder.encode(data, nonce: true)
end
请将以下内容翻译成中文,只需提供一种选项:
↓
def self.encode(unencoded_text, nonce: false)
Base64Bp.urlsafe_encode64(unencoded_text, padding: false)
end
请降低音量。
def urlsafe_encode64(bin, padding:)
str = strict_encode64(bin).tr("+/", "-_")
str = str.delete("=") unless padding
str
end
看起来在`cursor_from_node`方法中,它计算了当前节点的偏移量,并对其进行了Base64编码。
关于生成唯一字符串的方法,我最初以为会有些复杂,但实际上它只是将连续的数字转换为字符串形式而已。
顺便提一下,从实现来看,生成光标的逻辑似乎很容易被替换,所以如果不喜欢Base64的话,似乎可以进行自定义。
处理完成
我把处理过程写成以下方式。
def reviews
Review.all.order(id: :desc)
end
我只写了这个,例如获取第二页的10个条目,将会发出带有limit和offset的SQL语句,以及将limit设置为巨大数值的SQL语句。
SELECT `reviews`.* FROM `reviews` ORDER BY `reviews`.`id` DESC LIMIT 10 OFFSET 10
SELECT COUNT(*) FROM (SELECT 1 AS one FROM `reviews` LIMIT 18446744073709551615 OFFSET 10) subquery_for_count
我追踪了这个查询是如何被执行的源代码。
由于到达执行过程的路径十分复杂而无法完全写出,所以只提供了涉及的部分内容。
首先是第一个SQL。
def paged_nodes
return @paged_nodes if defined? @paged_nodes
items = sliced_nodes
if first
if relation_limit(items).nil? || relation_limit(items) > first
items = items.limit(first)
end
end
if last
if relation_limit(items)
if last <= relation_limit(items)
offset = (relation_offset(items) || 0) + (relation_limit(items) - last)
items = items.offset(offset).limit(last)
end
else
slice_count = relation_count(items)
offset = (relation_offset(items) || 0) + slice_count - [last, slice_count].min
items = items.offset(offset).limit(last)
end
end
if max_page_size && !first && !last
if relation_limit(items).nil? || relation_limit(items) > max_page_size
items = items.limit(max_page_size)
end
end
# Store this here so we can convert the relation to an Array
# (this avoids an extra DB call on Sequel)
@paged_nodes_offset = relation_offset(items)
@paged_nodes = items.to_a
end
def sliced_nodes
return @sliced_nodes if defined? @sliced_nodes
@sliced_nodes = nodes
if after
offset = (relation_offset(@sliced_nodes) || 0) + offset_from_cursor(after)
@sliced_nodes = @sliced_nodes.offset(offset)
end
if before && after
if offset_from_cursor(after) < offset_from_cursor(before)
@sliced_nodes = limit_nodes(@sliced_nodes, offset_from_cursor(before) - offset_from_cursor(after) - 1)
else
@sliced_nodes = limit_nodes(@sliced_nodes, 0)
end
elsif before
@sliced_nodes = limit_nodes(@sliced_nodes, offset_from_cursor(before) - 1)
end
@sliced_nodes
end
可以根据connection_type参数的存在与否来判断是否加上limit和offset。参数的预期使用方式是使用first+after(从after的位置开始的xx条)或last+before(在before的位置之前的xx条)的组合。但从处理结果来看,似乎也考虑到了相反的参数如first和last同时存在的情况并实施了相应的实现。然而,即使有这样的实现,同时指定相反的参数可能会导致难以理解的行为,所以最好不要使用这种方式。例如,当指定first: 5, after: “MTA”并返回以下结果时,如果再额外指定last: 3或last: 10的行为如下所示。
# first: 5, after: "MTA"
[{ id: 11, }, { id: 12, }, { id: 13, }, { id: 14, }, { id: 15, }]
# first: 5, after: "MTA", last: 3
# first > lastの場合、5件取得したものの後ろ3個が取得される。
[{ id: 13, }, { id: 14, }, { id: 15, }]
# first: 5, after: "MTA", last: 10
# first <= lastの場合、結果は変わらない。
[{ id: 11, }, { id: 12, }, { id: 13, }, { id: 14, }, { id: 15, }]
紧接着是第二个SQL。
def relation_count(relation)
count_or_hash = if(defined?(ActiveRecord::Relation) && relation.is_a?(ActiveRecord::Relation))
relation.respond_to?(:unscope)? relation.unscope(:order).count(:all) : relation.count(:all)
else # eg, Sequel::Dataset, don't mess up others
relation.count
end
count_or_hash.is_a?(Integer) ? count_or_hash : count_or_hash.length
end
看起来是对进行的查询使用.count(:all)进行计数,并将计数用于计算偏移量。
虽然有很大的LIMIT似乎有些担心,但是如果只指定了offset的查询来进行计数,Rails会自动添加它。看起来想要做的是计算偏移量后的记录数。
我在控制台上也进行了确认行为,如下所示:
irb(main):018:0> Review.all.order(:id).offset(10).count(:all)
(1.7ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `reviews` ORDER BY `reviews`.`id` ASC LIMIT 18446744073709551615 OFFSET 10) subquery_for_count
=> 190
页面信息
让我们来看一下pageinfo项目的计算方法。
"pageInfo": {
"endCursor": "MjA",
"hasNextPage": true,
"startCursor": "MTE",
"hasPreviousPage": false
}
游标结束
# Used by `pageInfo`
def end_cursor
if end_node = (respond_to?(:paged_nodes_array, true) ? paged_nodes_array : paged_nodes).last
return cursor_from_node(end_node)
else
return nil
end
end
这很容易理解。只是返回页内最后一个节点的光标。
有下一页
def has_next_page
if first
if defined?(ActiveRecord::Relation) && nodes.is_a?(ActiveRecord::Relation)
initial_offset = after ? offset_from_cursor(after) : 0
return paged_nodes.length >= first && nodes.offset(first + initial_offset).exists?
end
return paged_nodes.length >= first && sliced_nodes_count > first
end
if GraphQL::Relay::ConnectionType.bidirectional_pagination && last
return sliced_nodes_count >= last
end
false
end
如果指定了first参数,则似乎会判断当前页面是否有下一个项目并返回结果。
也有一个分支用于处理指定了last参数的情况,但根据GraphQL::Relay::ConnectionType.bidirectional_pagination的默认值false,将不会进入这个分支。顺便提一下,bidirectional_pagination直译过来是双向分页。虽然我很好奇它的用法,但与本题无关,所以我们将忽略它。
除此以外的情况似乎都会直接返回false,而不检查是否有下一页。请确保除了指定了first参数的情况外不要使用它。
开始游标
# Used by `pageInfo`
def start_cursor
if start_node = (respond_to?(:paged_nodes_array, true) ? paged_nodes_array : paged_nodes).first
return cursor_from_node(start_node)
else
return nil
end
end
这样就更容易理解了。只是返回了页面内第一个节点的cursor。
有前一页
def has_previous_page
if last
paged_nodes.length >= last && sliced_nodes_count > last
elsif GraphQL::Relay::ConnectionType.bidirectional_pagination && after
# We've already paginated through the collection a bit,
# there are nodes behind us
offset_from_cursor(after) > 0
else
false
end
end
如果设置了last参数,则根据offset判断是否存在前一页并返回结果。如果未设置last参数,则返回false,因此除非指定了last参数,否则不要使用它。
顺便说一下,虽然有赞同和反对的观点,但个人认为,如果没有意义,应该将其设置为nil而不是false。
赠品
如果在GraphiQL中执行异常的查询,即使重新加载页面也可能无法显示。
似乎是在最后执行的查询被保存在本地存储中,当在页面加载时读取该查询时可能会出现js错误。
在出现此错误时,Chrome的控制台会显示Error: Mode graphql failed to advance stream.
如果遇到这种情况,可以使用以下命令清除本地存储以修复问题。
localStorage.clear(); localStorage.setItem = () => {}