☰
graphql-ruby 分页 Cursor 完全指南:不透明游标的原理、默认编码与自定义 `cursor_encoder`
2026/10/12 16:50:01 网站建设 项目流程
  • 后端
  • API设计

【免费下载链接】graphql-ruby

Ruby implementation of GraphQL

项目地址:https://gitcode.com/gh_mirrors/gr/graphql-ruby
点击查看免费下载

游标(Cursor)是 graphql-ruby 中 Relay 风格 Connection 分页的核心机制:它以一种不透明的字符串标记列表中的具体位置,客户端无需也无法解读其内部含义,只需在拿到游标后将其回传给after/before参数即可继续向前或向后翻页。本文以 guides/pagination/cursors.md 为主线,结合 lib/graphql/pagination/connection.rb、lib/graphql/schema.rb 与 lib/graphql/schema/base_64_encoder.rb 等源码实现,为你讲清游标的工作方式、默认 Base64 编码细节,以及如何通过Schema.cursor_encoder接入自定义编码器,并给出可直接复制的完整代码示例。

什么是 Cursor:连接分页的位置标记

在 Connection 分页模型中,客户端通过四个参数控制翻页:first、last、after、before。其中after和before接收的字符串就是cursor(游标),它指示列表中某一个具体的"位置点"。游标由连接实现生成并返回给客户端(通常位于edges { cursor }、pageInfo { startCursor endCursor }中),客户端再次请求时把游标原样传回,服务端据此确定当前页的起点或终点。

在 graphql-ruby 中,连接字段由ConnectionExtension自动注入after、before、first、last四个参数,见 lib/graphql/schema/field/connection_extension.rb。

游标之所以设计成**不透明(opaque)**字符串,是为了把分页实现细节完全封装在服务端:

  • 不可解码:客户端不应尝试 base64 解码、逆向工程或猜解游标中隐含的信息(例如"第 N 条");
  • 不可临时拼造:客户端不应根据业务逻辑自己构造游标;
  • 唯一保证:拿到一个游标后,可以放心地用它请求该位置之后(after)或之前(before)的列表项。

这种"值本身无意义、只有回传才有意义"的契约,让服务端可以自由选择最高效、最稳定的底层分页策略——这正是游标被 Relay 风格连接采用的原因。如 guides/pagination/connection_concepts.md 所述,连接分页相比传统的 offset 分页,优势之一就是"游标实现可以支持高效且稳定的分页"。

游标底层是什么:从编码到偏移量的完整链路

要理解"不透明",最好的办法是看它透明的一面——即服务端内部是如何使用游标的。以最常用的ArrayConnection为例,lib/graphql/pagination/array_connection.rb 的实现非常直白:

# 生成游标:取 item 在数组中的下标 + 1,编码成字符串 def cursor_for(item) idx = items.find_index(item) + 1 encode(idx.to_s) end # 解析游标:解码字符串得到正整数偏移量 def index_from_cursor(cursor) index = Integer(decode(cursor), 10, exception: false) if index.nil? || index <= 0 raise GraphQL::ExecutionError, "Invalid cursor: #{cursor.inspect}" end [index, items.length + 1].min end

也就是说,游标的"明文"就是列表中的 1-based 位置索引("1"、"2"、"3"……),经过编码后对外呈现为一串无意义的字符。而RelationConnection(ActiveRecord / Sequel / Mongoid 连接的基础类)则把游标解码为 SQLOFFSET,见 lib/graphql/pagination/relation_connection.rb:

def offset_from_cursor(cursor) offset = Integer(decode(cursor), 10, exception: false) if offset.nil? || offset <= 0 raise GraphQL::ExecutionError, "Invalid cursor: #{cursor.inspect}" end offset end

