本文的所有代码是基于 在 Tauri 中使用 GraphQL 继续编写的,这一篇文章主要记录如何实现 GraphQL Relay 分页规范
Relay 分页规范由 Connection、Edge、Node、Cursor 以及 PageInfo 五部分组成,具体的文档参考 GraphQL Cursor Connections Spec
原谅没多少墨水,接下来只能贴代码了
实现 Cursor
Cursor 是一个用来定位的标记,它是一个 GraphQL scalar, 这里需要实现它的编码和解码,编码风格类似 jwt token, 其内部主要存储 id
和 created_at
字段。
这两个字段是用于排序的必要依赖,同时使用两个字段是避免如果存在同一时间多条记录就会导致出现问题,我的 created_at
是 10 位的时间戳(秒),再加上 id
是自增的,因此也就有两者同时使用的必要了。如果有必要的话也可以进行签名,防止被篡改。
首先在 graphql/realy
目录下新建 cursor.rs
文件, Cursor
定义结构体如下,此处的 scalar 来自之前编写的 graphql/scalar.rs
文件
rust
#[derive(Debug, Clone, GraphQLScalar)]
#[graphql(with = cursor_scalar, parse_token(String))]
pub struct Cursor {
pub(crate) id: scalar::ID,
pub(crate) created_at: scalar::Timestamp,
}
impl Cursor {
pub fn new(id: scalar::ID, created_at: scalar::Timestamp) -> Self {
Self { created_at, id }
}
}
为它实现编解码,编码方案依然采用之前编码 ID 时所用的 Base64
rust
impl From<&Cursor> for String {
fn from(cursor: &Cursor) -> Self {
base64_url::encode(format!("{}:{}", cursor.created_at, cursor.id).as_bytes())
}
}
从字符串尝试解码
rust
impl<'a> TryFrom<&'a str> for Cursor {
type Error = &'static str;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
static ERR_MSG: &str = "Invalid cursor format";
let bytes = base64_url::decode(value).map_err(|_| ERR_MSG)?;
let str = std::str::from_utf8(&bytes).map_err(|_| ERR_MSG)?;
let (created_at, id) = str.split_once(":").ok_or(ERR_MSG)?;
Ok(Self {
id: scalar::ID::from(id.parse::<i64>().map_err(|_| ERR_MSG)?),
created_at: scalar::Timestamp::from(created_at.parse::<i64>().map_err(|_| ERR_MSG)?),
})
}
}
核心的输入输出已经实现了,接下来使用 GraphQL 的输入和输出解析
rust
mod cursor_scalar {
use super::*;
use juniper::{InputValue, ScalarValue, Value};
pub(super) fn to_output<S: ScalarValue>(v: &Cursor) -> Value<S> {
Value::Scalar(String::from(v).into())
}
pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<Cursor, String> {
v.as_string_value()
.ok_or_else(|| format!("Expected `String`, found: {v}"))
.and_then(|v| match Cursor::try_from(v) {
Ok(cursor) => Ok(cursor),
Err(e) => Err(e.to_string()),
})
}
}
实现 Node
这里的 node
对应实体的数据,因此它不是一个结构体而是一个特征,要求对应输出的结构体要实现该特征
rust
pub trait ConnectionNode {
fn cursor(&self) -> Cursor;
const CONNECTION_TYPE_NAME: &'static str;
const EDGE_TYPE_NAME: &'static str;
}
该特征由三个字段组成,cursor 函数用于返回节点的游标,而 CONNECTION_TYPE_NAME
和 EDGE_TYPE_NAME
常量用于定义该连接的名称,例如我有一个 User
需要返回,那么它就应该是这样的
rust
impl ConnectionNode for User{
fn cursor(&self) -> Cursor {
Cursor::new(self.id, self.created_at)
}
const CONNECTION_TYPE_NAME: &'static str = "UserConnection";
const EDGE_TYPE_NAME: &'static str = "UserEdge";
}
这在 GraphQL Schema 中将会是如下的定义
graphql
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
其实还可以添加例如 CONNECTION_TYPE_DESCRIPTION
用于表示 UserConnection
的描述文档,但是感觉没必要,因为它基本都是重复的,添加这个字段会为使用带来额外的繁琐
实现 Edge
实际数据节点的数组便是由 Edge
组成,它有两个字段
node
: 实际数据对象,如上面的User
cursor
: 当前节点的游标,用于分页
定义它的结构
rust
#[derive(Debug)]
pub struct ConnectionEdge<N> {
pub(super) node: N,
pub(super) cursor: Cursor,
}
对于普通的对象,juniper 的 GraphQLObject
宏为我们实现了所需的特征,但现在 ConnectionEdge
是一个泛型,因此需要自行实现,从一个宏展开可得知接下来需要为它实现 7 个 juniper 的特征。
在实现之前需要理解这几个特征的功能
理解用于构建 GraphQLObject 所需的特征
这里对这些特征仅做简要概述
1. GraphQLType
用于在 GraphQL Schema 中公开 Rust 类型,在线文档 trait.GraphQLType
2. GraphQLValue
用于解析 GraphQL 值的主要特征,在线文档 trait.GraphQLValue
3. GraphQLValueAsync
支持异步的 query
和 mutation
解析器(resolvers),在线文档 trait.GraphQLValueAsync
4. IsOutputType
标记该结构体是用于输出的类型,在线文档 trait.IsOutputType
5. BaseType
用于在 Rust 类型和 GraphQL 类型系统之间建立映射关系 ,为 GraphQL Schema 生成提供类型名称,来源:macros/reflect.rs#38
6. BaseSubTypes
描述 GraphQL 类型子类型关系 ,为 GraphQL 类型系统提供 类型继承关系 的元信息,来源:macros/reflect.rs#101
7. WrappedType
用于编码 GraphQL 包装类型,来源:macros/reflect.rs#203
编码规则
Rust | 编码过程 | 值 | GraphQL 类型 |
---|---|---|---|
i32 |
基础类型 | 1 |
Int! |
Option<i32> |
1 + 2 |
12 |
Int |
Vec<i32> |
1 + 3 |
13 |
[Int!]! |
Option<Vec<i32>> |
1 + 3 + 2 |
132 |
[Int!] |
Vec<Option<i32>> |
1 + 2 + 3 |
123 |
[Int]! |
Option<Vec<Option<i32>>> |
1 + 2 + 3 + 2 |
1232 |
[Int] |
具体实现
rust
impl<N, S> GraphQLType<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
fn name(_info: &Self::TypeInfo) -> Option<&str> {
Some(N::EDGE_TYPE_NAME)
}
fn meta<'r>(info: &Self::TypeInfo, registry: &mut Registry<'r, S>) -> MetaType<'r, S>
where
S: 'r,
{
let fields = &[
registry
.field::<&N>("node", info)
.description("表示分页结果中的单个数据节点,包含实际业务数据"),
registry
.field::<&String>("cursor", &())
.description("唯一标识分页位置的游标"),
];
registry.build_object_type::<Self>(info, fields).into_meta()
}
}
impl<N, S> GraphQLValue<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
type Context = N::Context;
type TypeInfo = <N as GraphQLValue<S>>::TypeInfo;
fn type_name<'i>(&self, info: &'i Self::TypeInfo) -> Option<&'i str> {
<Self as GraphQLType<S>>::name(info)
}
fn resolve_field(
&self,
info: &Self::TypeInfo,
field_name: &str,
_arguments: &Arguments<S>,
executor: &Executor<Self::Context, S>,
) -> ExecutionResult<S> {
match field_name {
"node" => executor.resolve_with_ctx(info, &self.node),
"cursor" => executor.resolve_with_ctx(&(), &self.cursor),
_ => panic!("Field {} not found on type ConnectionEdge", field_name),
}
}
fn concrete_type_name(&self, _context: &Self::Context, info: &Self::TypeInfo) -> String {
self.type_name(info).unwrap_or("ConnectionEdge").to_string()
}
}
impl<N, S> GraphQLValueAsync<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + GraphQLValueAsync<S> + ConnectionNode + Sync + Send,
N::TypeInfo: Sync,
N::Context: Context + Sync,
S: ScalarValue + Send + Sync,
{
fn resolve_field_async<'a>(
&'a self,
info: &'a Self::TypeInfo,
field_name: &'a str,
_arguments: &'a Arguments<S>,
executor: &'a Executor<Self::Context, S>,
) -> juniper::BoxFuture<'a, ExecutionResult<S>> {
let f = async move {
match field_name {
"node" => executor.resolve_with_ctx_async(info, &self.node).await,
"cursor" => executor.resolve_with_ctx(&(), &self.cursor),
_ => panic!("Field {} not found on type ConnectionEdge", field_name),
}
};
use juniper::futures::future;
future::FutureExt::boxed(f)
}
}
impl<N, S> IsOutputType<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode,
S: ScalarValue,
<N as GraphQLValue<S>>::Context: Context,
{
}
impl<N, S> BaseType<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
const NAME: Type = N::EDGE_TYPE_NAME;
}
impl<N, S> BaseSubTypes<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode + BaseType<S>,
N::Context: Context,
S: ScalarValue,
{
const NAMES: Types = &[<N as BaseType<S>>::NAME, <Cursor as BaseType<S>>::NAME];
}
impl<N, S> WrappedType<S> for ConnectionEdge<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
const VALUE: WrappedValue = 1;
}
完整的代码见 src/graphql/relay/edge.rs
实现 Connection
Connection 通常由 edges
和 page_info
两个字段组成,故在实现 Connection
之前需要先实现 PageInfo
类型
PageInfo
不是泛型,因此直接使用 GraphQLObject
宏
rust
#[derive(Debug, Clone, Default, GraphQLObject)]
pub struct PageInfo {
/// 是否存在上一页(当使用 last/before 时可用)
pub(super) has_previous_page: bool,
/// 是否存在下一页(当使用 first/after 时可用)
pub(super) has_next_page: bool,
/// 当前页第一条记录的游标
pub(super) start_cursor: Option<Cursor>,
/// 当前页最后一条记录的游标
pub(super) end_cursor: Option<Cursor>,
}
Connection 的定义如下
rust
#[derive(Debug, Default)]
pub struct Connection<N> {
pub(super) edges: Vec<ConnectionEdge<N>>,
pub(super) page_info: PageInfo,
pub(super) total_count: i32,
}
这里加入了 total_count
字段,个人认为挺需要的,除此之外 Connection 应该还有一个 nodes
字段,它主要方便我们在不需要 cursor 时直接访问 node。
由于它可以直接在 GraphQLValue
特征中使用 map
遍历下 edges
便可以实现了,因此这里的结构体没有添加 nodes
字段的必要
然后实现上面实现 Edge
时所需实现的 7 个特征
rust
impl<N, S> GraphQLType<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
fn name(_info: &<N as GraphQLValue<S>>::TypeInfo) -> Option<&str> {
Some(N::CONNECTION_TYPE_NAME)
}
fn meta<'r>(
info: &<N as GraphQLValue<S>>::TypeInfo,
registry: &mut Registry<'r, S>,
) -> MetaType<'r, S>
where
S: 'r,
{
let fields = &[
registry
.field::<&Vec<ConnectionEdge<N>>>("edges", info)
.description("分页连接的核心数据载体,包含节点及其关联的元数据(如游标)"),
registry
.field::<&Vec<N>>("nodes", info)
.description("直接访问节点数据的快捷方式,省略 edges 层"),
registry
.field::<&i32>("totalCount", &())
.description("匹配当前筛选条件的总记录数,不受分页限制"),
registry
.field::<&PageInfo>("pageInfo", &())
.description("分页控制元数据,用于确定是否可翻页及边界游标"),
];
registry.build_object_type::<Self>(info, fields).into_meta()
}
}
impl<N, S> GraphQLValue<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode,
N::Context: Context,
S: ScalarValue,
{
type Context = N::Context;
type TypeInfo = <N as GraphQLValue<S>>::TypeInfo;
fn type_name<'i>(&self, info: &'i Self::TypeInfo) -> Option<&'i str> {
<Self as GraphQLType<S>>::name(info)
}
fn resolve_field(
&self,
info: &Self::TypeInfo,
field_name: &str,
_arguments: &Arguments<S>,
executor: &Executor<Self::Context, S>,
) -> ExecutionResult<S> {
match field_name {
"edges" => executor.resolve_with_ctx(info, &self.edges),
"nodes" => {
let nodes = self.edges.iter().map(|edge| &edge.node).collect::<Vec<_>>();
executor.resolve_with_ctx(info, &nodes)
}
"pageInfo" => executor.resolve_with_ctx(&(), &self.page_info),
"totalCount" => executor.resolve_with_ctx(&(), &self.total_count),
_ => panic!("Field {} not found on type Connection", field_name),
}
}
fn concrete_type_name(&self, _context: &Self::Context, info: &Self::TypeInfo) -> String {
self.type_name(info).unwrap_or("Connection").to_string()
}
}
impl<N, S> GraphQLValueAsync<S> for Connection<N>
where
N: GraphQLType<S> + GraphQLValueAsync<S> + ConnectionNode + Sync + Send,
N::TypeInfo: Sync,
N::Context: Context + Sync,
S: ScalarValue + Send + Sync,
{
fn resolve_field_async<'a>(
&'a self,
info: &'a Self::TypeInfo,
field_name: &'a str,
_arguments: &'a Arguments<S>,
executor: &'a Executor<Self::Context, S>,
) -> juniper::BoxFuture<'a, ExecutionResult<S>> {
let f = async move {
match field_name {
"edges" => executor.resolve_with_ctx_async(info, &self.edges).await,
"nodes" => {
let nodes = self.edges.iter().map(|edge| &edge.node).collect::<Vec<_>>();
executor.resolve_with_ctx_async(info, &nodes).await
}
"pageInfo" => executor.resolve_with_ctx(&(), &self.page_info),
"totalCount" => executor.resolve_with_ctx(&(), &self.total_count),
_ => panic!("Field {} not found on type Connection", field_name),
}
};
use juniper::futures::future;
future::FutureExt::boxed(f)
}
}
impl<N, S> IsOutputType<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode,
S: ScalarValue,
<N as GraphQLValue<S>>::Context: Context,
{
}
impl<N, S> BaseType<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode,
S: ScalarValue,
{
const NAME: Type = N::CONNECTION_TYPE_NAME;
}
impl<N, S> BaseSubTypes<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode + BaseType<S>,
N::Context: Context,
S: ScalarValue,
{
const NAMES: Types = &[
<ConnectionEdge<N> as BaseType<S>>::NAME,
<PageInfo as BaseType<S>>::NAME,
];
}
impl<N, S> WrappedType<S> for Connection<N>
where
N: GraphQLType<S> + ConnectionNode,
S: ScalarValue,
{
const VALUE: WrappedValue = 1;
}
完整的代码见 src/graphql/relay/connection.rs
基础的都已经实现了,下面来编写构建 Connection
的逻辑代码
构建 Connection
GraphQL relay 风格分页的参数有四个,我们将它装在 Pagination
结构体中
rust
#[derive(Debug, Default, GraphQLInputObject)]
pub struct Pagination {
pub(crate) first: Option<i32>,
pub(crate) after: Option<Cursor>,
pub(crate) last: Option<i32>,
pub(crate) before: Option<Cursor>,
}
然后为 Pagination
的参数实现一个验证函数和一个获取当前 limit 的函数
rust
impl Pagination {
pub fn validate(&self) -> Result<(), FieldError> {
match (
(self.first, self.after.as_ref()),
(self.last, self.before.as_ref()),
) {
((Some(first), _), _) if first < 0 => Err(FieldError::new(
"'first' argument must be positive number",
graphql_value!({
"code": "VALUE_OUT_OF_RANGE",
"min": 0,
"max": i32::MAX,
}),
)),
(_, (Some(last), _)) if last < 0 => Err(FieldError::new(
"'last' argument must be positive number",
graphql_value!({
"code": "VALUE_OUT_OF_RANGE",
"min": 0,
"max": i32::MAX,
}),
)),
((Some(_), _), (Some(_), _)) => Err(FieldError::new(
"Cannot use both 'first' and 'last'",
graphql_value!(
{
"code": "INVALID_PARAM_COMBINATION",
"allowed": ["first+after", "last+before"]
}
),
))?,
((Some(_), _), (_, Some(_))) => Err(FieldError::new(
"'first' cannot be used with 'before'",
graphql_value!({
"code": "DIRECTION_CONFLICT"
}),
)),
((_, Some(_)), (Some(_), _)) => Err(FieldError::new(
"'last' cannot be used with 'after'",
graphql_value!({
"code": "DIRECTION_CONFLICT",
}),
)),
_ => Ok(()),
}
}
#[inline]
pub fn limit(&self) -> i32 {
self.first.or(self.last).unwrap_or(10)
}
}
Pagination
定义好了就可以构建 Connection
,先实现一个 build_connection
函数。
在该函数中主要计算 PageInfo
所需的数据和裁剪 edges 长度为传入的 first
或 last
,裁剪是因为为了方便的处理 has_next_page
和 has_previous_page
默认都是查询 limit + 1 条数据,根据返回的数据条数是否大于 limit 来判断是否存在下一页的数据。
这里有一个不太好解决的问题(我没想到好的解决方案),对于 first+after
同时传入时(常见于页数 >= 2)has_previous_page
会永远是 false。除非多发出一条 SQL 查询用来检查是否存在 previous 或者在SQL 中加上额外的逻辑,但又存在无法和本地结构体映射的问题。
rust
impl<N> Connection<N>
where
N: ConnectionNode,
{
fn build_connection(
pagination: Pagination,
total_count: i32,
edges: Vec<N>,
) -> FieldResult<Connection<N>> {
let edges_len = edges.len() as i32;
// 前面有 validate 函数约束了 first 和 last 不能同时存在,故此不做额外的判断
let has_next_page = pagination.first.map(|it| edges_len > it).unwrap_or(false);
let has_previous_page = pagination.last.map(|it| edges_len > it).unwrap_or(false);
// 如果 first,last 都没有传,默认取 10 条,但 has_next_page 和 has_previous_page 永远为 false
let limit = pagination.limit();
let take_length = i32::min(edges_len, limit);
let edges = edges
.into_iter()
.take(take_length as usize)
.map(|edge| ConnectionEdge {
cursor: edge.cursor(),
node: edge,
})
.collect::<Vec<_>>();
Ok(Self {
page_info: PageInfo {
has_previous_page,
has_next_page,
start_cursor: edges.first().map(|edge| edge.cursor.clone()),
end_cursor: edges.last().map(|edge| edge.cursor.clone()),
},
edges,
total_count,
})
}
}
再实现 new
方法,这里对于 totalCount
字段我们选择使用 Look-ahead 来动态的决定是否要执行加载 total,这样可以有几率减少一次 SQL 查询
rust
impl<N> Connection<N>
where
N: ConnectionNode,
{
...
pub async fn new<'a, C, S, F1, F2>(
executor: &juniper::Executor<'_, '_, C, S>,
pagination: Pagination,
loader: F1,
total_loader: F2,
) -> juniper::FieldResult<Connection<N>>
where
S: juniper::ScalarValue + 'a,
C: 'a,
F1: AsyncFnOnce(&Pagination) -> anyhow::Result<Vec<N>>,
F2: AsyncFnOnce() -> anyhow::Result<i32>,
{
pagination.validate()?;
let children = executor.look_ahead().children();
let has_total_count_field = children
.iter()
.any(|sel| sel.field_original_name() == "totalCount");
let edges = loader(&pagination).await?;
let total_count = if has_total_count_field {
total_loader().await?
} else {
0
};
Self::build_connection(pagination, total_count, edges)
}
}
使用方式
在 graphql/schema.rs
文件中的 Query
处定义,例如实现一个 list_todos
方法,它应该拥有 first
、after
、last
、before
四个参数。
然后 totalCount
字段使用了 look-ahead,因此需要传入 juniper::Executor
。
这一部分代码如下
rust
impl Query {
...
pub async fn list_todos(
executor: &Executor<'_, '_, Context, scalar::CustomScalarValue>,
ctx: &Context,
first: Option<i32>,
after: Option<relay::Cursor>,
last: Option<i32>,
before: Option<relay::Cursor>,
) -> FieldResult<relay::Connection<Todo>> {
let patination = relay::Pagination {
first,
after,
last,
before,
};
let conn = relay::Connection::new(
executor,
patination,
async |pag| ctx.todo_repo.list_todos(&pag).await,
async || ctx.todo_repo.total().await,
)
.await?;
Ok(conn)
}
}
我基于 SolidJS 的 TodoMVC 例子结合上面的内容改造了下,代码在 tauri-graphql-demo
欢迎大家对本文做出批评和建议,我将会虚心接受。
参考内容: