在 Rust 基于 Juniper GraphQL 库实现 Relay 分页规范

本文的所有代码是基于 在 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

欢迎大家对本文做出批评和建议,我将会虚心接受。

参考内容:

相关推荐
进击的雷神4 小时前
OpenClaw 太重、启动太慢?我试了试这个 Rust 重写的轻量版 AI 助手网关
开发语言·人工智能·rust·zeroclaw
选择即世界5 小时前
高性能的 SGLang Rust 运行时
rust
Ramble_Naylor21 小时前
async/await:让一个线程同时等很多件事
开发语言·rust
Source.Liu1 天前
【A11】近期实现架构:面向 EPC 工作流企业 QQ 化的第一阶段
windows·架构·rust
geovindu2 天前
rust: Composite Pattern
开发语言·后端·设计模式·rust·组合模式
喵了几个咪2 天前
RushWind Admin — 契约驱动:203 条路由零手写的工程化拆解
微服务·rust·开源·admin
Ramble_Naylor2 天前
闭包与迭代器:函数式写法
rust
喵了几个咪2 天前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
微服务·架构·golang·rust·多租户·gowind·rushwind
对象存储与RustFS3 天前
升级 RustFS 二进制不停机:一条一条换,留一条退路
后端·rust·开源