EY-Office ブログ

Rustを勉強したのでTauriのバックエンドを書いてみた

前々回のブログデスクトップアプリ開発ツール Tauri に触れてみたの続きです。前回のブログRustを学んでみた、Rustは超モダンなC言語 !?に書いたようにRust言語の基本を学んだので、Tauriのバックエンド(Core Process)を書いてみました。

作ったのは、Next.jsのReact Server Componentsを試してみたと同じく、ジャンケンReactアプリの対戦結果をRDB(Sqlite3)に格納するTauriアプリを作ってみました。コンピューター側の手の発生やジャンケンの勝敗判定もバックエンドで行う事にしました。画面に関しては前々回のブログと同じです。

tauri-backend Bing Image Creatorが生成した画像を使っています

SQLx

RustでRDBにアクセスする方法は他の言語同様、以下のようなものがあります

  1. RDB毎に作られたライブラリー
  2. 複数のRDBに接続できるライブラリー
  3. ORM(Object-relational mapping)

今回は簡単なDB操作だけなので、ネット上に情報が多かった2.カテゴリーのSQLxを使ってみました(RDBはSQLite3)。
SQLxの特徴はJavaScriptではお馴染みのasync/awaitの非同期処理でRDBがアクセスできる事でしょうか。

また、SQLx CLIをインストールするとデータベースの作成やマイグレーションを実行できますが、今回は使わずにsqlite3コマンドで直接テーブルを作成しました。

サンプルコード

いきなりTauriバックエンドを作るのは難しいと思い。まずはRDBアクセスを行う簡単なRustのプログラムを作成しました。

Cargo.toml

外部クレート(他の言語でいる外部ライブラリー)をCargo.tomlファイルの[dependencies]に書いておくとcargo runした際にインストールされます。

SQLxのInstallにはsqlxをどのように使うかにより[dependencies]の記入方法が書かれているでコピーします。

  • sqlxは非同期処理はtokioを使います、sqlite3を使うのでTLS(SSL通信)は不要です
  • Rustは非同期処理async/awaitをサポートしていますがラインタイムはインストールする必要があります。ここではデファクトスタンダード的なtokioを選択しました
  • randは乱数のライブラリー(クレート)、C言語やJSの標準の乱数よりは安全性が高いようです
[package]
name = "rust_sqlite"
version = "0.1.0"
edition = "2021"

[dependencies]
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] }
tokio = { version = "1", features = ["full"] }
rand = "0.8"

main.rs

このコードではusersテーブルにあるデータの取得(select)、挿入(insert)を行うコードです。

  • ① RDBのデータを格納する構造体
    • derive マクロでデバッグ用プリントとsqlx::FromRowを指定しています
    • FromRowがないとquery_asでのselectの戻り値をUser構造体に入れることができませんでした
  • usersテーブルの全データを取得しUser構造体の配列で戻す関数
    • 引数は、DB接続プール
    • SQLxを使うとasync/awaitで非同期処理が行えます
    • 戻り値は、正常な値とエラーの値を戻せるResult型です
    • query_as関数は実行したSQL文の結果をUser構造体(の配列)を戻します
    • fetch_allメソッドで全データの取得を実行
    • await?メソッドはawiatした結果がエラー値ならここで実行を終了しエラー値を戻します
    • 正しく実行できた場合は、Okバリアントで正常な値usersを戻します
  • usersテーブルにレコードを挿入する関数
    • 引数は、DB接続プールとレコードのnameカラムの値
    • query関数はSQL文を実行関数です
    • bindメソッドでSQL文のプレースホルダー$1に値を割当て、executeメソッドでSQL文の実行します
  • ④ Sqlite3への接続プールを取得する関数
    • 引数は、Sqlite3のデータベースファイルのURL
    • SqlitePool::connectがSqlite3の接続プールを取得する関数
  • #[tokio::main]マクロで非同期処理のメインと指定しています
  • ⑥ DB接続プールを取得しpool変数に代入
  • "test_乱数の値"という文字列を作成しnameに代入
    • C言語ならsprintfを使いますが、Rustではformat!ですね
  • ⑧ nameカラムに"test_乱数の値"の値が入ったレコードを挿入
  • ⑨ 全レコードの取得しprintln!でコンソールに表示
  • ⑩ 非同期処理の終了、戻り値はユニット型
use sqlx::sqlite::SqlitePool;
use sqlx::{FromRow, Sqlite, Pool};
use rand::Rng;

#[derive(Debug, FromRow)]                        // ← ①
pub struct User {                                // ← ①
    pub id: i64,
    pub name: String,
}

