Rust and WebAssemblyでSPAを作ってみようWebアプリ実装で学ぶ、現場で役立つRust入門(12)

第12回からは、第11回までで作成した投稿アプリの延長として、Rust and WebAssemblyでTODOアプリを開発します。第12回では、プロジェクトの構築やさまざまな準備のためのコードを通じて、Rust and WebAssemblyの基本的な利用方法を理解します。

» 2024年09月19日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Webアプリ実装で学ぶ、現場で役立つRust入門」のインデックス

連載:Webアプリ実装で学ぶ、現場で役立つRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


Rust and WebAssemblyとは

 WebAssemblyとは、プログラムの高速な実行が特徴である、ブラウザにおける新しい言語フォーマットです。Rust and WebAssemblyとは、RustとWebAssemblyを連携するオープンソースプロダクトを開発するワーキンググループです。それぞれ、@ITの連載「いろんな言語で試す、WebAssembly入門」と、その第5回「RustでWebAssembly――「Rust and WebAssembly」を体験する」で紹介しています。本記事の理解にWebAssemblyの知識は必須ではありませんが、興味があれば一読することをお勧めします。

 Rust and WebAssemblyの主なプロダクトは以下の通りです。本記事では、これらを使って、ブラウザで動作するTODOアプリを作っていきます(図1)。

  • wasm-pack……RustからWebAssemblyファイルなどを生成するツール
  • wasm-bindgen……RustとJavaScriptを連携させるためのライブラリとツール
  • wasm-pack-template……wasm-packによるRustプロジェクトのテンプレート
  • create-wasm-app……Webページのパッケージ(npm init用)

 中核を成すのは、wasm-bindgenです。wasm-bindgenの備えるAPI(js-sys、web-sysクレート)を使ってRustでアプリをコーディングします。js-sysはJavaScript APIを、web-sysはWindowをはじめとするオブジェクトへのアクセスを提供しており、JavaScript側の備える機能にほぼ一対一に対応した構造体、メソッドを使って、ほとんどの処理をRustで記述できます。

図1 作成するTODOアプリのイメージ 図1 作成するTODOアプリのイメージ

Rust and WebAssemblyを使う準備

 Rust and WebAssemblyを使うには、Rustの他にNode.jsが必要ですので、インストールしておいてください。なお、本稿作成時点で最新のNode.jsは20.16で、npmのバージョンは10.8.xです。npmについては、後述のコマンドの実行でエラーとなるので、nvmコマンドで少し古いバージョンの10.7.Xをインストールしてください。

wasm-packをインストールする

 wasm-packをインストールします。macOS環境では、「wasm-pack」に記載されているコマンドでターミナルからwasm-packのインストールを実施します。Windows環境では、同ページからwasm-pack-init.exeをダウンロードしてインストールを実施します。

cargo-generateをインストールする

 Cargoパッケージマネジャーで、テンプレートからプロジェクトを生成可能にするcargo-generateをインストールしておきます。

% cargo install cargo-generate

アプリの作成

 アプリは、Rust and WebAssemblyが提供するテンプレートwasm-pack-templateから作成します。

プロジェクトを作成する

 プロジェクトtodo-appをテンプレートから作成します。コマンドを実行するとプロジェクト名を尋ねられるので、「todo-app」を指定してください。同名のフォルダが作成されるので、以降は、このフォルダで作業します。

% cargo generate --git https://github.com/rustwasm/wasm-pack-template
🤷   Project Name: todo-app
…略…
✨   Done! New project created /Users/…/todo-app

パッケージを作成する

 テンプレートからプロジェクトを作成した時点で、既定のファイルが用意されます。まずはWebページとして動作させて既定の動作を見てみましょう。WebAssemblyパッケージの作成を、wasm-packコマンドにbuildサブコマンドを指定して実行します。

