LoginSignup
1
0

More than 1 year has passed since last update.

VueからWebAssembly(seed使用)を利用してみる。

Last updated at Posted at 2022-04-02

この記事について

発端は、Rustを勉強する為に、ルービックキューブアプリをRust+WebAssemblyで作成し始めた事になります。
それから、勉強した事を記事にまとめつつ今に至ります。

今回の記事では、Vueからwasmモジュールを使用してみます。なるべくストレスかけずに開発する構成も考えてみました。

目標

今回のゴールは以下のポイントです。

  • wasmモジュールを組み込んだVueのコンポーネントを作成する
  • VueのUIコンポーネントからそのwasm使用のVueコンポーネントを使用する
  • ホットデプロイを基本とする開発時構成にする

結果

先に結果です。今までseed(WebAssemblyフレームワーク)側でボタンを配置していたのを、Vue側に移動出来ました。まだまだUI的な改善点はありますが、VueのUIを使用して大分まともになりました。右側の2つのルービックキューブ描画部分(canvas要素)がseedで構築され、左側のボタン群がVueで構築されています。
image.png

参考:
前回までの画像

wasmモジュールを扱う構成

静的モジュールを使用したデプロイ時にはあまり問題にならないと思いますが、開発時には以下の(自分の好みとしての)考慮ポイントがあります。

  • Vueを開発モードで起動する時、wasmモジュールを読み込めるようにする必要がある
  • wasmモジュールはVue側のリソースではなく、外部リソースとして扱っておきたい
  • あまりローカル環境を汚したくない

ここでは、docker-composeとnginxを使って構築してみます。

※2022年5月26日追記
こんな大掛かりな事しなくてもwasmモジュールファイルはpublicフォルダに置けばよかったです。ちょっと小ネタ記事書きました。「[小ネタ] Vueの静的ファイル配置](https://qiita.com/etnk/items/46da7c47dc3cb97f282f)」

構成図イメージ

image.png

フォルダ構成概要

  • 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は特筆する事なく、ベースは以下の通りです。

Dockerfile-nginx
FROM nginx
Dockerfile-vue
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_configrotateという二つの関数を用意します。設定変更用と回転処理用です。
また、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とマウントしているフォルダにもコピーしておきます。

wasm/build.sh
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を作成します。先に全ソースを記載し、それぞれポイントを紹介していきます。

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指定に関しては後程説明します。

image.png

wasm関数つなぎ部分

wasm側で定義した2つの関数を後に使用する為に、格納用の変数宣言をしておきます。interfaceSetConfiginterfaceRotateがそれです。そして、onMountedのタイミングでwasmの初期化処理を行います。/wasm/package_bg.wasmを指定してwasmモジュールを読み込みます。ここで最初に説明した構成により、/wasmパスである為、nginxを通してnginxコンテナ内に配置されたwasmを読みに行きます。start関数の返り値を受け取り、最初に宣言した格納用変数に関数としての返り値、set_configrotateを格納します。

    // 中略
    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全ソースを記載します。その後ポイントを紹介します。

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指定を付けておきます。

App.vue
<template>
<!-- 中略 -->
            <WasmScreen
              id="wasmelemid"
              ref="wasm"
            />
<!-- 中略 -->
</template>
<script lang="ts">
    // 中略
    const wasm = ref();
    // 中略
</script>

wasmモジュール関数実行部

ボタン群はまた別コンポーネントになっていますが、そちらからemitで呼ばれている関数onControlActiononRotateActionの中で、先に宣言しておいたwasm変数からwasmコンポーネントの関数を呼び出します。

App.vue
<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や便利機能などの開発が中心になりそうです。
ちなみに次は色々な機能を付けていくため、メニュー化や右ペインの活用をしていく予定です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0