//         ↓ ②
async fn get_users(pool: &Pool<Sqlite>) -> Result<Vec<User>, sqlx::Error> {
    let users = sqlx::query_as::<_, User>(
        "SELECT id, name FROM users"
    ).fetch_all(pool).await?;
    Ok(users)
}
//         ↓ ③
async fn create_user(pool: &Pool<Sqlite>, name: &str) -> Result<(), sqlx::Error> {
    sqlx::query("INSERT INTO users(name) values($1)").bind(name).execute(pool).await?;
    Ok(())
}
//         ↓ ④
async fn sqlite_pool(db_url: &str) -> Result<Pool<Sqlite>, sqlx::Error> {
    let pool = SqlitePool::connect(db_url).await?;
    Ok(pool)
}


#[tokio::main]                                                 // ← ⑤
async fn main() -> Result<(), sqlx::Error> {
    let pool = sqlite_pool("sqlite:./db/database.db").await?;  // ← ⑥

    let mut rng = rand::thread_rng();
    let name = format!("test_{}", rng.gen::<u32>());           // ← ⑦
    create_user(&pool, &name).await?;                          // ← ⑧

    let users = get_users(&pool).await?;                       // ← ⑨
    println!("{:?}", users);

    Ok(())                                                     // ← ⑩
}

Tauriバックエンド

いよいよTauriへの組み込みですが、gihyo.jpの「軽量RustフレームワークTauriでデスクトップアプリ開発をはじめよう」のコードがとても参考になりました。ありがとうございます!

React

まずはフロントエンドのReactですが、invoke()関数でバックエンドの関数を呼び出すだけです。

  • ① 人間の手を渡し、ジャンケンを行いDBへの書き込みを行うpon関数の呼び出し
    • 戻り値は、このジャンケンの結果、これをステートに追加します
  • ② DBから全ジャンケンの結果を取得する、get_scores関数の呼び出し
    • 戻り値は、全ジャンケンの結果(配列)、これをステートに設定
  • バックエンドはIPC(プロセス間通信)で呼び出されるので、バックエンド関数の同期・非同期にかかわらずinvoke()関数は非同期呼出しになります

JyankenBox、ScoreBoxコンポーネント等は前々回と同じです。

import { useEffect, useState } from 'react';
import "./App.css";
import { invoke } from '@tauri-apps/api/core';

const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"];

export default  function Jyanken () {
  const [scores, setScores] = useState<ScoreType[]>([]);

  const pon = async (human: Te) => {
    const score = await invoke<ScoreType>('pon', {human: human});    // ← ①
    setScores([score, ...scores]);
  }

  useEffect(() => {
    (async() => {
      const dbScores = await invoke<ScoreType[]>('get_scores', {});  // ← ②
      setScores(dbScores);
    })();
  }, []);

  return (
    <div className="md:ml-8">
      <h1 className="my-4 ml-4 text-3xl font-bold">じゃんけん ポン!</h1>
      <div className="p-3 md:p-6 bg-white md:w-3/5">
        <JyankenBox pon={pon} />
        <ScoreBox scores={scores} />
      </div>

    </div>
  );
}

Rust

バックエンドのコードは、RDB操作関数と、フロントエンドから呼び出されるコマンドハンドラー、メインコードに分かれます。

RDB操作関数

ほぼサンプルコードと同じですが違いは以下のみです

  • 関数名の違い
  • テーブル名とテーブルの構造の違い
  • ① データの構造体Scoreは、Reactから使えるようにSerialize, Deserializeが指定されています
-- テーブル定義
CREATE TABLE scores(
  "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  "human" INTEGER NOT NULL,
  "computer" INTEGER NOT NULL,
  "judgment" INTEGER NOT NULL);
コマンドハンドラー

フロントエンドからIPC(プロセス間通信)で呼び出されます。

  • #[tauri::command]マクロでTauriのコマンドハンドラーだと宣言されています
    • Resultのエラー値はTauriでは文字列に決まっているようです
    • Rsutは広域変数は非推奨なので、DB接続プールはTauriのState Managementを使って共有しています。コマンドハンドラーはState<>型の引数でDB接続プールを受け取っています
    • ③ ここでエラー値のsqlx::Errorから文字列に変換しています
  • ④ コマンドハンドラーはスレッドセーフなコードを書く必要があり、乱数発生の2行をサンプルコードの⑧のように書くとエラーになってしまいます。調べてみたら、この書き方でスレッドセーフになります(いろいろと難しいですね😅)。
  • ⑤ バックエンドのメインコードです、戻り値の型にあるdynの説明はこちら(いろいろと難しいですね😅)。
  • ⑥ このmain関数はasyncにできません。非同期処理であるsqlite_poolを実行し結果を待つためのblock_onを使っています
    • block_on実行中は処理がブロックされてしまうようです
  • ⑦ ②で説明したようにDB接続プールはTauriのState Managementで管理するので、このsetupメソッドでマネージャーを呼び出してDB接続プールを渡しています