% wasm-pack build
[INFO]: 🎯  Checking for the Wasm target...
…略…
[INFO]: 📦   Your wasm pkg is ready to publish at /Users/…/todo-app/pkg.

 これで、todo-appフォルダに、pkgフォルダが作成されます。pkgフォルダはパッケージとなっており、package.jsonファイル、WebAssemblyへコンパイルした.wasmファイル、それを読み込み実行するJavaScriptヘルパーの.js、.tsファイルなどから構成されます。

 次に、Webページのパッケージを作成します。以下のコマンドで、siteパッケージを作成します(パッケージの名前は何でもOKです)。ここで指定されているwasm-appが、Rust and WebAssemblyのプロダクトcreate-wasm-appに対応します。

% npm init wasm-app site
🦀 Rust + 🕸 Wasm = ❤

パッケージをインストール、リンクする

 ここでsiteフォルダに移動し、依存パッケージを以下のコマンドでインストールします。

% npm install

 pkgパッケージを、siteパッケージの依存パッケージとするための設定を行います。この設定により、プロジェクトの修正がsiteパッケージに反映されるようになります。まず、pkgフォルダに移動して以下のコマンドを実行してください。

% npm link
added 1 package, and audited 3 packages in 1s
found 0 vulnerabilities

 続けて、siteフォルダに移動して以下のコマンドを実行してください。引数のtodo-appは、プロジェクト名に一致する必要があります。

% npm link todo-app
added 1 package, and audited 590 packages in 9s

 ここまでの作業で、todo-app以下の構成は大まかに以下のようになっています。pkgパッケージをsiteパッケージから読み込み、利用する形になります。node_modulesフォルダにあるtodo-appフォルダが、pkgフォルダへのリンクになっているのがポイントです。

todo-app
 +―pkg		wasm-pack buildで作成されたWebAssemblyパッケージ
 +―site		npm initで作成されたWebページのパッケージ
 | +―node_modules	依存パッケージ
 |   +―todo-app	pkgパッケージへのリンク
 +――src		Rustのソースファイル

index.jsファイルを修正する

 最後に、pkgパッケージを利用するために、index.jsファイルをリスト1のように修正します。

import * as wasm from "todo-app";	※既定のhello-wasm-packから修正
wasm.greet();
リスト1:site/index.js

 ここでアプリを実行してみます。Node.jsのバージョンが17.X以降の場合はエラーとなることがあるので、環境変数NODE_OPTIONSを以下のように設定し、実行してください(Windows環境ではSETコマンド)。このオプションは、OpenSSLの旧バージョンを使うためのものでセキュリティ上は好ましくないですが、動作検証と割り切って指定しています。

% export NODE_OPTIONS=--openssl-legacy-provider
% npm run start

 「http://localhost:8080/」にアクセスして、図2のようにポップアップ表示されれば成功です。

図2 既定のアプリ 図2 既定のアプリ

TODOアプリ実装の準備

 TODOアプリとしての機能を実装していく前に、基本的な部分を準備しておきます。

ロガーを準備する

 アプリで発生する問題を解決しやすくするために、ロガーを準備しておきます。ロガーとしては、JavaScriptのconsolo.log関数をそのまま使えます。Cargo.tomlファイルに、以下のコマンドでweb-sysクレートを追加し、リスト2のように[dependencies.web-sys]セクションに修正して、console featureを追加します(続けて必要になるStorage、Window、Document、Element、HtmlElementも、ここで追加してしまっています)。

% cargo add web-sys
web-sys = "0.3.70"
↓
[dependencies.web-sys]
version = "0.3.70"
features = [
  'console',
  'Storage',
  'Window',
  'Document',
  'Element',
  'HtmlElement',
]
リスト2:Cargo.toml

 [dependencies]セクションの記法として、featuresオプションが長くなる場合に、[dependencies.crate]として切り離すことができます。web-sysはたくさんのfeaturesがあり、本記事でも多数利用することになるので、あらかじめこの記法を用いて煩雑になるのを防いでいます。

 続けて、console.log関数をRustから呼び出すコードを、src/utils.rsファイルにリスト3のように追加します。

