RustのWebフロントエンドフレームワーク「Kagura」をElectron上で動かしてみる


RustでもWebAssembly出力ができるようになっているんですが、いつか触ろうと思いつつずっと触る機会がありませんでした。
ちょうど自身の周りでもElectronの利用事案が増えて来て(例えばPostludium/Peridotでパイプラインの設計エディタのためにElectron使おうと思ったこともありました......)、良い機会なので入門として件名のライブラリを触ってみることにしました。

Kaguraとは

Rust製のWebフロントエンドフレームワークです。端的に言ってしまえばReactVueなどの仲間です。
仮想DOMを用いて差分レンダリングを行う点は既存のフレームワーク同様ですが、アーキテクチャ的にはHalogenElmが近いです
(いわゆるTEAの形をとっています)。

RustでWebAssemblyを出力する、動かす

Rustにはwasm-bindgenという便利ツール/クレートが存在しており、それらを活用することで非常に簡単にJavaScriptとFFIできます。

自身のコード側で #[wasm_bindgen] アトリビュートを関数定義につけることで、その関数をJavaScript側から呼び出せるようになります。
#[wasm_bindgen(start)] を指定するとその関数はエントリポイントと見なされ、モジュールのInstantiate時に自動で呼び出されるようになります。
つまりJavaScriptでトップレベルに書くのとほぼ同等になります。

逆に、外部関数宣言 extern "C" に同様のアトリビュートをつけることで、JavaScript側で定義された関数を呼び出すことができるようになります。

バイナリ作成についてもそこまで難しいことはなく、公式のビルドツール「cargo」に --target wasm32-unknown-unknown をつけるだけで大元となるwasmバイナリを生成することが可能です。ただしwasm-bindgenを使用している場合、このままでは使用できるwasmバイナリにはなっていません。
ここから「wasm-bindgen」なるツールを使用して、実際にJavaScriptで使いやすい形に関数をエクスポートしたりします。ここまでで、node.jsならほぼそのまま動かせるjsファイルが生成されます。

注意点として、node.jsで使うJavaScriptファイルをwasm-bindgenで出力する場合、JavaScriptのバージョンがnode.jsで直接読めるバージョンより若干新しいようなので、wasm-bindgenに「--nodejs」オプションも追加で渡す必要があります。

wasm-bindgenは公式にWebpackのプラグインを提供しており、使い慣れたWebpackを用いて既存のJavaScript資産を自然に統合することができます。

Electronアプリを100%Rustで書く

作者の記事1ではwebpack-dev-serverを使ってWebアプリを作成する例でしたが、こちらではせっかくなのでElectron上で動くようにしてみました。
JavaScriptのElectronモジュールのバインディングであるelectron-sysクレートがあるので、あまり凝ったことをしなければ(ipc周りは定義がありませんでした......)比較的容易にメインプロセスも作成することが可能です。

Electronのメインプロセスはほぼnode.jsそのものなので、通常のnode.js向けの出力を作る感覚で動くものが作れます。
理論上は100%純Rustで書けるはずなのですが、WebAssemblyからでは初期ページのロードに必要な __dirname などを参照することができず、かといって std::env::current_dir() もwasm環境では使えないのでここだけJavaScriptから渡してもらう必要があります。
なので純度としては99.99%程度です。 が、まあ実質100%と見て良いでしょう。

触ってみる

Rendererプロセスのコードは参考URL1のものとほとんど同じなので、ここではMainプロセスのコードのみ提示します。先に話したとおり、(Rust独自の制約によるものを除けば)ほとんどJavaScriptで書いていたものと同じであることが読み取れると思います。

main/src/lib.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use std::rc::Rc;
use std::cell::RefCell;
use std::path::Path;

// これをJavaScriptから呼んでもらう
// require("./main/main").rs_main(__dirname) みたいに
#[wasm_bindgen]
pub fn rs_main(basedir: String)
{
    // クロージャの寿命で生きられるようにRc+RefCellで囲う
    let main_window = Rc::new(RefCell::new(None));

    let main_window_aor = main_window.clone();
    let app_on_ready =  Closure::wrap(Box::new(move ||
    {
        *main_window_aor.borrow_mut() = Some(launch(&basedir));
    }) as Box<dyn Fn()>);
    electron_sys::app.on("ready".into(), app_on_ready.as_ref().unchecked_ref());
    electron_sys::app.on("all-window-closed".into(), &js_sys::Function::new_no_args("app.quit()"));
    app_on_ready.forget();
}

fn launch(basedir: &str) -> electron_sys::BrowserWindow
{
    let bw_opts = electron_sys::Options
    {
        width: 800, height: 600
    };
    let bw = electron_sys::BrowserWindow::new(Some(bw_opts));

    let index_path = Path::new(basedir).join("./renderer/index.html");
    bw.load_file(index_path.to_str().expect("invalid utf-8 seq").into());

    bw
}

なお、ディレクトリ構成としては以下を想定しております。

+ /
+-- main: Mainプロセスのコードがあるクレート
+-- renderer: Rendererプロセスのコードがあるクレート
+-+ dist: Electronを実行する際のディレクトリ
  +-- main: Mainプロセスの最終成果物
  +-- renderer: Rendererプロセスの最終成果物
  +-- index.js: ブートストラップコード ここからmain/main.jsのrs_mainを呼ぶ

main,rendererをcargoでビルド後、適切にdist/main,dist/rendererにデプロイする
アプリ起動は `electron dist` で行う

触ってみた感想

強い静的型付けとライフタイムという安全性があるので、非常に精神的に健康を保ったままElectronアプリを書ける気がしてきました。
electron-sysは非常にプリミティブなバインディングなので、ところどころJavaScriptの生の型を意識しないといけなくてそこだけ少ししんどいですが、
Kagura自体は(まだ開発中とはいえ)TEAの構成をきれいにRustに落とし込んだ感じなので、Elmの経験があればスッと移行できると思います。

あとRustのフロントエンドフレームワークはYewなんかもあるので、そちらも触ってみたいですね。

Rustのエコシステムは、言語本体の難易度の高さとは裏腹に頭おかしいくらいすごく充実しているので、一度触るだけ触ってみることをおすすめします。システムプログラミングの深淵が見えると思います。