[Rust 実践] #13 コマンドとイベント — 画面と Rust の橋渡し、スレッドと状態
はじめに
前回は、画面から Rust を呼ぶ invoke と、#[tauri::command] の対応を見ました。今回は、その先の 4 つを、実際に動かして確かめます。
| 見るもの | 確かめること |
|---|---|
状態(State) | コマンドをまたいで、値を持ち続けられるか |
| 実行するスレッド | 同期コマンドと async コマンドは、どこで動くか |
| 引数名 | snake_case の Rust の引数を、画面からどう呼ぶか |
| イベント | Rust から画面へ、画面から Rust へ、知らせられるか |
状態を持つ: manage と State
Rust 側で値を持ち続けたいときは、Builder の manage に渡します。コマンドでは、State<T> の引数で受け取ります。
use std::sync::Mutex;
use tauri::State;
// アプリ全体で共有する状態。manage() に渡した型が、State<T> で受け取れる。
struct Counter(Mutex<u32>);
// 同期コマンド。State から状態を取り出して更新する。
#[tauri::command]
fn bump(state: State<Counter>) -> u32 {
let mut n = state.0.lock().unwrap();
*n += 1;
*n
}
pub fn run() {
tauri::Builder::default()
.manage(Counter(Mutex::new(0)))
// ...
ここでは、値を書き換えたいので、Mutex で包んでいます(Mutex は、この連載では、まだ出てきていない型です。「同時に 1 か所からしか触れない」ようにする入れ物、と考えてください。State の参照の性質は、今回は、確かめていません)。
動かして確かめる
前回と同じ作りの最小アプリ(tauri 2.12.5)に、コマンドを足して確かめました。画面側は、前回と同じく、結果を report コマンドで Rust 側の標準出力に流しています。起動の方法も、前回と同じです(Xvfb の上、3 つの環境変数つき)。
State は、呼び出しをまたいで残る
[frontend] bump -> 1
[frontend] bump -> 2
[frontend] bump -> 3
3 回呼んで、1、2、3 と増えました。Counter は、manage に渡した 1 つが、アプリが動いている間、残っています。
State を登録し忘れると
manage に渡していない型 NotManaged を、State<NotManaged> で要求するコマンドを、わざと作りました。
struct NotManaged;
#[tauri::command]
fn needs_unmanaged(_s: State<NotManaged>) -> u32 {
1
}
画面から呼ぶと、次のとおりでした。
[frontend] needs_unmanaged rejected -> state not managed for field `s` on command `needs_unmanaged`. You must call `.manage()` before using this command
ビルドは通り、呼んだときに、reject されます。 登録し忘れは、コンパイルでは、見つかりません。ただ、メッセージが親切で、何をすればよいかまで書いてあります。
同期コマンドは、main スレッドで動く
スレッドの名前を返すコマンドを 2 つ作りました。
fn thread_name() -> String {
let t = thread::current();
t.name().unwrap_or("(unnamed)").to_string()
}
#[tauri::command]
fn whoami() -> String { // 同期
thread_name()
}
#[tauri::command(async)]
fn whoami_async_attr() -> String { // 同期の関数に、(async) を付けた
thread_name()
}
[frontend] whoami (sync) -> main
[frontend] whoami_async_attr -> tokio-rt-worker
普通の同期コマンドは main(アプリの本体のスレッド)で、#[tauri::command(async)] を付けると tokio-rt-worker で動きました。公式のドキュメントにも、「async のないコマンドは、#[tauri::command(async)] を付けない限り、メインスレッドで実行される」という趣旨の説明があります(取得ツールが返した要約を通して確認)。
同期コマンドの中で待つと、他のコマンドが止まる
「同期コマンドは main スレッド」の意味を、確かめます。400 ミリ秒待つコマンドを呼び、その直後に、すぐ返るコマンド(whoami)を呼んで、whoami が何ミリ秒後に返るかを測りました。
#[tauri::command]
fn block_sync(ms: u64) -> String { // 同期の中で待つ
thread::sleep(Duration::from_millis(ms));
format!("blocked {ms}ms on {}", thread_name())
}
#[tauri::command]
async fn block_in_async(ms: u64) -> String { // async の中で、同期の sleep
thread::sleep(Duration::from_millis(ms));
format!("blocked {ms}ms on {}", thread_name())
}
2 回実行した結果です。
[frontend] block_sync: whoami answered at 411ms; blocked 400ms on main; total 411ms
[frontend] block_in_async: whoami answered at 2ms; blocked 400ms on tokio-rt-worker; total 402ms
[frontend] block_sync: whoami answered at 406ms; blocked 400ms on main; total 406ms
[frontend] block_in_async: whoami answered at 1ms; blocked 400ms on tokio-rt-worker; total 402ms
block_sync: すぐ返るはずのwhoamiが、400 ミリ秒を超えてから返りました。待っている間、main スレッドが塞がっていたためです。block_in_async:whoamiは 1〜2 ミリ秒で返りました。待っているのは、別のスレッド(tokio-rt-worker)だからです。
重い処理や、待ち時間のある処理は、async にするか、(async) を付けるのが、よさそうです。ただし、この実験が示すのは、この環境の、この 2 つのコマンドの結果までです。画面が実際に固まって見えるかどうか(描画への影響)は、見ていません。
async コマンドは、待ちながら他を通す
await で待つ async コマンドは、待っている間も、他のコマンドを受け付けます。
#[tauri::command]
async fn slow_echo(ms: u64, msg: String) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{msg} ({ms}ms)")
}
slow_echo(300 ミリ秒)を呼んだ直後に、bump を呼びました。
[rust] slow_echo start on thread tokio-rt-worker
[rust] bump on thread main
[frontend] bump during slow_echo -> 4 at 2ms
[rust] slow_echo end on thread tokio-rt-worker
[frontend] slow_echo -> hi (300ms) at 303ms
bump は 2 ミリ秒で返り、slow_echo は 303 ミリ秒で返りました。bump の結果が 4 なのは、最初の 3 回の続きです(State が残っている)。なお、tokio::time::sleep を使うために、Cargo.toml に tokio = { version = "1", features = ["time"] } を足しました(今回の Cargo.lock では 1.54.1)。
引数名は camelCase
前回、invoke("count_text", { txt: "x" }) が、引数名の違いで reject されるのを見ました。では、Rust の snake_case の引数は、何で呼ぶのでしょうか。
#[tauri::command]
fn add_named(first_value: i32, second_value: i32) -> i32 {
first_value + second_value
}
[frontend] add_named camelCase -> 3
[frontend] add_named snake_case rejected -> invalid args `firstValue` for command `add_named`: command add_named missing required key firstValue
{ firstValue: 1, secondValue: 2 } は通り、{ first_value: 1, second_value: 2 } は reject されました。Rust は snake_case、画面は camelCase で、変換は自動です。メッセージは、Rust 側の名前(first_value)ではなく、画面側の名前(firstValue)を、探しています。
画面側も snake_case にしたいときは、rename_all を付けます。
#[tauri::command(rename_all = "snake_case")]
fn add_snake(first_value: i32, second_value: i32) -> i32 {
first_value + second_value
}
[frontend] add_snake snake_case -> 3
[frontend] add_snake camelCase rejected -> invalid args `first_value` for command `add_snake`: command add_snake missing required key first_value
今度は、snake_case が通り、camelCase が reject されました。
イベント: Rust から画面へ
コマンドは「画面が呼ぶ」ものでした。Rust のほうから知らせたいとき(時間がかかる処理の進み具合など)は、イベントを使います。
use tauri::{AppHandle, Emitter};
#[derive(Clone, Serialize)]
struct Tick {
i: u32,
}
// Rust から画面へ。別スレッドで 3 回 emit する。
#[tauri::command]
fn start_ticker(app: AppHandle, n: u32) {
thread::Builder::new()
.name("ticker".into())
.spawn(move || {
for i in 1..=n {
thread::sleep(Duration::from_millis(50));
app.emit("tick", Tick { i }).unwrap();
}
app.emit("ticker-done", ()).unwrap();
})
.unwrap();
}
AppHandleは、前回出てきた、アプリ全体を指すハンドルです。別のスレッドにmoveで渡せます(コンパイルが通っています)。app.emit("名前", 値)で、画面に知らせます。値はSerializeとCloneが必要です。start_ticker自体は、スレッドを起こして、すぐ返ります。
画面側は、listen で受けます。
const { listen, emit } = window.__TAURI__.event;
const ticks = [];
const un = await listen("tick", (e) => ticks.push(e.payload.i));
const done = new Promise((res) => listen("ticker-done", () => res()));
await invoke("start_ticker", { n: 3 });
await done; un();
await log("ticks received -> " + JSON.stringify(ticks));
listen は、受信をやめるための関数(un)を返す Promise です。結果です。
[frontend] ticks received -> [1,2,3]
別スレッドから emit した 3 件が、順番どおり(1、2、3)に届きました。
イベント: 画面から Rust へ
逆向きも、イベントで送れます。Rust 側では、listen で受けます。
use tauri::Listener;
.setup(|app| {
// 画面 → Rust のイベントを、Rust 側で受ける。
app.listen("from-frontend", |event| {
println!(
"[rust] got event from-frontend payload={} on thread {}",
event.payload(),
thread_name()
);
});
Ok(())
})
画面側は、emit("from-frontend", { hello: "rust" }) です。
[rust] got event from-frontend payload={"hello":"rust"} on thread tokio-rt-worker
Rust 側のコールバックが、{"hello":"rust"} を受け取りました。動いたスレッドは tokio-rt-worker でした。event.payload() は、JSON の文字列です。
コマンドとイベントの違い
公式のドキュメントには、「コマンドと違い、イベントは型安全ではなく、常に非同期で、値を返せず、JSON の値だけを運ぶ」という趣旨の説明があります(取得ツールが返した要約を通して確認)。今回動かした範囲では、次のようにまとめられます。
| コマンド | イベント | |
|---|---|---|
| 向き | 画面 → Rust(呼ぶ) | どちらからでも(知らせる) |
| 戻り値 | ある(Promise の結果、Err は reject) | ない |
| 引数と型 | Rust の関数の型で決まる(引数名は camelCase) | 名前と JSON の値だけ |
| 名前を間違えたとき | Command ... not found で reject | 今回は、確かめていません |
Java と並べる
Java で、「リスナーを登録して、別のスレッドから知らせる」小さな仕組みを書いて、動かしました(Map にリスナーを入れ、emit で順に呼ぶだけの、自作の Bus です。Tauri の中身ではなく、考え方の対比です)。
static void emit(String name, String payload) {
for (Consumer<String> f : listeners.getOrDefault(name, List.of())) f.accept(payload);
}
// ...
Thread t = new Thread(() -> { for (int i = 1; i <= 3; i++) emit("tick", "{\"i\":" + i + "}"); }, "ticker");
listener got {"i":1} on thread ticker
listener got {"i":2} on thread ticker
listener got {"i":3} on thread ticker
main is main
この Bus では、リスナーは、emit を呼んだスレッド(ticker)の上で動きます。これは、この自作の Bus の作りです。Tauri のイベントでは、画面側の listen は JavaScript の中で、Rust 側の listen は tokio-rt-worker の上で動きました。
状態の共有でも、並べました。4 つのスレッドで、100,000 回ずつ、カウンタを増やします(期待する値は 400,000)。
| 言語と書き方 | 結果 |
|---|---|
Java、int を racy++(保護なし) | 379,884、244,214(2 回の実行。実行のたびに違う) |
Java、AtomicInteger | 400,000 |
Rust、Arc<Mutex<u32>> | 400,000 |
Rust、保護なしの u32 を、スレッドで更新 | コンパイルエラー(E0373、E0502) |
Java は、保護なしでもコンパイルが通り、値が少なくなりました。Rust は、保護なしだとコンパイルが通りません。Counter(Mutex<u32>) という形は、「触るなら、先に lock する」ことを、型で決めています。ただし、前に見た State の登録し忘れは、型では防げず、実行時のエラーでした。
初めて出てきた書き方
| 書き方 | 意味 |
|---|---|
Builder::manage(値) | アプリ全体で共有する状態を登録する |
State<T> | コマンドの引数に書くと、登録した状態(共有の参照)を受け取れる |
Mutex<T> / .lock().unwrap() | 中の値を、一度に 1 か所からだけ触れるようにする |
async fn / .await | 待っている間、他の処理に場所を譲る関数 |
#[tauri::command(async)] | async のない関数を、別のタスクで動かす |
#[tauri::command(rename_all = "snake_case")] | 画面側の引数名を、snake_case にする |
app.emit("名前", 値) | イベントを送る(Emitter を use する) |
app.listen("名前", |event| ...) | イベントを受ける(Listener を use する) |
thread::Builder::new().name("...").spawn(...) | 名前を付けてスレッドを起こす |
listen("名前", callback) / emit("名前", 値) | (JavaScript)イベントの受信と送信。listen は受信をやめる関数を返す |
歴史
「何かが起きたら、登録した相手に知らせる」仕組みは、Observer パターンと呼ばれる設計の型です。
| 年月 | できごと |
|---|---|
| 1994年10月 | 『Design Patterns』(Gamma、Helm、Johnson、Vlissides。Addison-Wesley)が、OOPSLA(オレゴン州ポートランド)で公開された(Wikipedia の記述。刊行物の著作権表示は 1995 年) |
Wikipedia のこの本の項目では、Observer は 23 のパターンのうち、振る舞いに関するパターンとして挙げられ、「多くのオブザーバーが、イベントを見られるようにする、publish/subscribe のパターン」と説明されています。Tauri の listen と emit は、まさにこの形です。ただし、Tauri のイベントが、この本の Observer パターンから作られた、という資料は、確認していません。「同じ形をしている」というだけです。
動作確認
確認できたこと
- 上のコード、設定、出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です。
tauri2.12.5、tauri-build2.7.4、tokio1.54.1。Java は OpenJDK 21 Stateの更新が、呼び出しをまたいで残ること(1、2、3)。登録し忘れのStateが、実行時に reject されること- 同期コマンドが
main、asyncコマンドと#[tauri::command(async)]がtokio-rt-workerで動くこと - 同期コマンドの中の 400 ミリ秒の待ちで、
whoamiが 406〜411 ミリ秒後に返ったこと。asyncの中の同じ待ちでは、1〜2 ミリ秒で返ったこと(2 回の実行) asyncコマンドの待ちの間に、別のコマンドが 2 ミリ秒で返ったこと- 引数名が
camelCaseで、snake_caseが reject されること。rename_allで逆になること - Rust から別スレッドで
emitした 3 件が、画面に順番どおり届くこと。画面からemitしたイベントを、Rust のlistenで受けられること cargo clippyが指摘なし、cargo fmt --checkが成功- Java の自作
Busのスレッド名、競合のあるカウンタ(2 回の実行)、AtomicInteger、Rust のMutexの版と、保護なしの版のコンパイルエラー
歴史の出典
- 『Design Patterns』の公開は、Wikipedia のミラー(
kaweah.freedombox.rocks、2023 年 10 月版)の記述です。取得ツールが返した要約を通して確認しました。1 つの出典だけで、全文は読んでいません - Observer の説明(publish/subscribe)も、同じ出典の要約です。Smalltalk や MVC との関係は、その要約には書かれておらず、確認していません
確認できていないこと
- 公式ドキュメント(
v2.tauri.appの「Calling Rust from the Frontend」)の内容は、取得ツールの要約を通して確認しました。「Inter-Process Communication」のページは、取得できませんでした - 同期コマンドが塞がっている間の、画面の描画への影響(固まって見えるか)。確かめたのは、コマンドの応答時間だけです
Resultのエラー型を自作するときの書き方(Serializeの実装)。前回はStringだけを使いました- チャンネル(
Channel)によるストリーミング。公式ドキュメントでは、データを流し続けるときに推奨と書かれていますが、動かしていません - イベントの名前を間違えたとき、1 つの窓だけに送るとき(
emit_to)、多数のイベントが短時間に来たときの順序や取りこぼし - 自分で書いたコマンドに、権限を要求する設定(前回から持ち越し)
- 重い処理を
spawn_blockingに出す書き方、Mutexを持ったままawaitするときの問題 - Windows と macOS での、スレッドの動き
- Tauri のイベントが、Observer パターンを元にしているかどうか
次回
次回は、**グローバルホットキー常駐(shotkey を分解)**を扱います。他のアプリを使っている間も、キーの組み合わせに反応して動くアプリを、今回までの仕組み(コマンド、状態、イベント)を使って、分解して見ます。
連載の目次(予定)
入門編(8回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)
- Tauri v2 の構造(フロントと Rust の役割分担)
- コマンドとイベントの橋渡し(今回・連載の第13回)
- グローバルホットキー常駐(shotkey を分解)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース