Rust+Webフロントの最前線!tauriを試してみた

@Pctg_x8です。

Deno 1.0の登場でRustとWeb関連技術の繋がりがより高まっていく中で、Electronライクな新しいアプリケーションフレームワークである「tauri」を見つけましたのでちょっと触ってみようと思います。

tauriについて

公式サイト: https://tauri.studio/

※この記事ではv0.9.2をベースに解説しています。 tauriはまだメジャーバージョンが1になっていないため、頻繁にAPIの変更が起こる可能性があります。

概要

フロントはElectronと同じくWebViewですが、ベースの起動プログラム(Main Process)をRustで書くことができるものです。

ElectronではMain ProcessもJavaScript(Node.js)なので、例えば大量のデータを並行してバッと読むとか解析するとかの処理をさせようとすると マルチプロセス立てたりしないといけなくてちょっと面倒だな〜というところがありますが、tauriではMain ProcessはRustでネイティブアプリになるので この辺りの問題があらかた解決します。スレッドも任意のasync Executorも使いたい放題です。

類似プロダクトとしてGotronがありますが、あちらは裏でElectronを自動起動して、 nodejsとGoをWebsocketで繋ぐことでMain Processの一部の処理をGoで書けるような形になっています。 tauriはElectronを使用しておらず、Main Processは完全にRustプログラムで書かれるようになります。

特徴

tauri最大の特徴としては、プログラムが占めるバイトサイズがElectronと比較してパッケージ/メモリ共に圧倒的に小さいところでしょう。 公式の比較表は流石にちょっと極限すぎるかなーと思っていたんですが、実際に使ってみると確かにパッケージサイズ1/10、メモリ消費量1/2くらいになっています。

また、tauriもRust系プロダクトの例に漏れずCLIがしっかり作られています。 tauri自身に関係するようなところのみを最小限で自前で持って、それ以外はwebpackやVue CLIなどと連携することで、オプションとして覚えるべきことを最小限にし、 フロント部分は従来と変わらない構成でネイティブアプリのビルド/パッケージングを行うことができます。

構成

tauriのアプリケーション構成にはいくつか種類があり(Patternsと呼ばれています)、 用途に応じて使い分けることができます。 いくつかあるんですが、Electronからの移植要件程度であればLockdownで事足りるかなと思います。

Lockdown Patternは公式サイトの図を見ていただければわかる通り、WebViewとRustプロセスがAPIを通してダイレクトに通信を行います。 雰囲気としてはElectronのIPCともっとも近く、使い方も似ているのでとっつきやすいと思います。

注意点

ElectronはどのプラットフォームでもChromiumですが、tauriはプラットフォームごとにWebViewのエンジンが異なります。

  • WindowsではMSHTMLまたはEdge
  • macOSではWebKit
  • Linuxではgtk-webkit2

そのため、ブラウザ固有の罠を踏む可能性がElectronより高くなりますが、まあbabelとかでpolyfillすることも多いですし、tauri自身もいくつかpolyfillを注入してくれるのでそこまで困ることはないかなと思います。

通信プロトコル

Main ProcessとRenderer Process間の通信(というより、データパッシング)にはJSONを使用します。 より厳密には、

  • Renderer Process -> Main ProcessへのPassing
    • JavaScript側に提供されるAPI tauri.promisified (非同期)や tauri.invoke (同期)に渡されたオブジェクトを、Rust側 tauri::app::AppBuilder::invoke_handler で指定されたハンドラではJSON形式の String で受け取ることができる。
  • Main Process -> Renderer ProcessへのReturn: Passing時の利用APIによって異なる
    • tauri.promisified を使用した場合
      • 渡ってきたJSONオブジェクトに callbackerror と名付けられたフィールドが追加されているため、これらの関数を必要に応じて呼び出す。
      • Result を返す処理があるなら、ヘルパー関数 tauri::execute_promise_sync (スレッドプールを使用して並行処理するなら tauri::execute_promise) を使用することで callbackerror を組み合わせて良い感じに値を返すことができる。内部ではserde_jsonを使っているため、 serde::Serialize を実装している型であればなんでも返すことができる。
      • 所有権の関係でクロージャから値を返す形にできない場合は、先に tauri::api::rpc::format_callback を使用してコールバック処理を文字列にフォーマットし、 WebviewMut::dispatch を使って適切にWebView内でevalしてあげるようにする。
    • tauri.invoke を使用した場合
      • 値を返すことはできない。やる方法もあるかもしれないけど、返して欲しいなら tauri.promisified があるのでそちらを使う。

Hello Worldする

サンプルとしては月並みですが、触ってみたなのでとりあえずHello Worldでどれくらいtauri cliが扱いやすいかを紹介します。

