Go言語でWebAssembly
2018/06/18
  • このエントリーをはてなブックマークに追加

はじめに


こんにちは。カブクのThree.js担当的存在だったあんどうですが、最近は主にGo言語を雰囲気で使用しています。Go言語いいですね。JavaScriptが刺激あふれる大都会だとすると、Go言語は緑薫る大草原。人生には青い空と澄んだ空気、そして最低限の文法要素と標準添付ライブラリしかいらんかったんや!ここには「何もない」がある!

背景


さて、現在Go言語で作成しているのはあれをそれして3Dモデルに変換してブラウザで表示するツールですが、残念ながら自動生成した3Dモデルがいきなり完璧であることはまずありません。そうした場合、ブラウザ側でプロパティを直接変更したり、アノテーションを追加したりして、再度3Dモデルを生成し直すことになります。

もちろん編集内容をサーバーに送り返して再度3D化することは可能ですが、この修正と再生成は何度も繰り返されることになるため、変更が小さいなら、なるべくブラウザ側で3D化したいところです。かといってGo言語の実装をJavaScriptに移植するのは明らかに面倒くさい。そもそもGo言語の実装もまだまったく安定していませんし。などと困っていたらPublickeyさんでこんな記事が公開されていました。

Go言語がWebAssemblyをサポートへ。GOARCHは「wasm」、GOOSは「js」に

ということでこのGo言語製の3D変換ツールをWebAssemblyにしてブラウザ上で動かしてみるのが今回の目標です。

Go言語のWebAssemblyサポートについて


https://github.com/golang/go/issues/18892

Go言語のWebAssemblyサポートはGopherJSの作者であるneelanceさんによって進められていて、今年8月にリリースされるはずのGo 1.11に乗る予定です。現時点ではWASMサポートに関係するドキュメントは一切ありませんが、neelanceさんによると以下のような機能がすでに組み込まれています(#issuecomment-347057409#issuecomment-359208503)。

  • WASMを生成してブラウザとNode.jsで動作
  • 基本的な操作
  • インターフェース
  • Goルーチンとチャネル
  • defer/panic/rescue
  • ファイルI/O(Node.js)
  • リフレクション
  • 各種パッケージ(bytes、container/heap、container/list、container/ring、encoding/ascii85、encoding/asn1、encoding/base32、encoding/binary、encoding/csv、encoding/hex、errors、flag、hash/adler32、hash/crc32、hash/crc64、hash/fnv、html、image、image/color、index/suffixarray、math、math/bits、path、sort、strconv、strings、text/scanner、text/tabwriter、unicode、unicode/utf8、unicode/utf16、etc...)

動作させること優先でパフォーマンスなどの非機能要件は後回しにされているようですが、とはいえこれは思いの外、ちゃんと使えそうです。

Go言語のWebAssembly出力を試す


https://blog.lazyhacker.com/2018/05/webassembly-wasm-with-go.html

先ほども書きましたが今の所WebAssembly出力に関するドキュメントは一切ありません。が、HelloWorldを試してブログにまとめてくれている方がいらっしゃいます。ありがたく参考にさせていただきましょう。まずはここに書かれてあることをそのままやってみます。

開発はneelanceさんのリポジトリで進められているので、そちらからコードを取得してビルドします。

$ git clone https://github.com/neelance/go.git
$ cd go
$ git checkout wasm-wip
$ cd src
$ ./all.bash

test.goを用意して

// test.go
package main
import "fmt"

func main() {
  fmt.Println("Hello, WASM!")
}

neelanceさんのリポジトリから取ってきたgoコマンドを使用してビルドします。見ればわかると思いますが、GOOS=js GOARCH=wasmが肝です。

$ GOOS=js GOARCH=wasm go build -o test.wasm test.go

test.wasmが生成されたはずです。同じディレクトリにmisc/wasmディレクトリ以下にあるwasm_exec.htmlとwasm_exec.jsをコピーしてウェブサーバーを起動し、wasm_exec.htmlを表示してみましょう。



失敗します(すいません)。参考にしているブログにも書いていますし、エラーメッセージを見てもわかりますが、wasmファイルのMIMEタイプはapplication/wasmでなければいけません。/etc/mime.typesを開いて以下を追加します。

application/wasm wasm

もう一度試してみます。



無事に動きました。

引数と返り値を処理する


思いの外トラブルもなくあっさり動いてしまいましたが、本当にやりたいのはこの先です。ここまでで実現できているのは以下です。

  • main()関数の呼び出し
  • fmt.Printlnによるコンソール出力

3D変換ツールの機能を呼び出すにはさらに次の2つを実現する方法を調べなければいけません。

  • 引数の受け渡し方
  • 返り値の受け渡し方

先ほどの例ではmain()を呼び出していましたが、任意の関数を公開してJSから呼び出せるならまとめて解決できそうです。できるんでしょうか?ドキュメントがないのでとりあえずコードリーディングとDevToolsに頼ります。まずwasm_exec.htmlを見てみましょう。

// wasm_exec.html
...
const go = new Go();
let mod, inst;
WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
  mod = result.module;
  inst = result.instance;
  document.getElementById("runButton").disabled = false;
});

