在 Tauri 中使用 GraphQL

感觉 Tauri 的 command 定义写着有些不太友好,主要是 command 一多 lib.rs 文件中定义就是一长串,此外还有类型和文档这些也不太方便编写和同步,因此本文记录引入 GraphQL 的过程。GraphQL 是一个成熟的 API 查询风格,社区的库也多,其类型和文档这些定义也方便还能满足查询特点数据的所需

使用 GraphQL 除了 SubscriptionQueryMutation 都挺好实现的

本文基于 juniper + sqlx(SQLite) 编写

首先展示一下目录结构

text 复制代码
src-tauri/
├─ commands/
│  ├─ graphql.rs
│  ├─ mod.rs
├─ graphql/
│  ├─ loaders/
│  ├─ relay/
│  ├─ context.rs
│  ├─ mod.rs
│  ├─ scalar.rs
│  ├─ schema.rs
├─ lib.rs
├─ main.rs
├─ state.rs

其中 state.rs 存储 tauri 的上下文定义,graphql/context.rs 存储 GraphQL 的上下文,这里有两个上下文,tauri 的上下文将在启动程序时创建,用于存储 SQLite 的 pool,而 graphql 的上下文会在 graphql 请求时创建,存储 repository 和 service 等

数据类型映射

由于类型系统不一样,需要实现一些 GraphQL scalar 用来处理类型映射,在 SQLite 中的表 ID 我是使用的自增 ID,这在 Rust 使用的类型是 i64 ,而 GraphQL/Juniper 只支持 i32f64,因此需要进行转换一下,,除此之外还有 Timestamp, 实现这个其实必要性不大,但用来表示表中常见 created_at 也不错。

在 sqlx, SQLite 所有的 Integer 到 Rust 均采用的 i64,为了方便传给 GraphQL 因此还需要实现一个 Integer 标量。

下面来在 graphql/scalar.rs 中编写 IDTimestampInteger 的代码

实现 CustomScalarValue

为了 Integer 在 SQLx 中方便的转换,我们不能对它进行包装成结构体,否则 query_as! 宏无法将 Option<i64> 转换为 Option<Integer>

尽管可以通过 Column Type Override 对目标列修改为特定类型但还是增加繁琐性。要是 SQLx query_as! 能支持如果是 Option 则进行 xxx.map(Into) 就好了

CustomScalarValue 正是为了使用 Foreign scalars

juniper 自带 DefaultScalarValue 定义在 juniper crate 的 src/value/scalar.rs 文件内,我们将它复制出来然后重命名为 CustomScalarValue

rust 复制代码
#[derive(Clone, Debug, PartialEq, ScalarValue, Serialize)]  
#[serde(untagged)]
pub enum CustomScalarValue {
    #[value(as_float, as_int)]  
    Int(i32),  
    #[value(as_float)]  
    Float(f64),  
    #[value(as_str, as_string, into_string)]  
    String(String),  
    #[value(as_bool)]  
    Boolean(bool),
}

实现 Integer

其定义如下

rust 复制代码
#[graphql_scalar]  
#[graphql(  
    with = integer_scalar,  
    parse_token(String),  
    scalar = CustomScalarValue  
)]
pub type Integer = i64;

然后为它实现输入输出处理逻辑

rust 复制代码
mod integer_scalar {  
    use super::*;  
  
    pub(super) fn to_output<S: ScalarValue>(v: &Integer) -> Value<S> {  
        // 直接强制转为更小的类型  
        Value::Scalar((*v as i32).into())  
    }  
    pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<Integer, String> {  
        v.as_string_value()  
            .ok_or_else(|| format!("Expected `Int`, found: {v}"))  
            .and_then(|s| match s.parse::<i64>() {  
                Ok(i) => Ok(i),  
                Err(e) => Err(format!("{e}")),  
            })  
    }  
}

实现 Timestamp

rust 复制代码
#[derive(Debug, Copy, GraphQLScalar, Clone, Eq, PartialEq, Serialize, sqlx::Type)]  
#[graphql(  
    with = timestamp_scalar,
    parse_token(String),
)]  
pub struct Timestamp(i64);

由于 GraphQL 不支持 i64,因此将其转为 date rfc3339 这种字符串形式,这里引入 chrono crate 来处理

rust 复制代码
mod timestamp_scalar {  
    use super::*;  
    use chrono::{DateTime, Utc};  
  
    pub(super) fn to_output<S: ScalarValue>(v: &Timestamp) -> Value<S> {  
        Value::Scalar(  
            DateTime::<Utc>::from_timestamp(v.0, 0)  
                .unwrap()  
                .to_rfc3339()  
                .into(),  
        )  
    }  
    pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<Timestamp, String> {  
        v.as_string_value()  
            .ok_or_else(|| format!("Expected `Timestamp`, found {v}"))  
            .and_then(|s| match DateTime::parse_from_rfc3339(s) {  
                Ok(dt) => Ok(Timestamp(dt.timestamp())),  
                Err(e) => Err(format!("{e}")),  
            })  
    }  
}

实现 ID

rust 复制代码
#[derive(Debug, Copy, Hash, GraphQLScalar, Clone, Eq, PartialEq, Serialize, sqlx::Type)]  
#[graphql(with = id_scalar, parse_token(String))]  
pub struct ID(i64);

对于 ID 采用 base64 对 i64 进行编码为字符串

