WASM / WASIに興味が最近ある

作ったアプリにwasmが使われていたライブラリがあったので(そのために意図的に私が採用したんですが)、最近になってWebAssemblyに急激に興味が出てきた。

ただ、ちょっとこの技術の認識はフワフワしていて、まだ良くわかってない。 とりあえず感じることとしては、

  • 同一のバイナリはOSおよびCPUアーキテクチャに左右されることはない (これは中々画期的だと思っていて、私が興味を持ってる理由)
  • サンドボックス的実行(Dockerの代替とか言われている)
  • 様々な言語からバイナリを吐ける
  • なんかJVM的な感じ?
  • Webブラウザで共通仕様化し、javascriptに次ぐ実行環境として席巻

みたいなイメージではある。うん、理解してない

とりあえず、.NETではBlazorっていうのがあって、 C#上でReactっぽいことが出来る、厳密にはフロントエンドでコードをC#とjavascriptで相互に行き来できる的技術、という.NET発のWebAssemblyに対するアプローチがあり、1回公式チュートリアルやったことあるんですが、ちょっとよく分からなかった。 私自身がASP.NETに全く慣れてない為で、全てを.razorファイルに組み込めることを考えると、結構良いのかも、なんて浅いことを考えている。  こういう系のメリット把握できないのは実際仕様理解が足りないのが理由で、何かしらあるものだ。もうちょっと理解できたらなあと思う。

と言うことで、Hello Worldくらいはしてみろよ、って思ったのでしてみた。

とりあえず動かしてみる

とりあえずwasmをOSで動かす技術としてwasiがあるのだと言う。今回はそれでHello Worldをしてみる。

WASI実行環境としてはWasmtimeWasmerがあり、 とりあえずMac/Linux環境以外で実行できるかは不明。どちらも$HOMEフォルダに実行フォルダを作成するところはラクだ。

うん、最近Ubuntu Desktop入れてたけどかなり良かったな!こーゆーの入れたくなったらすぐ入れられる。

とりあえずこれでWebAssembly実行してみる。

まずWebAssemblyといったらRust。Rustは以前ちょい文法を触って学んで久々に。正直、大分分かっていない。 WebAssemblyのおかげで興味が急上昇中である。

なんとなくだが、単純な配列処理関数をやって表示するだけのものとした。

fn for_each<T>(arr: Vec<T>, f: fn(T)) {
    for i in arr {
        f(i);
    }
}

fn filter<T: Copy>(arr: Vec<T>, f: fn(T) -> bool) -> Vec<T> {
    let mut result = Vec::new();
    for i in arr {
        if f(i) {
            result.push(i);
        }
    }
    result
}

fn main() {
    println!("filter demo: ");
    let arr: Vec<i32> = vec![1, 2, 3, 4, 5];
    let result = filter(arr, |i| i % 2 == 0);
    for_each(result, |i| println!("{}", i));
}

wasm32-wasiターゲットを追加してビルドする。

rustup target add wasm32-wasi
rustc ./src/main.rs --target wasm32-wasi

で、実行。

wasmtime ./main.wasm

出力結果

filter demo: 
2
4

Goの場合だが、GOOS=wasip1 GOARCH=wasmを選択するとビルドできる模様。 Rustと同様のコードを書いてみる。

package main

import "fmt"

func forEach[T any](arr []T, f func(T)) {
	for _, v := range arr {
		f(v)
	}
}

func filter[T any](arr []T, f func(T) bool) []T {
	var res []T
	for _, v := range arr {
		if f(v) {
			res = append(res, v)
		}
	}
	return res
}

func main() {
	fmt.Println("filter demo: ")
	arr := []int{1, 2, 3, 4, 5}
	result := filter(arr, func(n int) bool {
		return n%2 == 0
	})
	forEach(result, func(n int) {
		fmt.Println(n)
	})
}

ビルド方法は以下の通り。

GOOS=wasip1 GOARCH=wasm go build -o main.wasm main.go

今度はwasmerでの例を示そう。

wasmer ./main.wasm

出力結果は次の通り。

filter demo: 
2
4

ちなみに出力されるバイナリはls -l -hで見ると、Rust vs Goで1.7MB vs 2.1MBであった。もちろん言語ごとに仕様が違うのもある。一見等価っぽい処理があったとしても、どの言語を入力にしてコンパイルするかで色々サイズや速度が違ったりする、と言うが、実際そうではあるみたいだ。

とりあえず、複数の言語から入力して、同じ感じの実行ファイルが作れるよーってことだけはまず理解した。

Node.jsでGoプログラムを実行

次は簡易にNode.jsでGoプログラムを実行してみる。次のような簡単なものを用意する。

package main

import "fmt"

func add(a, b int) int {
	return a + b
}

func main() {
	var a int = 1
	var b int = 2
	fmt.Printf("%d + %d = %d\n", a, b, add(a, b))
}

これを、次のような形でビルドする

GOOS=js GOARCH=wasm go build -o main.wasm main.go

浅い調査だが、これで生成したwasmを実行するには、専用のスクリプトを経由しないといけないようだ。 これは$GOROOT/misc/wasmに存在するスクリプトで、単純なブラウザ用スクリプト+Node.js実行用スクリプトの2段構えになっている。ちなみに$GOROOTの場所は、snapパッケージでインストールしたGoなら/snap/go/xxxxxみたいな、ホームディレクトリの場所とは違うから注意が必要である。

これらのファイルをローカルにまずコピーする。wasm_exec_node.jsは内部でrequire("./wasm_exec");してるため、両者が必須で、ディレクトリ位置も重要である。

cp $(go env GOROOT)/misc/wasm/wasm_exec_node.js ./
cp $(go env GOROOT)/misc/wasm/wasm_exec.js ./

そして、wasmファイルを引数に渡す。

node ./wasm_exec_node.js ./main.wasm

実行される。

1 + 2 = 3

とりあえず、メインプログラムを実行する、ということができたので良しとしよう。

Go関数を呼び出す

ではNode.js上でGo関数を使うことを考えてみる。とりあえず、TinyGoというコンパイラの方が本家よりWasm領域は特化してるようだ。

導入はとりあえずDockerで。compose.ymlを作っておく。

services:
  go:
    image: tinygo/tinygo:0.31.2
    tty: true
    command: /bin/bash
    volumes:
      - .:/src

パッケージはmainにしておき、メイン関数は空にしておく。大事なのが//exportコメントで、これを書くとjavascript側のWebAssembly.Instanceオブジェクトから関数がアクセスできるようになるっぽい。

package main

//export add
func Add(a, b int) int {
	return a + b
}

func main() {
}

先に似ているが、次のグルーファイルをルートにコピーしなければならない。

cp "$(tinygo env TINYGOROOT)/targets/wasm_exec.js" ./wasm_exec.js

そんなこんなでビルド。

tinygo build -o main.wasm -target wasm ./main.go

javascriptでの書き方だが、こんな感じでいけたっぽい。Node.jsではTinyGoでビルドしたwasmは以下のように呼び出してモジュール利用できる。

const fs = require("fs");

require("./wasm_exec");

const go = new Go();

async function main() {
  const buf = fs.readFileSync("./main.wasm");
  const { instance } = await WebAssembly.instantiate(buf, go.importObject);
  // Goはこれ必須みたい
  go.run(instance);

  const a = instance.exports?.add(1, 2);
  console.log(a); // -> 3
}

main();

とりあえず、少しずつやれることが増えてきた気がするが、Goめんどいなァ、、   データのやりとりの型とか、どうすれば正しく期待しているものが入出力できるかなど、もっと理解を増やしたいところだ。