async function run() {
  console.clear();
  await go.run(inst);  /* wasmのコードを呼び出してるっぽい部分 */
  inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance
}
...

実際の呼び出しはgo.run(inst)で行われているようです。wasm_exec.jsを確認します。

...
global.Go = class {
  ...
  async run(instance) {
    this._inst = instance;
    ...その他のプロパティ等の設定...

    while (true) {
      ...
      this._inst.exports.run(argc, argv);
      ...
    }
  }
}
...

回り回って結局inst.exports.runが呼ばれているようなので、とりあえずコンソールでinst.exportsを確認します。



関数はrun以外に公開されていなさそうです。runmainを呼んでいるので、main関数経由でなんとかするしかないのかも。mainになにかパラメータを渡す方法はないんでしょうか?wasm_exec.jsのGoクラスの定義を確認します。

// wasm_exec.js
...
global.Go = class {
  constructor() {
    this.argv = [];
    this.env = {};
    ...

コマンドライン引数や環境変数でパラメータを渡せそうな空気が漂ってます。試してみましょう。wasm_exec.htmlにコマンドライン引数を設定し、test.goでそのコマンドライン引数を使用するように修正します。

// wasm_exec.html
...
const go = new Go();
go.argv.push("Hello, arg!");
...

// test.go
package main

import (
  "fmt"
  "os"
)

func main() {
  fmt.Println(os.Args[0])
}

確認します。



うまく引数を渡せたようです。ちなみにos.Argsの代わりにflag.Argを使用するとなぜか動きませんでした。コマンドライン引数ではなく環境変数を使用する場合はos.Getenvgo.envを使ってください。

とりあえずパラメータを渡せるようになったので、残る問題は処理結果をどう受け取るかです。

先ほどinst.exportsを確認したときにmemというMemory型のプロパティがあったことを覚えているでしょうか。このオブジェクトはJSとWASMで共有されるメモリです。つまりこの中身をJS側で自由に利用できるなら返り値の問題は解決します。なにはともあれまずこのmemの中身を見てみましょう。



何もわかりません。雰囲気で太刀打ちできるレベルではありません。そもそもなにをどう保持しているかわからないと数字の羅列を見たところでなんともならないので、とりあえず生成されたwasmのコードが何をやっているか見てみましょう。ただ、wasmはバイナリファイルなのでそのまま目視で理解できるものではありません。まずはwasm2watでテキスト形式に変換します。

$ wabt/bin/wasm2wat -o test.wat test.wasm

これは詳細を省くが結論だけ言うと約56万行あってお前は死にます。確認してみるとwasmの段階でむっちゃでかくなってますね。

$ ls -lah test.*
-rw-r--r--  1 andoyasushi  staff    80B  6 15 16:14 test.go
-rwxr-xr-x  1 andoyasushi  staff   2.3M  6 15 13:19 test.wasm
-rw-r--r--  1 andoyasushi  staff    64M  6 15 16:04 test.wat

追々tree shakingが実装されるんだろうと思われますが、現状ではwasmの方面から共有メモリの使われ方を把握するのはたいへんそうです。かといってGoのバックエンドのコードを読むのも劣らずたいへんです。方針を変えましょう。

現在のHello Worldはコンソールに結果が出力されています。WebAssemblyから直接コンソールに出力することはできないので、WASMのコードをインスタンス化するときにJS側からconsole.logを渡しているはずです。このconsole.logを呼び出している部分で結果を横取りするのはどうでしょう。とりあえず該当部分を見てみます。

// wasm_exec.html
...
WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
...

instantiateStreamingの第二引数がwasmにインポートされるJavaScriptの関数です。このgo.importObjectがどうなっているか見てみましょう。

// wasm_exec.js
...
let outputBuf = "";
global.fs = {
  constants: {},
  writeSync(fd, buf) {
    outputBuf += decoder.decode(buf);
    const nl = outputBuf.lastIndexOf("\n");
    if (nl != -1) {
      console.log(outputBuf.substr(0, nl));
      outputBuf = outputBuf.substr(nl + 1);
    }
    return buf.length;
  },
};

global.Go = class {
  constructor() {
    ...
    this.importObject = {  // ココ
      go: {
        ...
        // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
        "runtime.wasmWrite": (sp) => {
          const fd = getInt64(sp + 8);
          const p = getInt64(sp + 16);
          const n = mem().getInt32(sp + 24, true);
          fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
        },
        ...

go.importObjectsgo["runtime.wasmWrite"]で呼ばれているfs.writeSyncconsole.logが呼ばれているようです。あまりきれいな方法ではありませんがとりあえずこの関数を次のように変更します。

writeSync(fd, buf) {
  outputBuf += decoder.decode(buf);
  const nl = outputBuf.lastIndexOf("\n");
  if (nl != -1) {
    //console.log(outputBuf.substr(0, nl));
    window.wasmResult = outputBuf.substr(0, nl);   // 結果をwindow.wasmResultに保存
    console.log(window.wasmResult);
    outputBuf = outputBuf.substr(nl + 1);
  }
  return buf.length;
},

どうでしょう。



期待通りwindow.wasmResultで結果が取得できるようになりました。

3D変換ツールをブラウザで動かす


それでは当初の予定通り3D変換ツールをブラウザで動かしてみます。変換ツールはJSON形式で与えられる2D情報をもとに、3D形状を表すJSONを返すものです。WebAssemblyを出力するために変換ツールのコードに対して行った変更は以下の通り。

  • 2D情報をファイルから読み取るのではなく、コマンドライン引数として受け取るように変更
  • 変換結果をファイルに書き出すのではなく、fmt.Printlnで標準出力に書き出すように変更

これだけです。では試してみます。



画面左の文字列が2D情報(内緒)で、画面右の文字列がその情報を解釈して3D情報に変換したものです。画面中央の3D表示はGo言語(WebAssembly)ではなくThree.jsで行っています。

ということで、無事にGo言語製の変換ツールをWebAssemblyビルドしてブラウザ上で動かすことができました。

まとめ


思った以上にあっさりとGo言語製のツールをブラウザ上で動かすことができました。

  • ドキュメントがない
  • JSから実行できるのはmain()関数のみ?
  • パラメータはコマンドライン引数か環境変数わたす?
  • 実行結果はfmt.Println()した結果を横取りするしかない?
  • 生成されるwasmのファイルサイズめっさでかい

上記のようなつらみはありますし、それ以外にもいろいろと小さな問題はありましたが、8月のリリースまでには潰してくれると信じています。その頃にはもちろんドキュメントも整備されていることでしょう。正直「もし使えたらラッキー」くらいのつもりで試し始めましたが、今は確実に業務で使えると思っています。

株式会社カブクではGo言語のWebAssembly出力に興味のある開発者を募集しています。