概要
最近は寒さも一層厳しくなり、本格的な冬を感じますが、皆さんはいかがお過ごしでしょうか。
さて、今年もアドベントカレンダーを書く時期になりました。
自身の得意分野で記事を書くのも良いのですが、アドベントカレンダーを書く年末は、新しい知識を取り入れる良い契機になると思うんですよね。
なので、今年のアドカレも新しいことに入門する内容で書くことにしました。
今回は、RsutとGUIフレームワークのTauriに入門します。
また、今回の記事の大半は下記のサイトから学んだ内容になります。
最終的に出来上がるのは、下記のような看板ボードになります。
環境
各バージョン
$ rustc -V
rustc 1.74.1 (a28077b28 2023-12-04)
$ cargo-tauri -V
tauri-cli 1.5.9
$ node -v
v20.9.0
$ yarn -v
1.22.21
プロジェクト作成
公式ページのQuickStartからプロジェクトセッティングを行います。今回は、フロントの開発にVite+Reactを使用しました。他にもNext.jsやSvelteなども使えるっぽいです。
$ yarn create tauri-app
yarn create v1.22.21
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Installed "create-tauri-app@3.11.5" with binaries:
- create-tauri-app
✔ Project name · Sample
✔ Package name · sample
✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, bun)
✔ Choose your package manager · yarn
✔ Choose your UI template · React - (https://reactjs.org/)
✔ Choose your UI flavor · TypeScript
Template created! To get started run:
cd Sample
yarn
yarn tauri dev
✨ Done in 24.83s.
さて、出来上がったプロジェクトディレクトリの構成は下記のようになっています。
Sample
- src (Reactのソースファイル)
- ...
- App.tsx
- src-tauri (tauriのソースファイル)
- ...
- src
- main.rs
- 各種Configファイル
src配下のApp.tsxを編集して看板ボードUIを作成し、src-tauri/src配下のmain.rsを編集してサーバーサイドの処理を記述していきます。
開発
全体図
Tauriでは、フロントエンドとバックエンドのやりとりは、プロセス間通信(IPC)でやりとりを行なっているみたいです。
カンバンボードの描画には、react-kanbanというライブラリを使用しています。
SQLiteへのアクセスには、sqlxを使用しています。
Frontの実装
カンバンボードコンポーネントを生成する際に、 SQLiteから読み込んだ値を表示したいので、TauriAPIを使ってバックエンドにIPCでの通信を行います。
...省略...
// カードの追加直後に呼ばれるハンドラ
async function handleAddCard(board: TBoard, column: TColumn, card: TCard) {
const pos = new CardPos(column.id, 0);
// IPCでCoreプロセスのhandle_add_cardを呼ぶ(引数はJSON形式)
await invoke<void>("handle_add_card", { "card": card, "pos": pos })
}
// カードの移動直後に呼ばれるハンドラ
async function handleMoveCard(board: TBoard, card: TCard, from: TMovedFrom, to: TMovedTo) {
const fromPos = new CardPos(from.fromColumnId, from.fromPosition);
const toPos = new CardPos(to.toColumnId, to.toPosition);
await invoke<void>("handle_move_card", { "card": card, "from": fromPos, "to": toPos })
}
// カードの削除直後に呼ばれるハンドラ
async function handleRemoveCard(board: TBoard, column: TColumn, card: TCard) {
await invoke<void>("handle_remove_card", { "card": card, "columnId": column.id })
}
const [board, setBoard] = useState<TBoard | null>(null);
// ボードのデータを取得する
useEffect(() => {
(async () => {
// IPCでCoreプロセスのget_boardを呼ぶ
const board = await invoke<TBoard>("get_board", {})
.catch(err => {
console.error(err);
return null
});
console.debug(board);
setBoard(board);
})();
}, []);
return (
{board != null &&
<Board
initialBoard={board}
allowAddCard={{ on: "top" }}
allowRemoveCard
disableColumnDrag
onNewCardConfirm={(draftCard: any) => ({
id: new Date().getTime(),
...draftCard
})}
onCardNew={handleAddCard}
onCardDragEnd={handleMoveCard}
onCardRemove={handleRemoveCard}
/>}
)
...省略...
コンポーネントに起こるイベントに対して、定義したメソッドを渡すことで、メソッド内でバックエンドで定義されている処理を呼び出します。invoke()
の第一引数にメソッド名を、第二引数にデータをJSON形式で指定します。
続いて、main.rs
の内容になります。
use serde::{Deserialize, Serialize};
/// ボード
#[derive(Debug, Serialize, Deserialize)]
pub struct Board {
columns: Vec<Column>,
}
/// カラム
#[derive(Debug, Serialize, Deserialize)]
pub struct Column {
id: i64,
title: String,
cards: Vec<Card>,
}
impl Column {
pub fn new(id: i64, title: &str) -> Self {
Column {
id,
title: title.to_string(),
cards: Vec::new(),
}
}
pub fn add_card(&mut self, card: Card) {
self.cards.push(card);
}
}
/// カードを表す
#[derive(Debug, Serialize, Deserialize)]
pub struct Card {
id: i64,
title: String,
description: Option<String>,
}
impl Card {
pub fn new(id: i64, title: &str, description: Option<&str>) -> Self {
Card {
id,
title: title.to_string(),
description: description.map(ToString::to_string),
}
}
}
...省略(後述)...
Reactから、データがJSON形式で送られてくるので、構造体定義の際にはシリアライズできるようにserdeパッケージを使います。(use serde::{Deserialize, Serialize};
)
続いてReact側で呼び出すハンドラーを作ります。
...省略...
/// カードの追加直後に呼ばれるハンドラ
#[tauri::command]
async fn handle_add_card(card: Card, pos: CardPos) -> Result<(), String> {
// IPCで受信したデータをデバッグ表示する
println!("handle_add_card ----------");
dbg!(&card);
dbg!(&pos);
Ok(())
}
/// カードの移動直後に呼ばれるハンドラ
#[tauri::command]
async fn handle_move_card(card: Card, from: CardPos, to: CardPos) -> Result<(), String> {
...省略...
/// カードの削除直後に呼ばれるハンドラ
#[tauri::command]
async fn handle_remove_card(card: Card, column_id: i64) -> Result<(), String> {
...省略...
とりあえず、受け取ったカードやポジションの情報をデバッグログに吐き出させるようにします。
そして最後に、メイン関数の処理を書きます。
fn main() {
tauri::Builder::default()
// ハンドラを登録する
.invoke_handler(tauri::generate_handler![
get_board,
handle_add_card,
handle_move_card,
handle_remove_card
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
ここら辺はTauriのお作法的な書き方なのですが、Reactで呼び出すハンドラを全て登録しておきます。
動作確認
これで、画面の操作情報がTauri側でハンドルできるようになったので、動かしてみます。
ちなみにTauriはホットリロードしてくれるので、常にyarn tauri dev
しておけば最新の動作を確認できるのが嬉しいです。そしてViteのビルドが早いのも開発者体験の向上に寄与してくれています。
DB周り?
残すところはsqlxを用いたSQLiteへのCRUDな訳ですが、少し長くなったので割愛します
(詳しくは冒頭で貼った、参考ページをご覧ください)
最後に
普段はScalaを書いているのですが、RustにもScalaと似た機能が沢山あって書き味を結構似せることができるな、と思いました。式指向だったり、Match Caseの存在、そしてOption型・Result型(Either)などはScalaでもよく使われると思いますので、親近感を覚えますね。
今回は簡単なアプリケーションでしたので設計などほとんど無かったですが、Clean Architectureを導入してみたいので、やる気と体力があれば次回はTauri x React x CleanArchitecture
に入門、というか挑戦したいです。
最後まで読んでいただきありがとうございました。