[Rust 実践] #13 コマンドとイベント — 画面と Rust の橋渡し、スレッドと状態

[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、AtomicInteger400,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)で、実際に実行した結果です。tauri 2.12.5、tauri-build 2.7.4、tokio 1.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回・完結)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI

実践編(8回・予定。内容は、書きながら変わる可能性があります)

  1. Cargo・クレート・モジュール構成
  2. ファイル操作・パス・文字コード
  3. 小さな CLI を作り込む(簡易アーカイバ)
  4. Tauri v2 の構造(フロントと Rust の役割分担)
  5. コマンドとイベントの橋渡し(今回・連載の第13回)
  6. グローバルホットキー常駐(shotkey を分解)
  7. 設定の保存・トレイ・自動起動
  8. ビルド・署名・GitHub リリース