
Tauriでrspcを使用し、TypescriptとRust間で型を共有する
-
2023年2月17日
こんにちは。Web開発室でフロントエンドエンジニアをしているOです。
今回はrspcというライブラリを使用して、Rust側で開発したAPIの型定義ファイルを生成し、Typescriptで型安全性を高めて開発を進める方法をご紹介します。
rspc : https://rspc.dev/
※リポジトリ(https://github.com/oscartbeaumont/rspc) を見るとまだ`Work in progress`と記述されているので、その点注意です。
また、今回はTauriというマルチプラットフォームのGUIアプリケーションを開発するためのフレームワークを使用します。
Tauriは基本的にWebviewプロセスにTypescriptを使用し、CoreプロセスをRustで記述していきます。
2022年6月にバージョン1.0がリリースされ、現在ではバージョン1.2.0となり、デスクトップだけではなくモバイルアプリケーションの出力も対応を進めています。
※現時点でモバイル出力はアルファ版です。
Tauri : https://tauri.app/
WebviewプロセスからCoreプロセスの呼び出し
rspcを使用する前に、Tauriでのプロセス間のやりとりの方法について
TauriのWebviewプロセスがWebviewサンドボックス外の動作を呼び出す場合、Coreプロセスと通信を行う必要があります。
Tauriには、あらかじめTauri CommandというCoreプロセスを呼び出す仕組みが用意されています。
まず、Rust側のコードでWebview側から呼び出されるコマンドを定義します。
例として単純にアプリケーション名を返すようなget_app_nameを定義し、rspc Test Projectという文字列をWebview側に渡すとします。
get_app_nameをハンドラーとして登録したら準備完了です。
// main.rs
#[tauri::command]
fn get_app_name() -> &'static str {
"rspc Test Project"
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_app_name,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
次にWebviewプロセスからget_app_nameを呼び出してみます。
// ts
import { invoke } from '@tauri-apps/api'
const getAppName = async () => {
return await invoke('get_app_name')
}
getAppNameを呼び出せば、ブラウザのコンソールにrspc Test Projectと表示されます。
無事、WebviewプロセスからCoreプロセスの動作を呼び出すことができました。
問題点
しかし、これでは下記のような問題が発生します。
- Rust側のCommandの名称がWebviewプロセス側で適切に間違いなく使用されているかわからない。
- タイプミスをしたり、存在しない名称を指定していたり。
- Rust側のCommandの引数、返り値などの型がWebviewプロセス側からだとコードを見ないとわからない。
- そのまま使うと型がanyになる。
上記の問題に対して、ドキュメントを用意するということで解決を図ることは可能ですが、そのドキュメントの正しさを保証することはできません。
そこで、rspcを使用してRust側の実装をすると自動的にTypescript用の型定義ファイルが生成されるようにしてみましょう。
rspcを使用する
Cargo.tomlにrspcを追記します。
※Tauriを使用するので、featuresにtauriを指定します。
# Cargo.toml
[dependencies]
rspc = { version = "0.1.2", features = ["tauri"] }
先にrouterを定義します。
routerとは、クライアントからアクセス可能なAPIのエンドポイントを設定する箇所で、
今回はTauriを使用しているため、Webviewプロセスからのアクセスポイントのルーティング設定をここで定義します。
routerというディレクトリを切って、以下のようなディレクトリ構造にします。
stc-tauri
├ router
├ mod.rs
├ app.rs
├ main.rs
router/app.rsに、最初に例として定義したアプリケーション名を返すget_app_nameを以下のように形を変えてルーティング設定をします。
// router/app.rs
use super::{RouterBuilder};
pub(crate) fn mount() -> RouterBuilder {
// getAppNameをエンドポイントとし、文字列で"rspc Test Project"を返す
<RouterBuilder>::new().query("getAppName", |t| t(|_: (), _: ()| "rspc Test Project"))
}
次に、すべてのルーティング設定をマージさせる集約部分をrouter/mod.rsに実装します。
// router/mod.rs
use std::sync::Arc;
mod app;
pub type Router = rspc::Router;
pub(crate) type RouterBuilder = rspc::RouterBuilder;
pub(crate) fn mount() -> Arc<Router> {
let config = rspc::Config::new().set_ts_bindings_header("/* eslint-disable */"); // ①
let config = config.export_ts_bindings(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../src/types/rspc/bindings.ts"), // ②
);
<Router>::new()
.config(config)
.merge("app.", app::mount()) // ③
.build()
.arced()
}
① – 生成される型ファイルに/* eslint-disable */の1行を追加し、lintを無効化します。
※基本的にWebviewプロセスの開発で使用されているeslintの設定を気にする必要はないため。
② – export_ts_bindings関数の引数に型定義ファイルの出力先パスを設定します。
③ – 先ほど実装したrouter/app.rsのルーティング設定をマージします。
merge関数の引数にapp.と指定することで、Webviewプロセス側からアクセスするパスがapp.getAppNameとなります。
最後に、main関数内のtauriのビルド設定にプラグインとしてrspcを登録します。
// main.rs
mod router;
fn main() {
tauri::Builder::default()
.plugin(rspc::integrations::tauri::plugin(router, || ())) // 追記
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
これで、各ルーティング設定から自動的にTypescript用の型定義ファイルが生成され、Webviewプロセスからのアクセスも可能な状態になりました。
この状態で一度ビルドすると型定義ファイルが生成されるので、中身を見てみましょう。
// bindings.ts
/* eslint-disable */
// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually.
export type Procedures = {
queries:
{ key: "app.getAppName", input: never, result: string },
mutations: never,
subscriptions: never
};
Proceduresという型エイリアスが宣言されており、queriesを見ると先ほどルーティング設定したapp.getAppNameがkeyとして存在することが確認できます。
keyがエンドポイントの型、inputがリクエストパラメータの型、resultが返り値の型となっており、
今回はリクエストパラメータはなく、返り値の型はrspc Test Projectなのでstringとなっています。
実際にWebviewプロセス側で型定義ファイルの恩恵を受けてみましょう。
まず、rspcのクライアント生成し、transportにTauriTransportを設定します。
createClientの型引数には、さきほどの型エイリアスProceduresを指定します。
// client.ts
import { Procedures } from '@/types/rspc' // 自動生成した型定義ファイル
import { createClient } from '@rspc/client'
import { TauriTransport } from '@rspc/tauri'
export const tauriClient = createClient<Procedures>({
transport: new TauriTransport(),
})
つぎにクライアントを使用してCoreプロセスと通信してみます。
rspc経由でCoreプロセスにアクセスするので、@tauri-apps/apiのinvoke関数は使用しなくなります。
// import { invoke } from '@tauri-apps/api'
import { tauriClient } from '@/client'
const getAppName = (): Promise<string> => {
// return await invoke('get_app_name')
return tauriClient.query(['app.getAppName']) // エディタ補完が効く
}
実際にエディタで記述してみないとわかりづらいのですが、エンドポイントのkeyに補完が効きます。
さらに、query関数の返り値にPromiseが型定義されています。
getAppNameを呼び出すと、ブラウザのコンソールにrspc Test Projectと表示されるはずです。
晴れて、Rust – Typescript間で型共有することでCoreプロセスとのやり取りの安全性を高めて開発を進められることがわかりました。
最後に
本記事では、rspcを使用して型定義ファイルを自動生成し、Rust – Typescript間で型共有をすることで、安全に開発を進める手法をご紹介しました。
rspcは、今回のようにTauriでクロスプラットフォームのアプリケーションを開発するケースだけではなく、もちろんRustでREST APIを開発するようなケースでもrspcは使用できます。
Typescriptを使用する以上、今回のように別の言語とのやりとりをする上での型問題は必ずといっていいほど発生します。
最近ではgrpcやtrpc, GraphQLなどアプローチの方法は様々ですが、Rust – Typescript間での型問題の解決としてrspcは1つの選択肢となるのではないかと考えています。
ココネでは一緒に働く仲間を募集中です。
ご興味のある方は、ぜひこちらの採用特設サイトをご覧ください。