GoのWASMがライブラリではなくアプリケーションであること
はじめに
がんばって書いた書籍が低評価で少々しょんぼりしているあんどうです。まぁ、つい力が入りすぎて袋小路に思い切り突っ込んだ結果抜けられなくなることってあるよね。あるある。そんなわけで今日はできるだけ力を入れずテンション低めにサクッと行きます。
で、GoのWASM。大道の真ん中をまっすぐに歩まれているみなさんはWASMするときはRustかいっそC/C++をemscriptenでってことになると思いますが、私はしょせん路傍の石の下で低評価が目に入らないように丸まっているダンゴムシ。せっかくだからオレはこのGoでWASMを選ぶぜって感じなんですが、ぶっちゃけあれ、めんどくさいすよね。
あ、ちなみに今回の話は「このめんどくささをまるっと解決!」みたいな気持ちのいい話ではなくて、ただただ「めんどくさいよね」っていうだけの話です。あーめんどくさい。
Rustの場合
まず比較のためにRustの例をあげます。Rustで2数を足し算するWASM関数を定義するとこうなるみたいです。
#[no_mangle]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
コンパイル結果がadd.wasmだとすると、JavaScriptから呼び出すときはたぶんこんな感じ。
WebAssembly.instantiateStreaming(fetch("add.wasm")).then(result => {
const add = result.instance.exports.add
const val = add(1, 2)
console.log(val)
})
顧客が本当に欲しかったもの。めんどくさくない。
Goの場合
めんどくさ1
Goだとこうなります。
func add(this js.Value, args []js.Value) interface{} {
return js.ValueOf(args[0].Int()+args[1].Int())
}
引数や返り値を特殊なオブジェクトでラップしてやる必要があります。ちょっとめんどくさい。とはいえ、まぁ異なる言語間で情報をやり取りするんだから仕方ないとしましょう。
ではこれを呼び出します・・・と言いたいのですが、なんとGoのWASMでは独自に定義した関数を直接呼び出すことはできません。Rustだとexports
プロパティが使えたのに・・・。
めんどくさ2
どうするかというとmain()
関数内でなんとかします。こんな感じ。
import "syscall/js"
func main() {
js.Global().Set("add", js.FuncOf(add))
}
main()
関数はシグネチャが決まっているので自由に引数や返り値を設定できません。仕方がないのでJavaScriptのグローバルオブジェクト(globalThis
)にプロパティを生やしてそこに関数を設定します。めんどくさくなってきました。
さっそく呼び出してみましょう。コンパイル結果はmain.wasmとします。
const go = new Go()
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(result => {
go.run(result.instance)
console.log(add(1, 2))
}
new Go()
とかgo.importObject
とかgo.run
とかいろいろとありますが説明しません。雰囲気でご理解ください。説明しすぎても読みにくいだけですからね。これはあくまで一般論で、特に自身の経験に根ざしたとかそういう話ではありませんが。
結果どうなるかと言うと
こうなりました。「Goプログラムはすでに終了しています」だそうです。は? いや、まぁ、main()
関数の実行が終わったらアプリケーションが終わるのはわかりますけども。
めんどく3
しょうがないのでみんな大好きなチャンネルを使ってmain()
関数を終わらなくします。
func main() {
ch := make(chan struct{})
js.Global().Set("add", js.FuncOf(add))
<-ch
}
これでmain()
関数は終了しません。実行してみましょう。
計算できました。勝ったッ!第3部完!
めんどくさ4
・・・と思いましたか?残念。まだです。呼び出し側のコードを次のように修正して実行してみます。
const go = new Go()
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(result => {
go.run(result.instance)
console.log(add(1))
}
add
関数を呼び出そうとしていますが、引数が足りていません。どうなるでしょう。
panicします。とはいえこれは別にいいのです。さらにこのままコンソールでadd(1, 2)
を実行してください。
なんと次の呼び出しも「Goプログラムはすでに終了しています」と言われて失敗します。これは先ほどのpanicでmain()
関数が終了してしまったことが原因です。
これをどうするかですが、プログラムのpanicを完全に防止することは人類には不可能です。難しく考えるよりも、panicで終了した場合はアプリケーションを再度立ち上げましょう。難しく考えすぎても読みにくいだけですから。あくまで一般論として。
めんど草MAX
ということでこれが今回の最終形です。
let mod, inst
const go = new Go()
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(async (result) => {
mod = result.module
inst = result.instance
go.run(inst)
console.log(await add2(1))
})
async function add2(i, j) {
try {
return add(i, j)
} catch(e) {
inst = await WebAssembly.instantiate(mod, go.importObject)
go.run(inst)
return add2(i, j)
}
}
- panicしたときに
WebAssembly.instantiate()
で復帰できるように、最初のWebAssembly.instantiateStreaming()
実行時にmod
とinst
という変数に情報を保存します - 復帰の際に使用する
WebAssembly.instantiate()
は非同期なので、add()
関数を直接使用するのではなく、ラップした非同期のadd2()
関数を使用しました add2()
では例外発生時に、mod
を使用してWebAssembly.instantiate()
を呼び出し、再度inst
を設定、go.run(inst)
でアプリケーションを再起動します- JSの例外はGo側でpanicしたときに吐かれるのではなく、そのときはログを吐いて通常終了し、次回Go側のコードが呼び出されるときに件の「Goプログラムはすでに終了しています」が出ます。そのため、
add2()
関数の例外処理でGoアプリケーションを再起動したあとで関数を再実行しました
最初のRust版と比較するともうめんど草MAX。
ついでにGo側の全体像も載せておきます。
package main
import "syscall/js"
func main() {
ch := make(chan struct{})
js.Global().Set("add", js.FuncOf(add))
<-ch
}
func add(this js.Value, args []js.Value) interface{} {
return js.ValueOf(args[0].Int()+args[1].Int())
}
一応、実行結果も確認しておきましょう。
panic後に関数を呼び出しても無事に動作しました。
まとめ
最後にGoのWASMのめんどくさポイントをまとめます。
- Go言語側でいちいち
ValueOf()
やFuncOf()
でラップする必要がある main()
関数しか呼び出せない- チャンネルを使用して
main()
関数を終了させなくする必要がある - 返り値などにグローバルオブジェクトを使用する必要がある
- Go側で例外が発生した場合は
main()
関数を再起動する必要がある
結局、最初の一つを除きGo WASMのツラミは「ライブラリではなくアプリケーションしか作成できないこと」に尽きます。なんでこんな事になってるんですかね・・・。
ただまぁ、一つ言えるのはGo WASMの作者陣もめんどくさくしようと思ってこうなってるんではなくて、GoでWASMを吐けるようにしよう、みんなに喜んでもらおう、と思ってこうなってるはずですよね。そういう作者の気持ちを慮ってあげることも大切なのではないか。創作物に対するそういう気持ちこそが次はもっと良いものを作ろうという原動力になるのではないか。そしてそれこそが巡り巡ってすべての人が幸せになれる道なのではないか。あくまで一般論としてそんな事を考えました。
ということで、私はこれからも頑張ってGoでWASMを使い続けようと思います。
などと書いていたらレビューで大橋パイセンに「TinyGoのWASMビルドならmain以外の関数を公開できる」と教えてもらいました。
https://github.com/tinygo-org/tinygo/blob/master/src/examples/wasm/export/wasm.go
package main
func main() {
}
//export add
func add(a, b int) int {
return a + b
}
関数定義の前に//export 関数名
というコメントをつけるとJavaScript側からinst.exports.add()
みたいにして呼び出せるみたいです。ValueOf()
でラップする必要もチャンネル使ってmain()
で待ち受ける必要もなし。これ!これが欲しかった。
TinyGoでWASMビルドするとファイルサイズがだいぶ小さくなるのはどこかで見て知ってましたが、exportsできるのは知りませんでした。次回はその辺を触ってみたいと思います。
私はこれからもGoでWASMを使い続けるつもりでしたがTinyGoに乗り換えるかも知れません。
その他の記事
Other Articles
関連職種
Recruit