rust 复制代码
mod id_scalar {  
    use super::*;  
  
    pub(super) fn to_output<S: ScalarValue>(v: &ID) -> Value<S> {  
        let value = base64_url::encode(&v.0.to_be_bytes());  
        Value::Scalar(value.into())  
    }  
    pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<ID, String> {  
        v.as_string_value()  
            .ok_or_else(|| format!("Expected `String`, found: {v}"))  
            .and_then(|s| {  
                match base64_url::decode(s)  
                    .map_err(|e| format!("{e}"))  
                    .and_then(|b| b.try_into().map_err(|_e| "Invalid byte length".to_string()))  
                    .map(i64::from_be_bytes)  
                {  
                    Ok(v) => Ok(ID(v)),  
                    Err(e) => Err(format!("Invalid ID, {e}")),  
                }  
            })  
    }  
}

这三个标量定义好了后,接下来就是在 Tauri 中 创建 command 了

创建 graphql 命令入口

在创建命令前我们需要先定义 Schema 和 Context 的结构

创建 GraphQL Schema

首先在 graphql/schema.rs 中创建 schema

rust 复制代码
pub struct Query;

#[graphql_object]  
#[graphql(context = Context, scalar = scalar::CustomScalarValue)]
impl Query{
    pub fn greet(name: String) -> String{
        format!("Hello, {}! You've been greeted from Rust!", name)
    }
}

pub struct Mutation;

#[graphql_object]  
#[graphql(context = Context, scalar = scalar::CustomScalarValue)]  
impl Mutation {  
    pub fn add(a: i32, b: i32) -> i32 {  
        a + b  
    }
}

pub type Schema =  
    RootNode<'static, Query, Mutation, EmptySubscription<Context>, scalar::CustomScalarValue>;

pub fn create_schema() -> Schema {  
    let schema = Schema::new_with_scalar_value(Query, Mutation, EmptySubscription::new());
    #[cfg(debug_assertions)]
    {
      // 每次启动时输出 schema 到文件
      let path = ...;
      std::fs::write(&path, schema.as_sdl()).unwrap();
    }
    schema
}

创建 Context

首先在 state.rs 中定义数据库的连接和 GraphQL Schema

SqlitePool 内部使用了 Arc 因此不需要使用 Arc , graphql::Schema 后面只用到了其引用,因此也无需使用 Arc

rust 复制代码
pub struct AppState {    
    pub pool: SqlitePool,
    pub schema: graphql::Schema
}

pub fn build_app_state(pool: SqlitePool) -> AppState{
    AppState {
        pool,
        schema: graphql::create_schema(),
    }
}

然后在 graphql/context.rs 中定义 GraphQL 的上下文

rust 复制代码
pub struct Context {
    // 这里除了存储数据库连接外还用于存储 service 或 repository、loader 等
    pub pool: SqlitePool
}

impl Context{
  pub fn new(pool: SqlitePool) -> Self {
      Self { pool }
  }
}

impl juniper::Context for Context {}

创建命令

commands/graphql.rs 中声明 graphql 命令

rust 复制代码
#[command]
pub async fn graphql(
    state: tauri::State<'_, AppState>,
    body: GraphQLRequest<scalar::CustomScalarValue>,
) -> Result<serde_json::Value, serde_json::Value> {
    let pool = state.pool.clone();
    let context = graphql::Context::new(pool);

    let response = body.execute(&state.schema, &context).await;
    match (response.is_ok(), serde_json::to_value(response)) {
        (true, Ok(v)) => Ok(v),
        (false, Ok(v)) => Err(v),
        (_, Err(e)) => Err(serde_json::Value::String(e.to_string())),
    }
}

然后在 lib.rs 中导入使用

rust 复制代码
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![commands::graphql::graphql])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

至此,便可以在前端以 invoke 形式调用了

ts 复制代码
import { invoke } from '@tauri-apps/api/core';

const graphql = async <T = unknown>(
  query: string,
  variables: Record<string, unknown>,
): Promise<T> => {
  return invoke("graphql", {
    body: {
      query,
      variables,
    },
  });
};

完整代码见:github.com/tonitrnel/t...

本文首发于我的博客 在 Tauri 中使用 GraphQL (一。基础结构搭建),掘金的内容精简了一下不必要的内容

欢迎各位对本文做出批评、建议,我总感觉在类型映射上没处理好

相关推荐
Bigger9 小时前
Tauri(十六)——为托盘菜单添加快捷键提示
前端·rust·app
Hello.Reader9 小时前
初探 Dubbo Rust SDK打造现代微服务的新可能
微服务·rust·dubbo
yezipi耶不耶11 小时前
Rust 入门之闭包(Closures)
开发语言·后端·rust
勇敢牛牛_11 小时前
【Rust基础】使用Rust和WASM开发的图片压缩工具
开发语言·rust·wasm·图片压缩
weixin_5025398514 小时前
rust学习笔记18-迭代器
笔记·学习·rust
Source.Liu18 小时前
【CXX-Qt】2.2 生成的 QObject
c++·qt·rust
Bigger1 天前
Tauri(十五)——多窗口之间通信方案
前端·rust·app
techdashen1 天前
Rust vs. Go: 在仅使用标准库时的性能测试
开发语言·golang·rust
一只小松许️1 天前
Rust函数、条件语句、循环
开发语言·后端·rust