WebAssembly 用のアプリでもテストがしたい
Go 言語(以下 Golang)で組んだ WebAssembly(
wasm)が動きはするものの、go testでテストを実行するとexec format errorやimports syscall/jsエラーが出る。fork/exec /tmp/go-buildxxxxxxxxx/xxxx/my_pkg_hoge.test: exec format errorimports syscall/js: build constraints exclude all Go files in /usr/local/go/src/syscall/js
- 検証環境:
- Go 1.15.6(
golang:1.15-alpine@ Docker) - Go 1.16.5(
tinygo/tinygo:latest@ Docker) - Go 1.16.6(
golang:alpine@ Docker) - Go 1.21.5(@ macOS 12.7.2)
- Go 1.15.6(
TL; DR (今北産業)
-
ローカル/実行環境に Node.js をインストールする。
-
wasmは実行環境(GOARCH)なので、テストであってもアプリの実行/ビルド時に OS とアーキテクチャを指定する必要がある(VSCode ユーザーは下記「エラー時の切り分け」参照)。GOOS=js GOARCH=wasm go test ./...GOOS=js GOARCH=wasm go build ./... -o myWasm -
環境変数の
PATHにgo_js_wasm_execのあるディレクトリを追加する。これがないと
exec format errorが発生する。
一般的に$(go env GOROOT)/misc/wasm- 設定例:
export PATH="$(go env GOROOT)/misc/wasm:${PATH}" -
go_js_wasm_execの場所が分からない場合はfind / -name go_js_wasm_execなどで検索する。
- 設定例:
- 参考文献: https://golang.org/src/syscall/js/js_test.go | js | syscall | src @ golang.org
エラー時の切り分け
【VSCode ユーザー必見】
「could not import "syscall/js"」と出てエラーる。もしくは、エラーがうざい時。
リポジトリの ./.vscode/settings.json に、以下の VSCode の追加設定を追記し、VSCode を再起動します。
{
"go": {
"toolsEnvVars": {
"GOARCH": "wasm",
"GOOS": "js"
},
"installDependenciesWhenBuilding": false
}
}
"installDependenciesWhenBuilding": false がないと、VSCode からのテストは実行できるものの、ソースコード上のエラー表記が消えません。
というのも、VSCode は gopls を使って、適宜ソースコードに問題がないか解析しています。
TS; DR でも説明していますが、GOOS が "js"、GOARCH が "wasm" でないと syscall/js パッケージは読み込めず、ローカルの環境変数に $(go env GOROOT)/misc/wasm を追加しただけでは syscall/js のパッケージを読み込めません。import "syscall/js" の箇所に以下のエラーが発生します。
could not import syscall/js (cannot find package "syscall/js" in any of
/usr/local/Cellar/go/1.**.**/libexec/src/syscall/js (from $GOROOT)
/Users/admin/go/src/syscall/js (from $GOPATH))compilerBrokenImport
error while importing syscall/js: build constraints exclude all Go files in
/usr/local/Cellar/go/1.**.**/libexec/src/syscall/jscompiler
could not import syscall/js (no required module provides package "syscall/js")
(compilerBrokenImport)
error while importing syscall/js: build constraints exclude all Go files in
/usr/local/Cellar/go/1.**.**/libexec/src/syscall/jscompiler
VSCode で「exec: node: not found」と出てテストが実行できない場合
VSCode上からテストを実行すると exec: node: not found が出る場合は、ローカルに Node.js がインストールされていません。別途インストールする必要があります。
Running tool: /usr/local/Cellar/go/1.xx.x/libexec/bin/go test -timeout 30s -run ^TestAdd$ github.com/YOURNAME/my-sample-wasm
/usr/local/Cellar/go/1.xx.x/libexec/misc/wasm/go_js_wasm_exec: line 14: exec: node: not found
FAIL github.com/YOURNAME/my-sample-wasm 0.010s
FAIL
ビルド済みの wasm バイナリを Golang 内から呼び出して使いたい
ビルドされた wasm バイナリを実行するには、通常ブラウザを経由するか Node.js が必要です。
しかし、wasm アプリの作成ではなく、wasm バイナリを Golang 内で読み込んで外部モジュールのように利用したい場合があります。
例えば「Rust などの他言語で作成された wasm バイナリを Go で使いたい」といったニッチなケースです。
その場合、Golang で実装されたランタイムに wasmer-go があり、これを Golang のソースから使えるようにバインドした gowasmer を使うことができます。Javascript で言うところの node.js と wasm_exec.js の Golang 版が wasmer-go と gowasmer です。
- wasmer で Go の WASM を実行できるパッケージを作った @ zenn.dev
- github.com/mattn/gowasmer @ GitHub
テストの実行に wasmer-go を node.js の代わりに使いたいところですが、wasmer-go や gowasmer は「ビルド済み」の wasm ファイルを利用することを前提としています。カバレッジなどのことも考えると、ローカルに node.js 環境を入れた方がメンテナンス性が高まると思います。
TS; DR (Go で wasm のテストを完全に理解した気になっている俺様プラクティス)
- WebAssembly 用のソースとはいえ、テストの書き方は通常でおk。
- 問題は
syscall/jsパッケージ。OS をjs、アーキテクチャをwasmでテストビルドする必要がある。(GOOS=js GOARCH=wasm go testなど) -
js, wasm指定でビルドするだけでなく Node.js も必要。
Golang で WebAssembly 用の開発をする場合でもテストは行いたいものです。Golang 初心者ゆえ、ユニット・テストを書くだけで最低限のことをしている気になれるので、安心できるのです。
しかし、ふいんきで Golang を触っているものだから、コンパイルしたものは動くものの、いつも通りの Golang のテストを書いてもエラーが出るのです。
後述するように、落ち着いて考えればわかることなのですが、Golang 初心者に輪をかけて、せっかちなものだから「(えっ?あっ!WebAssembly だからブラウザでテストしないといけないの?)」と焦ってしまいました。
さらに、ガバガバなくせに Go でのカバレッジ(テストの網羅率)が脳裏をよぎります。
3 歩ですぐ忘れるのですが、おそらく大事なのは wasm を提供する側でのテストと、それを利用する側でのテストは別物であるということです。つまり、Golang 側でのテストと、Javascript 側でのテストです。
この記事では(自分への戒めも含めて)Golang 側でのテストに注力し、気づいた点や学んだ点を反芻するためと、未来の自分のために残したいと思います。
テストが実行される流れを思い出す。ゆっくりと
アホみたいな話しなのですが、Golang はコンパイル型の言語です。
そのため、テストの場合でもソースから temp ディレクトリに作成します。そしてビルドされた temp 内のバイナリをデバッガーと一緒に実行し、各種テストの測定を行います。
また、Golang の大きな特徴として OS と CPU の種類を指定すると、それに対応したバイナリを作成してくれます。つまり、Windows で macOS や Linux 向けのバイナリが、実行できなくてもビルドはできる、ということです。クロス困憊るってヤツです。
このことは「知ってはいる」のですが、wasm という目新しい用語に翻弄されて「wasm 用のソースは、ローカルでテストできない」と早とちりしてしまいました。
気づきのポイントは「go test がビルドしたバイナリを『誰が』『どこで』実行するのか」ということです。
この「誰」とはカーネル(OS)のことで、「どこで」はアーキテクチャ(CPU)のことです。
go env の環境変数が GOOS=darwin GOARCH=amd64 の場合は go test ./... すると、OS が macOS で CPU が AMD/Intel 64bit 向けのバイナリがビルドされます。
この場合、Intel Mac の macOS 上であれば、テストが実行されます。
しかし、macOS 上で GOOS=darwin GOARCH=amd64 go test ./... しても M1/M2 Mac だとエラーがでます。OS(カーネル)が合っていても、CPU(アーキテクチャ)が違うためです。
同様に、macOS 上で GOOS=linux GOARCH=amd64 go test ./... すると exec format error が出ます。当然なのですが、CPU は合っていても OS が違うためです。
「あっ!wasm を作るときは GOOS=js GOARCH=wasm go build とするから、このマシン(Mac)ではテストできないんジャマイカ!🔈」と、よく考えもせず慌ててしまったのです。
しかし、すでにお気づきの方も多いと思います。正しくは「ジャメイカ🔈」です。
Golang で WebAssembly (wasm)を考える場合の 2 つのポイント
-
syscall/jsパッケージの存在 -
wasmバイナリの実行者
Golang で WebAssembly 用の "Hello, world!" が動いたので、喜んでいたのも束の間。既存の自作アプリを WebAssembly 対応させようと、サイトを練り歩いたりサンプル・プログラムを見ていると syscall/js のパッケージを import しないといけないことに気づきます。
- Hello, world! の WASM サンプル @ GitHub
この syscall/js.go パッケージは、ブラウザの Javascript が wasm バイナリにアクセスした際のブリッジ(橋渡し)もしくはゲート(出入口)となるパッケージです。
Javascript の型と Golang 用の型を変換してくれたり、Javascript 側(ブラウザ側)からのリクエスト(処理依頼)を受け取ったり、返したりなど、交換手のような仕組みを提供してくれています。
問題は、この syscall/js.go パッケージは GOOS=js GOARCH=wasm でないと動かないことです。
VSCode の Go 拡張機能を入れていると import "syscall/js" の箇所で以下のようなエラー・メッセージが出ていると思います。
could not import syscall/js (no required module provides package "syscall/js") compiler (BrokenImport)
error while importing syscall/js: build constraints exclude all Go files in /usr/local/go/src/syscall/jscompiler
エラー内容としては、コードの静的解析時に syscall/js モジュールの読み込みに失敗した(存在しなかった)という内容です。
このエラー自体は、ソースの上部に以下のコメントをつけると解除されます。
//go:build js && wasm
// +build js,wasm
では、なぜ読み込みに失敗したかと言うと、syscall/js.go パッケージのソース・コードのヘッダを見ると、上記と同じ //go:build js && wasm が指定されているためです。
- js.go | js | syscall | src @ golang.org
//go:build はたまた // +build の復習
Golang では、ソースコードの package 宣言の前のコメント行に //go:build という記載があった場合は、マッチした GOOS や GOARCH の場合のみビルドの対象にすることができます
//go:build js && wasm
// +build js,wasm
そのため、syscall/js.go パッケージは、GOOS/GOARCH が js/wasm の場合のみ読み込まれるということです(ちなみに // +build の記法は Go 1.17 より前のバージョンの記法で同じ意味を持ちます。下位互換のため記載したりもできます)。
これが、通常通り go test ./... と実行しても imports syscall/js エラー(パッケージがないエラー)が出る原因です。
この //go:build ... や // +build ... を使った仕組みは 1 つのリポジトリで複数 OS に対応したい場合に力を発揮します。特にシステム・コールがらみ(OS 独自の API やコマンドを利用)の場合などです。
例えば、とある関数のコードを「Windows 用」「Linux 用」「macOS(darwin)用」と分けておき、各々のソースコードのヘッダに # +build darwin などと限定させることで、他のパッケージは同じ関数名を使うことができます。
🐒 この OS 互換について、以下のパッケージのリポジトリが、とても参考になります。ターミナル/コマンドプロンプトからの入力を OS ごとにわけることで一元化してくれる、CUI アプリを作る場合に、たいへんありがたいパッケージです。
ここで少し整理します。
-
syscall/jsパッケージを使う場合はGOOS=js GOARCH=wasmでテストやビルドを実行しないとパッケージ足らずでエラーになる。 -
GOOS=js GOARCH=wasmでテストやビルドすると、GOOSとGOARCHが実行環境と合わないためエラーになる。
この 2 つの矛盾を何とかしないといけません。
ビルドした wasm を Web サーバに設置した時のことを思い出す
Hello, world! をブラウザで動かした時のことを思い出しましょう。必要なファイルは以下のような感じだったと思います。
$ ./build.sh
**snip**
$ tree ./html
./html
├── index.html # <- 元 wasm_exec.html
├── test.wasm
└── wasm_exec.js
- Hello, world! の WASM サンプル @ GitHub
ここで重要なのが wasm_exec.js です。Javascript が *.wasm バイナリを読み込んで関数を実行するのに必要なものだからです。
つまり、Golang 側で言うところの syscall/js と似た役割を、Javascript 側では wasm_exec.js が担ってくれているのです。
wasm_exec.js の注意点として、*.wasm を作った(ビルドした)コンパイラによって中身が変わることです。
つまり、コンパイラが変わった場合は、wasm_exec.js もコンパイラに合ったものに変えないといけません。
そして、肝心の wasm_exec.js は go と一緒に同梱されています。公式リポジトリの Wiki ではシンプルに「ローカルからコピーする」と書いてあります。
Copy the JavaScript support file:
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .(WebAssembly | Wiki | go | golang @ GitHub より)
これを読み違えたのか、ネットの記事では「公式リポジトリから最新版をダウンロードしろ」的なことも書いてあったりします。案の定、ネット記事をベースに試して動かないと Issue を立てた人がいて、「ドキュメント嫁」から叱られてました。
I then copied the js / html fiels for running wasm in browser via:
https://github.com/golang/go/tree/master/misc/wasmDo not copy from master branch. Use the files from your distribution as mentioned in the wiki page.
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .and same for the html file.
(issue コメント | Issue #29827 | go | golang @ GitHub より)【以下、筆者訳】
次に、wasm を走らせる js と html ファイルをブラウザ経由で以下からピーコピーコしてきました。
https://github.com/golang/go/tree/master/misc/wasmマスターブランチからコピペピピックしないでください。Wiki にもあるように、自分に配布された(Go にバンドルしてきた)ものを使ってください。html ファイルについても同様です。
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
他人の嫁ながら、自分も叱られた思いがします。独身だけど。
つまり、wasm をコンパイルしたものが提供している wasm_exec.* を利用しないといけないということです。
例えば、Go の標準コンパイラより小さいバイナリが作成できる TinyGo コンパイラを使う場合も同様です。
この場合は、TinyGo と同じ環境にある Go が提供しているものではなく、実際にコンパイルする TinyGo が提供している wasm_exec.js を使います。具体的には、TinyGo の Docker の場合は以下のディレクトリにあります。
-
wasm_exec.js:/usr/local/tinygo/targets/wasm_exec.js -
wasm_exec.html:/usr/local/tinygo/src/examples/wasm/main/index.html
さて、ブラウザ実行の場合は、コンパイラに合った wasm_exec.js を使うことは理解できました。違うものだと Javascript がコンパイラの API を理解できないためです。
では、本題の「Golang 側で wasm バイナリをテストする」にはどうすればいいでしょう。
Node.js は OS みたいなもの
復習ですが、wasm のバイナリは OS が js(Javascript)、アーキテクチャが wasm でビルドされています。(GOOS=js GOARCH=wasm go build ./main.go -o ./doc/mywasm.wasm)
そして、実行する時は Javascript 環境で wasm の構文を解釈できるプロセッサー(処理系統)でないといけません。つまり、Javascript + WebAssembly 対応のブラウザなら実行できるということです。
問題は「テストを実行する場合はどうなるのか」というところです。
まず、アーキテクチャの問題ですが、先の「wasm_exec.js はコンパイラと合ったものを使う」ことで Javascript 側から wasm を処理することができます。となると、残る問題は OS である js(Javascript)をどうするかです。
もぅ、すでにお気づきだと思いますが、Node.js を介して実行すればいいのです。
Node.js(node)は、Javascript のランタイムです。つまり、php ./index.php や python ./index.py のように、node ./index.js と実行すると Javascript を実行できるのです。
Golang のテスト(go test)の場合、まずテスト用にバイナリをビルドしてから、直接バイナリを実行して、その結果を取得することでテストを行います。
つまり、go が $ /path/to/mybuiltapp_test ...(テスト用の引数)... のように実行するのです。
となると、この時に go が $ node /path/to/mybuiltapp_test ...(テスト用の引数)... と実行出来れば、その実行結果をテストに反映できることになります。
何か見えてきたでしょうか。
そう、コンパイルは go にさせて、バイナリの実行は node にまかせればいいのです。
具体的には go run の -exec オプションが使われます。
ここで、-exec オプションの詳細の前に確認したいことがあります。
先の「ドキュメント嫁に叱られる」件で「配布されたものを使え」と cp $(go env GOROOT)/misc/wasm/wasm_exec.js 光線を浴びました。そのディレクトリにあるファイルを見てみましょう。
$ ls -lah "$(go env GOROOT)/misc/wasm"
total 36K
drwxr-xr-x 2 root root 4.0K Dec 4 2020 .
drwxr-xr-x 12 root root 4.0K Dec 4 2020 ..
-rwxr-xr-x 1 root root 441 Dec 4 2020 go_js_wasm_exec
-rw-r--r-- 1 root root 1.3K Dec 4 2020 wasm_exec.html
-rw-r--r-- 1 root root 16.7K Dec 4 2020 wasm_exec.js
「変なところからコピペピピックせずに、ここからコピーしろ」と言われた wasm_exec.js と wasm_exec.html が確認できます。
ここで注目してもらいたいのが go_js_wasm_exec ファイルです。初めて出て来た名前ですが、これが -exec に使われます。
Golang は、go run 実行時に -exec オプションが指定されていない場合、GOOS と GOARCH が現在の環境とマッチしているか否かで挙動が変わります。
マッチしている場合は、テスト用に作成されたバイナリはそのまま実行されます。
$ ./path/to/mybuiltapp_test ...(テスト用の引数)...
マッチしない(異なる)場合は go_<GOOS>_<GOARCH>_exec という書式のファイルを、パス(環境変数の PATH にあるディレクトリ)から探し、それを使ってビルドされたテスト用バイナリを実行します。
この時、go run -exec=go_<GOOS>_<GOARCH>_exec ./... として実行するのと同じ挙動になります。
つまり GOOS=js GOARCH=wasm の場合、ビルド後、go_js_wasm_exec を探して以下のようにテスト用に作成されたバイナリを実行します。
$ go_js_wasm_exec /path/to/mybuiltapp_test ...(テスト用の引数)...
そして、go_js_wasm_exec の中身ですが、同ディレクトリにある wasm_exec.js を使った Javascript で書かれたテストを作成して node に渡し、その実行結果を Go が解釈できる形で返します。パーサーのような役割をします。
ここで大事なのが go_js_wasm_exec を見つけられるようにパスを通す必要があるということと、node がインストールされていないといけないことです。
まとめ
- HTML で使う
wasm_exec.jsは、コンパイラー(Go もしくは TinyGo)が提供しているものを使う。 -
wasm_exec.jsなどがあったディレクトリを、OS の検索パス(環境変数PATHなど)に追加する。 -
nodeコマンドが実行できるように Node.js を入れておく(npmなどのパッケージ・マネージャーは基本的に不要) -
go test ./...する時にGOOS=js GOARCH=wasm go test ./...と OS とアーキテクチャを指定する。