- 后端
- API设计
【免费下载链接】graphql-ruby
Ruby implementation of GraphQL
游标(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这里有两个值得注意的工程细节:
- 解码失败即报错:
decode抛出的非法字符、Integer()无法解析的值、<= 0的游标,都会统一转换为GraphQL::ExecutionError(错误消息Invalid cursor: ...),而不是静默返回空页。测试 spec/support/connection_assertions.rb 还验证了负数游标不会"回绕"到列表尾部、超出列表长度的游标返回空结果、第一个游标之前的before查询返回空列表等边界行为。 - 同一个游标用于双向翻页:
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 配置的编码器,与内置连接行为保持一致。
实战注意事项与边界行为
结合源码与测试,在实际使用游标分页时应当留意以下几点:
- 不要在前端解析游标:游标格式(包括默认 Base64)不属于稳定 API,升级 graphql-ruby 版本后可能变化;客户端应把游标当作"黑盒字符串"原样存储、原样回传。
- 空字符串游标按
nil处理:lib/graphql/pagination/connection.rb 中,before/after的""会被视为nil,避免空字符串引发解码错误。 - 非法游标会报错而非静默:解码失败、非正整数偏移(如
"-1"、"0"、"abc"、"1e10")都会抛出GraphQL::ExecutionError(消息Invalid cursor),并遵守max_page_size限制,不会绕过分页上限(见 spec/support/connection_assertions.rb)。 - 越界游标返回空列表:游标对应的位置超出列表范围时返回空结果,但不会把负数"回绕"到列表尾部。
- 双向分页天然可用:同一个游标既能作为
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
相关推荐
Keystone 6 bytes 字段自定义编码实战:从 Hex 默认值到 Base64 自定义 GraphQL 标量
Keystone 6 bytes 字段自定义编码实战:从 Hex 默认值到 Base64 自定义 GraphQL 标量 本文以仓库中 examples/byte
后端FlexSearch Encoder 完全指南:从默认配置到自定义编码管线
FlexSearch Encoder 完全指南:从默认配置到自定义编码管线 导读 Encoder(编码器)是 FlexSearch 中负责语言处理与"模糊性"(
搜索引擎后端LNReader完整指南:打造终极Android轻小说阅读体验的免费开源方案
LNReader完整指南:打造终极Android轻小说阅读体验的免费开源方案 LNReader是一款专为Android平台设计的开源轻小说阅读器,通过强大的插件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考