我使用 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
スクリーンショット 2019-12-06 23.25.38.png

创建表格

使用具有标题(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 = () => {}
广告
将在 10 秒后关闭
bannerAds