use wasm_bindgen::prelude::*;	(1)
use web_sys::console;
…略…
pub fn log(s: &String) {	(2)
    console::log_1(&JsValue::from(s));
}
リスト3:src/utils.rs

 (1)は、必要な名前空間の取り込みです。このように、関数コードなどで必要な名前空間は、適宜取り込むようにしてください。以降は、記載を省略します。

 (2)は、log関数の定義です。処理内容は、console::log_1関数の呼び出しです。関数名に数字が付いているのは、log関数の引数の数に合わせてRustの関数が用意されているためです(Rustの関数には可変長引数の仕様がないため)。そのため、log_2,log_3というように引数の個数に合わせた関数がありますが、ここでは簡略化のために引数1個のlog_1関数を使います。複数の引数を使いたい場合は、format!マクロなどで1個の引数にまとめてしまいます。

 これで、「log("hello")」「log(&message)」のように、任意の場所でログをコンソールに書き出すことができます。

【補足】JsValue型

 log_1関数の引数であるJsValue型は、JavaScriptコードとやり取りするための汎用(はんよう)的な型です。リスト3の場合、fromメソッドで&String型からのJsValue生成としているので、log_1関数には文字列への参照が渡りますが、数値や文字列にかかわらず受け入れることができるように、汎用的なJsValueを受け取るようにしているのです。

HTMLを用意する

 アプリの外形となる、HTMLを用意しておきます。既定でHTMLファイルが用意されていますが、中身はbootstrap.jsファイル(index.jsファイルを呼び出すだけ)を呼び出すためだけのものなので、本記事用に全て置き換えてしまうことにします。CSSフレームワークBootstrapを使うなど、基本的な構成は第11回までと同様なので、全容は配布サンプルを参照してください。リスト4には、本アプリで特徴的な部分のみ示します。

…略…
<div>
    <p class="mb-2">		(1)
        <button id="new_button" class="btn btn-primary me-2">+</button>
    </p>
</div>
<div id="main">				(2)
    <p id="no_task">タスクがありません。</p>
    <div id="items"></div>
</div>
…略…
<script src="./bootstrap.js"></script>	(3)
…略…
リスト4:site/index.html

 アプリの実行中に変化しない「+」ボタンなどは、(1)のようにHTMLにあらかじめ埋め込んでしまいます。(2)はメインのコンテンツです。初期内容としては、タスクがない旨のメッセージと、タスク一覧を表示するためのホルダーとなっており、これらは動的に変化します。(3)は、エントリポイントであるindex.jsファイルの、bootstrap.jsファイルを経由した読み込みです。

データストアを作成する

 タスクのデータは、Web Storageに保存することにします。新規にstore.rsファイルを作り、そこにタスクデータの構造体とストアの構造体を定義し、必要なメソッドを実装していきます。

 store.rsには、リスト5のように構造体を定義します。(1)はタスクデータ(タスクID、タイトル、期限、完了フラグ)の構造体Taskです。(2)はストアの構造体TaskStoreで、Storageのインスタンスと名前、Taskのリストを保持します(StorageはWeb Storageを扱うための型)。

// タスクの構造体
pub struct Task {		(1)
    pub id: i32,		// タスクID
    pub title: String,		// タスクのタイトル
    pub period: String,		// タスクの期限
    pub completed: bool,	// 完了フラグ
}
// データストアの構造体
pub struct TaskStore {				(2)
    local_storage: web_sys::Storage,	// Web Storageのインスタンスを保持
    name: String,			// Web Storageの名前を保持
    pub list: Vec<Task>,		// タスクリストを保持
}
impl TaskStore {
    // データストアの初期化関数
    pub fn new(name: &str) -> Option<TaskStore> {	(3)
        let window = web_sys::window()?;
        // WindowオブジェクトからLocal Storageを取得する
        if let Ok(Some(local_storage)) = window.local_storage() {
            let mut store = TaskStore {
                local_storage,
                name: String::from(name),
                list: Vec::new(),
            };
            // 以下はダミーデータ
            let dummy = Task {id: 1, title: "生活費を口座に入金する".to_string(), 
                period: "2024-08-20".to_string(), completed: false};
            store.list.push(dummy);
            …もう2件ほど追加する…
            Some(store)
        } else {
            None
        }
    }
}
リスト5:src/store.rs

 (3)は、TaskStoreに実装するメソッドの一つです。このnewメソッドは、引数のnameをLocal Storageの名前として設定し、local_storageメソッドでLocal Storageオブジェクトを取得、タスクデータを初期化してTaskStoreインスタンスを返します。この時点では、ダミーのタスクデータをハードコードし、タスクデータに追加しています。次回以降で、タスクの追加などの編集機能の実装時に、実際のLocal Storageを使った処理に置き換えていきます。