这里有两个值得注意的工程细节:

  1. 解码失败即报错:decode抛出的非法字符、Integer()无法解析的值、<= 0的游标,都会统一转换为GraphQL::ExecutionError(错误消息Invalid cursor: ...),而不是静默返回空页。测试 spec/support/connection_assertions.rb 还验证了负数游标不会"回绕"到列表尾部、超出列表长度的游标返回空结果、第一个游标之前的before查询返回空列表等边界行为。
  2. 同一个游标用于双向翻页:before与after共用同一套偏移量语义,因此游标天然支持向前(before/last)与向后(after/first)双向分页。

整个链路可以概括为:列表位置 → 明文偏移量字符串 → 编码器.encode→ 不透明游标(对外)→ 客户端回传 → 编码器.decode→ 偏移量 → 切片/SQL OFFSET。encode与decode正是连接实例上可供调用的两个方法(见下文"自定义 Cursor 编码器")。

默认编码器:无填充的 URL 安全 Base64

graphql-ruby 的默认游标编码器是GraphQL::Schema::Base64Encoder,定义在 lib/graphql/schema/base_64_encoder.rb:

module Base64Encoder def self.encode(unencoded_text, nonce: false) Base64.urlsafe_encode64(unencoded_text, padding: false) end def self.decode(encoded_text, nonce: false) # urlsafe_decode64 is for forward compatibility Base64.urlsafe_decode64(encoded_text) rescue ArgumentError raise GraphQL::ExecutionError, "Invalid input: #{encoded_text.inspect}" end end

可以看到默认实现有两个关键选择:

  • URL 安全(urlsafe):使用Base64.urlsafe_encode64,把标准 Base64 中的+/替换为-_,避免游标作为查询字符串或 JSON 传输时产生转义歧义;
  • 无填充(padding: false):去掉末尾的=,让游标更紧凑;
  • 解码容错:解码遇到非法输入时抛出GraphQL::ExecutionError,而不是底层库异常,保证错误以 GraphQL 错误形式呈现给客户端。

Base64Encoder是游标编码的"出厂默认值":在 lib/graphql/schema.rb 中,cursor_encoder未配置时通过find_inherited_value(:cursor_encoder, Base64Encoder)回退到该模块,并且该配置支持沿 Schema 继承链传递(spec/graphql/schema_spec.rb 中验证了子类 Schema 会继承父类的cursor_encoder)。

自定义 Cursor 编码器:Schema.cursor_encoder

如果默认的 Base64 编码不满足需求(例如需要更短、加密、或与既有系统对齐的游标格式),可以在 Schema 上通过cursor_encoder指定自定义编码器。

编码器接口契约

自定义编码器只需是一个响应两个类方法(或实例方法)的对象:

.encode(plain_text, nonce: false) # 返回编码后的字符串 .decode(encoded_text, nonce: false) # 返回解码后的明文

方法签名里的nonce:关键字参数是游标编码的扩展点:连接在内部调用编码器时总是以nonce: true传递,见 lib/graphql/pagination/connection.rb:

def decode(cursor) context.schema.cursor_encoder.decode(cursor, nonce: true) end def encode(cursor) context.schema.cursor_encoder.encode(cursor, nonce: true) end

也就是说,编码器可以通过nonce: true区分"内部生成的游标"与"其他场景下的调用"(例如校验客户端传入的游标是否由本服务端签发),从而为游标附加一次性随机数(nonce)或签名,进一步增强防篡改能力。默认的Base64Encoder直接忽略nonce参数。

官方示例:URL 安全 Base64

沿用官方文档的完整示例,将 Schema 的游标编码切换到 URL 安全 Base64:

module URLSafeBase64Encoder def self.encode(txt, nonce: false) Base64.urlsafe_encode64(txt) end def self.decode(txt, nonce: false) Base64.urlsafe_decode64(txt) end end class MySchema < GraphQL::Schema # ... cursor_encoder(URLSafeBase64Encoder) end

配置完成后,该 Schema 下所有连接生成的游标都会使用这个编码器,包括内置的ArrayConnection、ActiveRecordRelationConnection、SequelDatasetConnection、MongoidRelationConnection以及任何自定义连接包装器——因为它们统一经由基类GraphQL::Pagination::Connection的encode/decode方法访问context.schema.cursor_encoder(见 lib/graphql/pagination/connection.rb)。

