github pages でWASMを使ったGoのWebツールを動かす 【その5】(UnixTime変換ツールのTinyGoへの置き換え)
ページの構成
- github pages でWASMを使ったGoのWebツールを動かす【その1】(github pages導入) - ludwig125のブログ
- github pages でWASMを使ったGoのWebツールを動かす【その2】(WebAssembly導入) - ludwig125のブログ
- github pages でWASMを使ったGoのWebツールを動かす【その3】(WebAssemblyでの計算機) - ludwig125のブログ
- github pages でWASMを使ったGoのWebツールを動かす 【その4】(WebAssemblyでのUnixTime変換ツール作成) - ludwig125のブログ
- github pages でWASMを使ったGoのWebツールを動かす 【その5】(UnixTime変換ツールのTinyGoへの置き換え) - ludwig125のブログ
- github pages でWASMを使ったGoのWebツールを動かす 【その6】(WASM の Web ツールを github pages で公開する) - ludwig125のブログ
TinyGo への置き換え
TinyGoのInstallと実行
Installなどはこちらのページにまとめました
TinyGo の実行方法
上で作った Unixtime ツールを TinyGo に置き換えてみます。
以下の方法で TinyGo として Buid できます。(通常の Go に比べて若干 Build に時間がかかるような気がします)
$tinygo build -o unixtime.wasm -target wasm unixtime.go
cp $(tinygo env TINYGOROOT)/targets/wasm_exec.js .
これだけで TinyGo として WASM で実行できます。
[~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'
ただ、http://localhost:8080/ を見ると、一見問題ないように見えますが、 「変換対象の時刻」に Unixtime を入れると Console にエラーがでます。 (処理自体は問題なく行われます)
エラー:syscall/js.finalizeRef not implemented
このエラー原因について詳しくは以下を見ると良いのですが、
TinyGo のバグなので、TinyGo のwasm_exec.js
が修正されるまでは、以下のようにindex.html
側に書いておくとこのエラーがなくなります。
const go = new Go(); // TinyGoのバグを無視するため // https://github.com/tinygo-org/tinygo/issues1140#issuecomment-671261465 go.importObject.env["syscall/js.finalizeRef"] = ()=> {}; WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => {
参考:
これでエラー文が出なくなります。
TinyGo のバイナリサイズ
2つのバイナリサイズを比べてみます
[~/go/src/github.com/ludwig125/githubpages/unixtime] $GOOS=js GOARCH=wasm go build -o unixtime.wasm [~/go/src/github.com/ludwig125/githubpages/unixtime] $ls -l 合計 2096 -rw-r--r-- 1 ludwig125 ludwig125 1247 2月 14 06:58 index.html -rw-r--r-- 1 ludwig125 ludwig125 2103 2月 18 06:08 unixtime.go -rwxr-xr-x 1 ludwig125 ludwig125 2113909 2月 18 06:08 unixtime.wasm* -rw-r--r-- 1 ludwig125 ludwig125 18346 2月 14 06:10 wasm_exec.js
[~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $tinygo build -o unixtime.wasm -target wasm unixtime.go [~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $ls -l 合計 464 -rw-r--r-- 1 ludwig125 ludwig125 1437 2月 17 06:39 index.html -rw-r--r-- 1 ludwig125 ludwig125 2103 2月 18 06:08 unixtime.go -rwxr-xr-x 1 ludwig125 ludwig125 447857 2月 18 06:09 unixtime.wasm* -rw-r--r-- 1 ludwig125 ludwig125 15929 2月 14 06:30 wasm_exec.js
私の環境では、ほぼ同じコードでも、TinyGo は Go と比べてunixtime.wasm*
のバイナリサイズが 1/4 以下になっていました。
TinyGo の速度
バイナリサイズが小さいということは、当然 WASM として Fetch したり Load するのも速くなるはずです。
通常の Go と TinyGo の Load までの時間を計測するために、それぞれのindex.html
に以下のコードを追加してみます。
<script> var start = performance.now(); // 追加部分 const go = new Go(); WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => { go.run(result.instance); var end = performance.now(); // 追加部分 console.log("latency of load and run wasm %f ms", end - start); // 追加部分 }); </script>
https://developer.mozilla.org/ja/docs/Web/API/Performance/now
こちらのパフォーマンス計測用の関数を使います
WebAssembly.instantiateStreaming
の前をstart
go.run(result.instance);
の後をend
としてこの差分を測ってみます。
ついでに、Go の方の関数にも Latency を計測するために以下の部分を追記します。
func convTime(this js.Value, args []js.Value) interface{} { start := time.Now() defer func() { fmt.Println("convTime latency:", time.Since(start)) }() 略
これで、Go と TinyGoUnixtime の Web ページをそれぞれ順番に見てみます。
通常の Go
TinyGo
注意点
- Go のあとに TinyGo のページを読み込みなおすときは、Chrome のキャッシュに残っていておかしなエラーが出る場合があります。この場合はキャッシュをクリアしてページを再読み込みするために、
Ctrl+Shift+R
でページを更新するといいです
WASM の Fetch から実行までの時間は
- Go: 52.10000002384186 ms
- TinyGo: 16 ms
となりました。
やはり、起動までの時間は TinyGo の方が短くなっています。 今回は小さなプログラムなので、この程度の差ですが、大きなプログラムになると実行までの時間はさらに変わってくるかも知れません。
一方で、convTime
の実行速度はあまり変わりませんでした。
これは意外でした。
ひとたびバイナリとして読み込んでメモリに乗ってしまえばあとはそんなに変わらないものなのか、それとも実行している関数がそんなに違いが見られる類のものではなかったのかも知れませんが分かりません。
export を利用した TinyGo コードの書き換え
TinyGo は Go と同じコードをそのまま使えますが、TinyGo ならではのexport
の機能を使うとコードをより直接に呼びだすことができます
https://tinygo.org/docs/guides/webassembly/
If you have used explicit exports, you can call them by invoking them under the wasm.exports namespace. See the export directory in the examples for an example of this.
とあるとおり、以下のように Go の関数に//export 関数名
をつけるだけで、なんと Javascript 側から呼びだすことができます。
//export multiply func multiply(x, y int) int { return x * y; }
- ここで、
//export
の//
とexport
の間に半角スペースを入れると認識されないので、くっつけて書くことを注意してください** - ちなみに
//export
は以前は//go:export
でしたが、2020 年に変わったので少し古い資料を見ると//go:export
となっていることがあります
javascript からの呼び出し方法
// Calling the multiply function: console.log("multiplied two numbers:", wasm.exports.multiply(5, 3));
このmultiply
関数はこれまでの WASM の Go の書き方の
multiply(this js.Value, args []js.Value) interface{}
のような形にしなくて済むというのが最大の利点です。
//export
を使った場合の大きな問題点もあるのですがそれは後述します
この機能を使うと、Unixtime ツールの例えばsetTimeZone
関数は以下のようにシンプルになり、
//export setTimeZone func setTimeZone() { t := time.Now() zone, _ := t.Zone() setJSValue("time_zone", fmt.Sprintf("(%s)", zone)) }
index.html 側では以下のように呼びだすことができます。
const go = new Go(); WebAssembly.instantiateStreaming(fetch("unixtime.wasm"), go.importObject).then( (result) => { go.run(result.instance); result.instance.exports.setTimeZone(); } );
この方式で、go.run(result.instance);
のあとに必要な処理をつらつら書いても良いのですが、これだとindex.html
の<head>
の<script>
部分が肥大するので、以下の資料を参考にindex.js
ファイルに切り出してみます。
package main import ( "errors" "fmt" "strconv" "syscall/js" "time" ) func main() {} //export setTimeZone func setTimeZone() { t := time.Now() zone, _ := t.Zone() setJSValue("time_zone", fmt.Sprintf("(%s)", zone)) } func setJSValue(elemID string, value interface{}) error { // 元と同じ } func getElementByID(targetID string) js.Value { // 元と同じ } //export clock func clock() { nowStr, nowUnix := getNow(time.Now()) getElementByID("clock").Set("textContent", nowStr) getElementByID("clock_unixtime").Set("textContent", nowUnix) } //export convTime func convTime() { in := getElementByID("in").Get("value").String() date, err := unixtimeToDate(in) if err != nil { getElementByID("out").Set("value", js.ValueOf("不正な時刻です")) return } getElementByID("out").Set("value", js.ValueOf(date)) } // 以降、元と同じ
「//export
」を使うことでかなりシンプルになりました。
TinyGo の export を使えば Javascript 側から Go の関数を直接呼びだすことができます。
コールバック関数が呼び出されたときのために Go のプログラムを永久に終わらせないようにするために、main
関数内でチャネルを使っていましたがその必要もなくなりました。
Go の関数の呼び出し側である、HTML と Javascript も修正します。
前述の通り head 部分を見やすくするために、以下を参考に修正しました。
まず、WASM ファイルのインスタンス生成部分を別のファイルにします。
instantiateWasm.js
export const wasmBrowserInstantiate = async (wasmModuleUrl, importObject) => { let response = undefined; if (!importObject) { importObject = { env: { abort: () => console.log("Abort!"), }, }; } response = await WebAssembly.instantiateStreaming( fetch(wasmModuleUrl), importObject ); return response; };
// polyfillを定義した場合 if (WebAssembly.instantiateStreaming) { response = await WebAssembly.instantiateStreaming( fetch(wasmModuleUrl), importObject ); } else { const fetchAndInstantiateTask = async () => { const wasmArrayBuffer = await fetch(wasmModuleUrl).then((response) => response.arrayBuffer() ); return WebAssembly.instantiate(wasmArrayBuffer, importObject); }; response = await fetchAndInstantiateTask(); }
一方、呼び出し側のindex.html
から、WASM の呼び出し部分を切り出して別のファイルにすると以下のようになります。
index.js
import { wasmBrowserInstantiate } from "./instantiateWasm.js"; const go = new Go(); // Defined in wasm_exec.js. Don't forget to add this in your index.html. // TinyGoのバグを無視するため // https://github.com/tinygo-org/tinygo/issues/1140#issuecomment-671261465 go.importObject.env["syscall/js.finalizeRef"] = () => {}; const runWasm = async () => { // Get the importObject from the go instance. const importObject = go.importObject; // wasm moduleのインスタンスを作成 const wasmModule = await wasmBrowserInstantiate( "./unixtime.wasm", importObject ); go.run(wasmModule.instance); wasmModule.instance.exports.setTimeZone(); setInterval(wasmModule.instance.exports.clock, 200); document .getElementById("in") .addEventListener("input", wasmModule.instance.exports.convTime); }; runWasm();
- インスタンス生成部分とメインの処理部分を分離して分かりやすくなりました
- (
wasmModule.instance.exports.
部分がやや鬱陶しいですが、)Go の関数を Javascript ネイティブの関数のように扱うことができるようになったため、実行方法も Javascript の書き方になっています
最後に、このindex.js
をindex.html
から呼びだせば終わりです。
<head> <meta charset="utf-8" /> <title>unixtime</title> <link rel="shortcut icon" href="#" /> <script src="./wasm_exec.js"></script> <script type="module" src="./index.js"></script> </head>
かなり見やすくなったかと思います。
export を使った関数の限界
とても素敵な機能に思える TinyGo の//export
ですが、これを書いている 2022 年 3 月の現時点ではとても大きな問題があります。
それは、WASM では直接文字列をやりとりできないことです
以下が WASM の扱える型の種類です。 https://github.com/WebAssembly/design/blob/main/Semantics.md#types
WebAssembly has the following value types: i32: 32-bit integer i64: 64-bit integer f32: 32-bit floating point f64: 64-bit floating point
そのため、例えば以下のような方法で直接文字列を関数に渡したり返してもらうことはできません
// 以下のようにTinyGoで関数を使うことはできない //export printMessage func printMessage(s string) { // stringを受け取ることができない fmt.Println("hello:", s) } //export returnString func returnString() string { return "hello" // stringを返すこともできない }
int 型は扱えるので、変数のアドレスと長さを計算してそれを関数に渡す方法があるにはありますが、とても分かりやすいとは言えません。
参考
- https://github.com/tinygo-org/tinygo/issues/645
- https://github.com/tinygo-org/tinygo/issues/411#issuecomment-503066868
- https://www.alcarney.me/blog/2020/passing-strings-between-tinygo-wasm/
- https://stackoverflow.com/questions/41353389/how-can-i-return-a-javascript-string-from-a-webassembly-function
- https://github.com/tinygo-org/tinygo/issues/1824
- https://wasmbyexample.dev/examples/webassembly-linear-memory/webassembly-linear-memory.go.en-us.html
- https://nulab.com/ja/blog/nulab/basic-webassembly-begginer/
- https://zenn.dev/summerwind/articles/96f2aae05b6614
また仮に文字列を1つ渡せても2つ以上はできないので、その場合は json などでデコードして渡す必要があります
TinyGo での json 参考
- https://github.com/tinygo-org/tinygo/issues/447
- https://github.com/mailru/easyjson
- https://www.sambaiz.net/article/193/
- https://stackoverflow.com/questions/40587860/using-easyjson-with-golang/44757748
- https://github.com/tinygo-org/tinygo/pull/2314
以上の理由から、TinyGo でも//export
を使いまくるわけにはいかず、文字列のやり取りをする際は素直に js パッケージを使って Javascript とやり取りしたほうが便利な場面が多そうです。