この記事について
発端は、Rustを勉強する為に、ルービックキューブアプリをRust+WebAssemblyで作成し始めた事になります。
それから、勉強した事を記事にまとめつつ今に至ります。
- Rust + seed でWebAssemblyを体験してみる。
- Rustの参照渡しを使いこなすためにその1
- Rustの参照渡しを使いこなすためにその2-Vecとfor-
- javaScriptとWebAssembly(seed使用)を相互連携してみる。
今回の記事では、Vueからwasmモジュールを使用してみます。なるべくストレスかけずに開発する構成も考えてみました。
目標
今回のゴールは以下のポイントです。
- wasmモジュールを組み込んだVueのコンポーネントを作成する
- VueのUIコンポーネントからそのwasm使用のVueコンポーネントを使用する
- ホットデプロイを基本とする開発時構成にする
結果
先に結果です。今までseed(WebAssemblyフレームワーク)側でボタンを配置していたのを、Vue側に移動出来ました。まだまだUI的な改善点はありますが、VueのUIを使用して大分まともになりました。右側の2つのルービックキューブ描画部分(canvas要素)がseedで構築され、左側のボタン群がVueで構築されています。
参考:
前回までの画像
wasmモジュールを扱う構成
静的モジュールを使用したデプロイ時にはあまり問題にならないと思いますが、開発時には以下の(自分の好みとしての)考慮ポイントがあります。
- Vueを開発モードで起動する時、wasmモジュールを読み込めるようにする必要がある
- wasmモジュールはVue側のリソースではなく、外部リソースとして扱っておきたい
- あまりローカル環境を汚したくない
ここでは、docker-composeとnginxを使って構築してみます。
※2022年5月26日追記
こんな大掛かりな事しなくてもwasmモジュールファイルはpublicフォルダに置けばよかったです。ちょっと小ネタ記事書きました。「[小ネタ] Vueの静的ファイル配置](https://qiita.com/etnk/items/46da7c47dc3cb97f282f)」
構成図イメージ
フォルダ構成概要
- nginx(directory)
- nginx.conf
- Dockerfile-nginx
- vue(directory)
- cubetrain(vueのプロジェクトdirectory)
- Dockerfile-vue
- wasm(seedプロジェクトdirectory)
- (各種ファイル)
- docker-compose.yml
主要ファイル詳細
docker-compose.yml
version: '3.7'
services:
nginx:
build:
context: ./nginx
dockerfile: Dockerfile-nginx
ports:
- 8880:80
volumes:
- ./log/nginx/:/var/log/nginx/
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/wasm:/wasm
- ./nginx/vuedist:/vuedist
depends_on:
- vuefront
container_name: cubetrain-nginx
vuefront:
build:
context: ./vue
dockerfile: Dockerfile-vue
ports:
- 8881:8080
volumes:
- ./vue:/vue
container_name: cubetrain-vuefront
tty: true
nginx.conf
# vue側のサーバーを定義しておきます
upstream vuefront {
server vuefront:8080;
}
include /etc/nginx/sites-enabled/*;
server {
listen 80 default_server;
server_name localhost;
charset utf-8;
# wasmパスへアクセスがあった時、nginxコンテナの/wasmフォルダへつなぎます
location /wasm {
root /;
}
# vueのビルド成果物を使用する時は、nginxコンテナの/vuedistフォルダへつなぎます
# location / {
# root /vuedist;
# }
# wasm以外のパスの時はそのままvueの開発モードサービスへつなぎます
location / {
proxy_pass http://vuefront/;
}
}
nginxとvueのDockerfileは特筆する事なく、ベースは以下の通りです。
FROM nginx
FROM node:17.8
WORKDIR /vue
wasmモジュール作成
今回のポイントであるインターフェース部を構築します。前回の記事「javaScriptとWebAssembly(seed使用)を相互連携してみる。」を基本にします。既にseedの開発が終わり、インターフェース部分のみを変える事を想定します。
Cargo.toml
lib.rsで使用するモジュールを追記しておきます。
[dependencies]
enclose = "1.1.8" # 追記
lib.rs
start関数はjavaScript側で明示的に実行するので、wasm_bindgen(start)になっているのをwasm_bindgenと指定します。
start関数で、javaScript側で呼びだす関数のインターフェースを返しておきます。複数のパラメーターを受け取る関数を作りたい所でしたが、自分の今の知識では、seedのサンプルにあったラッピング関数を改造する事が出来ませんでした。スペース区切りの単一文字列として受け取ったものを分割して使用する事にします。
set_config
、rotate
という二つの関数を用意します。設定変更用と回転処理用です。
また、start関数に引数を一つ追加します。wasmを結び付けるDOM要素のidを受け取り(引数名:targetid)、それを使用してApp::startを呼び出します。今まではapp
と固定値だった部分です。
#[wasm_bindgen]
pub fn start(targetid: &str) -> Box<[JsValue]> {
// Mount the `app` to the element with the `id` "app".
let app = App::start(targetid, init, update, view);
create_closures_for_js(&app)
}
# 引数のappを使用し、Messageイベントを実行する関数をラッピングして返却します
fn create_closures_for_js(app: &App<Msg, Model, Node<Msg>>) -> Box<[JsValue]> {
let set_config = wrap_in_permanent_closure(enc!((app) move |unitedstr: String| {
let mut params = unitedstr.split_whitespace();
let typestr = params.next().unwrap().to_string();
let valuestr = params.next().unwrap().to_string();
# ・・seed側処理実行・・
}));
let rotate = wrap_in_permanent_closure(enc!((app) move |unitedstr: String| {
let mut params = unitedstr.split_whitespace();
let axisstr = params.next().unwrap().to_string();
let layerstr = params.next().unwrap().to_string();
let dirstr = params.next().unwrap().to_string();
# ・・seed側処理実行・・
}));
vec![set_config, rotate].into_boxed_slice()
}
# この部分れはseedのサンプルをそのまま使用させてもらいます。
fn wrap_in_permanent_closure<T>(f: impl FnMut(T) + 'static) -> JsValue
where
T: wasm_bindgen::convert::FromWasmAbi + 'static,
{
// `Closure::new` isn't in `stable` Rust (yet) - it's a custom implementation from Seed.
// If you need more flexibility, use `Closure::wrap`.
let closure = Closure::new(f);
let closure_as_js_value = closure.as_ref().clone();
// `forget` leaks `Closure` - we should use it only when
// we want to call given `Closure` more than once.
closure.forget();
closure_as_js_value
}
Vue側準備
以下コマンドdoker-compose起動した後、Vueのコンテナに入り、Vueプロジェクト作成などを行います。また、サービス起動時もコンテナに入って起動する形を取ります。
sudo docker-compose build
sudo docker-compose up
sudo docker exec -it cubetrain-vuefront bash
vueプロジェクト生成に関しては各種手法があったり今回の重要ポイントではないので詳細説明は省きます。ただ、以後のソースを説明する上で重要なので、今回使用した設定、モジュールを記載します。
- Vue3を使用
- typescript使用
- パッケージマネージャーにはyarnを使用
- UIライブラリにはVuetifyを使用(※2022年4月現在、VuetifyのVue3版はβバージョンです)
package.jsの配置
package.json
ではなく、wasmのビルド生成物の方です。wasmモジュール本体とjavaScriptのインターフェースを担っています。
このファイルが無いと、Vueのビルド時にエラーが出てしまうので、このファイルはVueプロジェクト側に配置する必要があります。今回はvue/cubetrain/src/wasm/
フォルダ配下へ配置する事にします。
さらに、そのまま配置するとVue側lintでエラーが出てしまいます。ファイルの冒頭に以下の1行を追加する必要があります。
/* eslint-disable */
wasm側は、サービスとしては使用せず、モジュールとして使用する形になるので、wasmビルド時に一連の処理をするようにシェルファイルを作っておきます。まず上記1行を出力しておいて、元package.js
の中身を追記する形で出力するという手法です。そして、そのシェルを実行してpackage.jsをVue側に配置しておきます。ついでにnginxとマウントしているフォルダにもコピーしておきます。
VUE_OUTPUT_PATH='../vue/cubetrain/src/wasm'
NGINX_OUTPUT_PATH='../nginx/wasm'
cargo make build_release && \
echo '/* eslint-disable */' > ${VUE_OUTPUT_PATH}/package.js && \
cat pkg/package.js >> ${VUE_OUTPUT_PATH}/package.js && \
cp pkg/package_bg.wasm ${VUE_OUTPUT_PATH}/package_bg.wasm && \
cp pkg/package.js ${NGINX_OUTPUT_PATH}/package.js && \
cp pkg/package_bg.wasm ${NGINX_OUTPUT_PATH}/package_bg.wasm
同時に、vue/curetrain/.gitignore
ファイルにsrc/wasm
フォルダ記載を追記しておきます。
Vueコンポーネント作成
ここではWasmScreen.vueを作成します。先に全ソースを記載し、それぞれポイントを紹介していきます。
<template>
<div :id="id"></div>
</template>
<script lang="ts">
import { defineComponent, toRefs, onMounted, ref } from 'vue';
import init, { start } from '@/wasm/package.js';
export default defineComponent({
name: "WasmScreen",
setup(props){
const { id } = toRefs(props)
const interfaceSetConfig = ref<any>(() => {});
const interfaceRotate = ref<any>(() => {});
const setConfig = (type: string, val: number) => {
const unitedstr = `${type} ${val}`;
interfaceSetConfig.value(unitedstr);
};
const rotate = (axis: string, layer: string, dir: string) => {
const unitedstr = `${axis} ${layer} ${dir}`;
interfaceRotate.value(unitedstr);
};
const onMountedOperation = () => {
init('/wasm/package_bg.wasm').then(() => {
const [set_config, rotate] = start(id.value);
interfaceSetConfig.value = set_config;
interfaceRotate.value = rotate;
});
}
onMounted(onMountedOperation);
return {
setConfig,
rotate
}
},
props: {
id: {type: String, required: true}
},
})
</script>
import節
先のコマンドので配置したwasmモジュールとのインターフェースpackage.js
を読み込んでいます。こちらはnginx側のファイルでは無いのでwasmというフォルダでなくても良いはずですが、解りやすさの為wasmフォルダを作ってそこに配置する形を取っています。
import init, { start } from '@/wasm/package.js';
template部とprop部
コンポーネントとして使用する為、wasm(seed)を組み込むDOM要素をid付きで定義します。そしてそのidは固定でなく使用する側から指定できる様にしておきます。
<template>
<div :id="id"></div>
</template>
<script lang="ts">
// 中略
export default defineComponent({
// 中略
props: {
id: {type: String, required: true}
},
}
</script>
ちなみに、ブラウザの開発者モードでwasm部分のDOM要素を見ると以下の様になっています。id指定に関しては後程説明します。
wasm関数つなぎ部分
wasm側で定義した2つの関数を後に使用する為に、格納用の変数宣言をしておきます。interfaceSetConfig
、interfaceRotate
がそれです。そして、onMounted
のタイミングでwasmの初期化処理を行います。/wasm/package_bg.wasm
を指定してwasmモジュールを読み込みます。ここで最初に説明した構成により、/wasmパスである為、nginxを通してnginxコンテナ内に配置されたwasmを読みに行きます。start関数の返り値を受け取り、最初に宣言した格納用変数に関数としての返り値、set_config
、rotate
を格納します。
// 中略
const interfaceSetConfig = ref<any>(() => {});
const interfaceRotate = ref<any>(() => {});
// 中略
const onMountedOperation = () => {
init('/wasm/package_bg.wasm').then(() => {
const [set_config, rotate] = start(id.value);
interfaceSetConfig.value = set_config;
interfaceRotate.value = rotate;
});
}
onMounted(onMountedOperation);
// 中略
関数実行部
設定変更、回転処理それぞれの関数を定義します。受け取った複数の引数をスペース区切りの一つの文字列にして、wasm側関数を呼びます。
// 中略
const setConfig = (type: string, val: number) => {
const unitedstr = `${type} ${val}`;
interfaceSetConfig.value(unitedstr);
};
const rotate = (axis: string, layer: string, dir: string) => {
const unitedstr = `${axis} ${layer} ${dir}`;
interfaceRotate.value(unitedstr);
};
// 中略
Vueコンポーネント使用部
同様にまずは今のApp.vue全ソースを記載します。その後ポイントを紹介します。
<template>
<v-app>
<v-main>
<v-container class="grey lighten-5">
<v-row>
<v-col md="4">
<ControlPanel
:defspeed=40
:defscramblestep=24
@controlAction="onControlAction"
@rotateAction="onRotateAction"
/>
</v-col>
<v-col md="8">
<WasmScreen
id="wasmelemid"
ref="wasm"
/>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import ControlPanel from './components/ControlPanel.vue'
import WasmScreen from './components/WasmScreen.vue'
export default defineComponent({
name: 'App',
setup(){
const wasm = ref();
const onControlAction = (type: string, val: number) => {
if (wasm.value != null) {
wasm.value.setConfig(type, val);
}
};
const onRotateAction = (axis: string, layer: string, dir: string) => {
if (wasm.value != null) {
wasm.value.rotate(axis, layer, dir);
}
};
return {
wasm,
onControlAction,
onRotateAction
};
},
components: {
ControlPanel,
WasmScreen
},
})
</script>
wasmモジュール宣言部
作成したWasmScreenコンポーネントを配置します。プロパティとしてidを固定値で指定します。モジュールの関数を実行する為、ref指定を付けておきます。
<template>
<!-- 中略 -->
<WasmScreen
id="wasmelemid"
ref="wasm"
/>
<!-- 中略 -->
</template>
<script lang="ts">
// 中略
const wasm = ref();
// 中略
</script>
wasmモジュール関数実行部
ボタン群はまた別コンポーネントになっていますが、そちらからemitで呼ばれている関数onControlAction
、onRotateAction
の中で、先に宣言しておいたwasm
変数からwasmコンポーネントの関数を呼び出します。
<script lang="ts">
// 中略
const onControlAction = (type: string, val: number) => {
if (wasm.value != null) {
wasm.value.setConfig(type, val);
}
};
const onRotateAction = (axis: string, layer: string, dir: string) => {
if (wasm.value != null) {
wasm.value.rotate(axis, layer, dir);
}
};
// 中略
</script>
アクセス
docker-composeでは、vueのポートをホスト側8881につなげていますが、こちらにアクセスするとnginxを通りません。
http://localhost:8880
にアクセスしてnginxを通します。
まとめ
今回の構成では、wasm側は変更すると都度ビルドする必要があります。ブラウザ側再読み込みでwasmモジュールを再読み込みする必要もあります。今回は無理に自動化せずに、ビルド用シェルを作成し、都度シェル実行する形にしています。ファイル変更を検知して実行するまでもないかなという感じです。
最初はseedなどのwasmフレームワークでテスト用ボタンやその他を配置して主ロジックをチェックして後はデザインという段階まできたらインターフェース部を作成して連携するという流れが良いのではと思います。
次に向けて
Rustの勉強をする為に始めたこのアプリ開発ですが、今後はVue側でUIや便利機能などの開発が中心になりそうです。
ちなみに次は色々な機能を付けていくため、メニュー化や右ペインの活用をしていく予定です。