まずは普通にWebフロントエンドのプロジェクトを作成します。addすべきパッケージは以下です。webpackを使うのでReactもとりあえず入れておきます。

$ yarn add react react-dom
$ yarn add --dev tauri webpack webpack-cli webpack-dev-server typescript ts-loader @types/react @types/react-dom

続いて、tauri関係の初期化を行います。

$ yarn tauri init

各設問には以下のように答えておきます。特に3番目と4番目が重要で、webpack-dev-serverの配信URLとwebpackのビルド出力先に合わせておく必要があります。 4番目は書いてある通り $(pwd)/src-tauriから見た相対ディレクトリ なので少し注意が必要です。

yarn tauri init

設問に答えた後はtauriの依存パッケージのインストールなどが始まり、しばらくすると完了します。 今回はとりあえずHello Worldなので、以下のようなファイルを用意しました。

// src/index.tsx

import * as React from "react";
import * as ReactDOM from "react-dom";

function App() {
    return <h1>hello world</h1>;
}

document.addEventListener("DOMContentLoaded", () => {
    ReactDOM.render(<App />, document.body);
});

<!-- dist/index.html -->
<!DOCTYPE html>
<html>
    <head>
        <script src="./index.js"></script>
    </head>
    <body>
        
    </body>
</html>

// webpack.config.js

const path = require("path");

module.exports = {
    mode: "development",
    entry: path.resolve(__dirname, "src/index.tsx"),
    output: {
        filename: "index.js",
        path: path.resolve(__dirname, "dist")
    },
    resolve: {
        extensions: [".js", ".jsx", ".ts", ".tsx"]
    },
    module: {
        rules: [
            { test: /.tsx?$/, use: "ts-loader" }
        ]
    },
    devServer: {
        contentBase: "dist"
    }
};

tsconfig.jsonは省略します。

上記準備ができたら、次は src-tauri/tauri.conf.json を適切に書き換えます。initの時点である程度の雛形はできていますが、tauriの起動時に自動でwebpackによるビルドを走らせたいので、その設定を加えます。書き換えるのは2行だけです。

{
    "build": {
      "distDir": "../dist",
      "devPath": "http://localhost:8080/",
      "beforeDevCommand": "yarn webpack-dev-server",
      "beforeBuildCommand": "yarn webpack"
    },

build.beforeDevCommandtauri dev 時に起動するコマンドを、 build.beforeBuildCommandtauri build 時に起動するコマンドを指定します。特に凝ったことをしないのであれば、dev時はHMRを搭載したdevserverの起動コマンドを、build時には最終的なリリース用ビルドコマンドを指定します。

以下のコマンドで開発用にアプリを立ち上げることができます。

$ yarn tauri dev

今回はwebpack-dev-serverを使用しているため、src以下の更新に合わせてフロントが自動で更新されるようになっています。また、tauri自身もRustコードの変更を検知して自動でリビルドとアプリの再起動を行ってくれます。そのため、開発中は基本的にdevでアプリを立ち上げたままで行うことが多いです。

最後にビルドもしてみます。Electronのように特に追加のパッケージとかは必要なくて、以下のコマンドでパッケージを生成できます。

$ yarn tauri build

ビルド中に一時的にdmgファイルがマウントされて馴染みの画面が表示されますが、必要な処理が終わった後は自動でアンマウントされるので触らずに放置してください。

結果は src-tauri/target/release/bundle 以下に生成されます。 osx/*.app がアプリ本体で dmg/*.dmg がディスクイメージになっています。 今回は特にminifyなどはしていませんが、画像の通りappパッケージは4.7MBとかなり出力サイズが小さいことがわかります。

Finder

実際に使ってみた

ちまちまと作っている社内用のゲームデータ閲覧アプリをElectronからtauriに移植してみました。

NodeView

このアプリは、フロント部分がJavaScript(React)で、ElectronのMain Processを介してローカルに立てたデータ処理用のRustサーバと通信してデータを表示する形をとっていました。 今回tauriに移植することでRustサーバ部分をアプリ埋め込みにすることができ、TCPポート的な問題、サーバプロセス管理の問題を解決できた上、いざとなった場合に共有できる形にしやすくなりました。

もともと共有するようなものでもなかったのですが、今回試しにelectron-packagerでパッケージングしたものと比較してみたところ、以下のようになりました。

  • パッケージサイズ: 275MB -> 13MB
    • ※どちらもminifyなし
  • メモリ消費量: 400MB -> 190MB

おわり

tauri自体はまだv0.9.2と発展途上のプラットフォームですが、CLIなど足回りがかなり揃っていてちょろっと使う分には面白かったです。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。