メインコード
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use sqlx::sqlite::SqlitePool;
use sqlx::{FromRow, Sqlite, Pool};
use serde::{Deserialize, Serialize};
use tauri::async_runtime::block_on;
use tauri::{Manager, State};
use rand::Rng;

#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Score {                      // ← ①
    pub id: i64,
    pub human: i64,
    pub computer: i64,
    pub judgment: i64
}

async fn sqlite_select_scores(pool: &Pool<Sqlite>) -> Result<Vec<Score>, sqlx::Error> {
    let scores = sqlx::query_as::<_, Score>(
        "SELECT id, human, computer, judgment FROM scores ORDER BY id DESC"
    ).fetch_all(pool).await?;
    Ok(scores)
}

async fn sqlite_insert_score(pool: &Pool<Sqlite>, human: i64, computer: i64, judgment: i64) -> Result<Score, sqlx::Error> {

    let score = sqlx::query_as::<_, Score>(
        "INSERT INTO scores(human, computer, judgment) values($1, $2, $3) returning *")
        .bind(human)
        .bind(computer)
        .bind(judgment)
        .fetch_one(pool).await?;
    Ok(score)
}

async fn sqlite_pool(db_url: &str) -> Result<Pool<Sqlite>, sqlx::Error> {
    let pool = SqlitePool::connect(db_url).await?;
    Ok(pool)
}

#[tauri::command]                          // ← ↓ ②
async fn get_scores(pool: State<'_, sqlx::SqlitePool>) -> Result<Vec<Score>, String>  {
    let scores = sqlite_select_scores(&pool)
        .await
        .map_err(|e| e.to_string())?;      // ← ③
    Ok(scores)
}

#[tauri::command]
async fn pon(pool: State<'_, sqlx::SqlitePool>, human: i64) -> Result<Score, String>  {
    let computer = {
        let mut rng = rand::thread_rng();  // ← ④
        rng.gen_range(0..=2)
    };
    let judgment = (computer - human + 3) % 3;
    let score = sqlite_insert_score(&pool, human, computer, judgment)
        .await
        .map_err(|e| e.to_string())?;
    Ok(score)
}


#[cfg_attr(mobile, tauri::mobile_entry_point)]                      // ← ⑤
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = block_on(sqlite_pool("sqlite:../db/database.db"))?;  // ← ⑥

    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![
            get_scores,
            pon
        ])
        .setup(|app| {                                             // ← ⑦
            app.manage(pool);
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");

    Ok(())
}

Cargo.toml

参考までに

[package]
name = "tauri_jyanken"
version = "0.0.0"
description = "A Tauri Jyanken App"
authors = ["yuumi3"]
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
name = "tauri2_2_lib"
crate-type = ["lib", "cdylib", "staticlib"]

[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }

[dependencies]
tauri = { version = "2.0.0-rc", features = [] }
tauri-plugin-shell = "2.0.0-rc"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] }
tokio = { version = "1", features = ["full"] }
rand = "0.8"

まとめ

Rust言語

前回のブログに書いたように、Rustは超モダンなC言語だと思います。
厳密な静的型付と所有権により、安全なプログラムを書く事ができます。そしてC言語とほぼ同じ速度で動作するプログラムを生成可能です。またビルド環境やライブラリーが現代的になっているのは素晴らしいですね!

ただし、文字列と数値を連結するだけで悩むのはC言語的ですね。またString&strの違いなど、どのような機械語が生成されて動作しているのかイメージ出来ないと、効率の良いプログラムが書けないですね。

マクロなどまだまだ学ばないと行けない事がありますが、今後C言語レベルを必要とするプログラムを作る機会があれば、本気でRustを使って作ってみたいと思います。

Tauri

Tauriの記事を読んだ時に、TauriElectronに変わるプラットフォームかと思ったのですが、実は違うプラットフォームですね。
両者共にフロントエンドはHTML, CSS, JavaScriptですが、バックエンドがJavaScriptかRustかの違いはかなり大きいです。

私が関わるような世界では、バックエンドもJavaScript(TypeScript)で書けるElectronの方が生産性の高さが有用だと思います。今回Rustのプログラムを書いてみてJavaScriptに比べると生産性の低さを実感しました。

ただし、すでにRsutのコード資産があり、それにWeb-UI付けたい場合や。Electronよりコンパクトで高速に動くツールを作りたい場合にはTauriは有用だと思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