感觉 Tauri 的 command 定义写着有些不太友好,主要是 command 一多 lib.rs
文件中定义就是一长串,此外还有类型和文档这些也不太方便编写和同步,因此本文记录引入 GraphQL 的过程。GraphQL 是一个成熟的 API 查询风格,社区的库也多,其类型和文档这些定义也方便还能满足查询特点数据的所需
使用 GraphQL 除了 Subscription
外 Query
和 Mutation
都挺好实现的
本文基于 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 只支持 i32
和 f64
,因此需要进行转换一下,,除此之外还有 Timestamp
, 实现这个其实必要性不大,但用来表示表中常见 created_at
也不错。
在 sqlx, SQLite 所有的 Integer 到 Rust 均采用的 i64
,为了方便传给 GraphQL 因此还需要实现一个 Integer
标量。
下面来在 graphql/scalar.rs
中编写 ID
、Timestamp
和 Integer
的代码
实现 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 (一。基础结构搭建),掘金的内容精简了一下不必要的内容
欢迎各位对本文做出批评、建议,我总感觉在类型映射上没处理好