测试中的编码器形态:认识nonce的真实用途

仓库测试 spec/support/connection_assertions.rb 提供了一个值得参考的NonceEnabledEncoder,它演示了 nonce 参数在实践中的用法:

class NonceEnabledEncoder class << self def encode(value, nonce: false) "#{JSON.dump([value])}#{nonce ? "+nonce" : ""}" end def decode(value, nonce: false) if nonce value = value.sub(/\+nonce$/, "") end JSON.parse(value).first end end end

该编码器在nonce: true(即 graphql-ruby 内部调用)时给明文附加+nonce标记,并在解码时剥离。测试 spec/graphql/pagination/connection_spec.rb 用它对["-1", "0", "abc", "1e10"]这类畸形游标做了验证,确认都会被拒绝并抛出包含Invalid cursor的GraphQL::ExecutionError。由此可以推断:你可以利用nonce:参数给游标加盐、加签名或附加过期时间,让"不透明"升级为"不可伪造"。

自定义连接中如何使用encode/decode

GraphQL::Pagination::Connection的子类在实现分页时,可以直接调用继承自基类的encode与decode私有方法,无需自己持有编码器引用。例如 lib/graphql/pagination/array_connection.rb 与 lib/graphql/pagination/relation_connection.rb 生成游标时都是encode(offset.to_s)的写法。

如果你在编写自己的连接包装器(参见 guides/pagination/custom_connections.md 中自定义连接的要求),实现#cursor_for(item)时只需:

class SearchResultsConnection < GraphQL::Pagination::Connection def cursor_for(item) # 计算出 item 在列表中的稳定位置,交给 encode 处理 encode(position_of(item).to_s) end def nodes # 用 decode 还原客户端回传的 before/after 位置 start_pos = after && Integer(decode(after), 10) # ... 基于位置切片 end end

这样一来,你的自定义连接会自动遵守当前 Schema 配置的编码器,与内置连接行为保持一致。

实战注意事项与边界行为

结合源码与测试,在实际使用游标分页时应当留意以下几点:

  1. 不要在前端解析游标:游标格式(包括默认 Base64)不属于稳定 API,升级 graphql-ruby 版本后可能变化;客户端应把游标当作"黑盒字符串"原样存储、原样回传。
  2. 空字符串游标按nil处理:lib/graphql/pagination/connection.rb 中,before/after的""会被视为nil,避免空字符串引发解码错误。
  3. 非法游标会报错而非静默:解码失败、非正整数偏移(如"-1"、"0"、"abc"、"1e10")都会抛出GraphQL::ExecutionError(消息Invalid cursor),并遵守max_page_size限制,不会绕过分页上限(见 spec/support/connection_assertions.rb)。
  4. 越界游标返回空列表:游标对应的位置超出列表范围时返回空结果,但不会把负数"回绕"到列表尾部。
  5. 双向分页天然可用:同一个游标既能作为after向后翻页,也能作为before向前翻页,hasNextPage/hasPreviousPage由连接根据游标位置自动计算。

延伸阅读

  • guides/pagination/connection_concepts.md:Connection / Edge / Node 的概念与关系元数据;
  • guides/pagination/using_connections.md:如何定义连接字段、返回集合以及配置max_page_size/default_page_size;
  • guides/pagination/custom_connections.md:如何为自有数据对象实现连接包装器(必须实现#cursor_for等四个方法);
  • lib/graphql/pagination/connection.rb:连接基类,encode/decode与cursor_for的声明所在;
  • lib/graphql/schema/base_64_encoder.rb:默认游标编码器的完整实现。
  • 后端
  • API设计

【免费下载链接】graphql-ruby

Ruby implementation of GraphQL

项目地址:https://gitcode.com/gh_mirrors/gr/graphql-ruby
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询