テンプレートを利用する

 HTMLをコードで生成すると冗長で煩雑になるので、テンプレートを使います。今回は、第4回でも少し紹介した、JinjaベースのテンプレートエンジンであるAskamaを使用します。以下のコマンドで、askamaクレートをプロジェクトに追加してください。

% cargo add askama

 テンプレートファイルは、プロジェクトルートにtemplatesフォルダを作成して設置します。まずはタスクの表示に使うitem.htmlファイルをリスト6のように作成します。

<div id="task{{item.id}}" class="d-flex w-100 border border-1 rounded mb-2">	(1)
    <input type="hidden" value="{{item.id}}" />	(2)
    <div class="border p-2">
        {% if item.completed %}			(3)
        <input type="checkbox" checked />
        {% else %}
        <input type="checkbox" />
        {% endif %}
    </div>
    <div class="flex-grow-1 border p-2">{{item.title}}</div>
    <div class="border p-2">{{item.period}}</div>
</div>
リスト6:templates/item.html

 ポイントは、(1)のようにタスクを識別するためのid属性を付加すること、(2)のようにタスクIDそのものを隠しフィールドで埋め込むことです。これにより、後ほどタスククリック時にタスクが判別できたり、タスクIDを用いた処理が可能になります。値の埋め込みは、中カッコを二重にしたもの({{ }})でフィールド名などの式を囲みます。フィールド名は、リスト7の構造体で定義されている必要があります。

 (2)では、完了フラグ(completed)の状態に応じてチェックボックスのchecked属性を付け分けています。このように、{% if %}〜{% else %}〜{% endif %}で条件分岐ブロックを作ることができます。この他、記法ルールの詳細は「Askama - Askama」を参照してください。

 次に、テンプレート利用のための構造体と関数を、template.rsファイルを作成してリスト7のように記述します。

// テンプレート構造体の定義
#[derive(Template)]			(1)
#[template(path = "item.html")]
struct ItemTemplate<'a> {
    item: &'a store::Task,
}
// テンプレートをレンダリングする関数
pub fn render_item(item: &store::Task) -> String {	(2)
    let template = ItemTemplate { item: &item };
    template.render().unwrap()
}
リスト7:src/template.rs

 (1)はテンプレート構造体に必要な注釈です。template注釈は、テンプレートに対応するファイルをpath引数で指定します。フィールドが参照を含む場合には構造体にライフタイム注釈が必要です。テンプレートに現れる全てのフィールドが含まれる必要があります。

 (2)はレンダリングを実行する関数です。テンプレート構造体をインスタンス化し、renderメソッドを呼び出すと、レンダリングされたHTML文字列が返ります。これを呼び出し側でDOM(Document Object Model)に挿入するといった処理を実行しますが、それについては次回に紹介します。

まとめ

 今回は、Rust and WebAssemblyを使ったSPA(Single Page Application)開発について、そのあらましとプロジェクトの準備までを紹介しました。

 次回からは、このアプリに表示と更新の機能を実装していく過程で、DOMへのアクセス、イベントハンドラの書き方と設定などを紹介していきます。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
X: @WingsPro_info(https://x.com/WingsPro_info
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。