github pages でWASMを使ったGoのWebツールを動かす 【その4】(WebAssemblyでのUnixTime変換ツール作成)
ページの構成
- 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のブログ
Unixtime 変換ツール
上の加算減算しかできない計算機より少しは使い道のありそうな、Unixtime を JST の日付に変換するツールを作ってみました
いきなりコードを載せると以下の通りです
unixtime.go
package main import ( "errors" "fmt" "strconv" "syscall/js" "time" ) func main() { unixtime() <-make(chan struct{}) } func unixtime() { // time zoneを最初に表示させる js.Global().Call("queueMicrotask", js.FuncOf(setTimeZone)) // 二度と使わない関数はメモリを解放する js.FuncOf(setTimeZone).Release() // 一定時間おきにclockを呼び出す js.Global().Call("setInterval", js.FuncOf(clock), "200") getElementByID("in").Call("addEventListener", "input", js.FuncOf(convTime)) } func setTimeZone(this js.Value, args []js.Value) interface{} { t := time.Now() zone, _ := t.Zone() return setJSValue("time_zone", fmt.Sprintf("(%s)", zone)) } func setJSValue(elemID string, value interface{}) error { jsDoc := js.Global().Get("document") if !jsDoc.Truthy() { return errors.New("failed to get document object") } jsElement := jsDoc.Call("getElementById", elemID) if !jsElement.Truthy() { return fmt.Errorf("failed to getElementById: %s", elemID) } jsElement.Set("innerHTML", value) return nil } func getElementByID(targetID string) js.Value { return js.Global().Get("document").Call("getElementById", targetID) } func clock(this js.Value, args []js.Value) interface{} { nowStr, nowUnix := getNow(time.Now()) getElementByID("clock").Set("textContent", nowStr) getElementByID("clock_unixtime").Set("textContent", nowUnix) return nil } func convTime(this js.Value, args []js.Value) interface{} { in := getElementByID("in").Get("value").String() date, err := unixtimeToDate(in) if err != nil { getElementByID("out").Set("value", js.ValueOf("不正な時刻です")) return nil } getElementByID("out").Set("value", js.ValueOf(date)) return nil } func getNow(now time.Time) (string, string) { s := now.Format("2006-01-02 15:04:05") unix := now.Unix() return s, fmt.Sprintf("%d", unix) } func unixtimeToDate(s string) (string, error) { unixtime, err := strconv.Atoi(s) if err != nil { return "", err } date := time.Unix(int64(unixtime), 0) layout := "2006-01-02 15:04:05" // Goの時刻フォーマットではこれで時分秒まで取れる return date.Format(layout), nil }
index.html
<html> <head> <meta charset="utf-8" /> <title>unixtime</title> <link rel="shortcut icon" href="#" /> <script src="wasm_exec.js"></script> <script> const go = new Go(); WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => { go.run(result.instance); }); </script> </head> <body> <h1>UnixTimeを日付に変換するツール</h1> <table border="1" align="center" width="600" height="100"> <tr align="center"> <td> 現在時刻<br /> <div id="time_zone">time_zone</div> </td> <td><div id="clock"></div></td> <td><div id="clock_unixtime"></div></td> </tr> </table> <hr /> <table border="1" align="center" width="300" height="200"> <tr align="center"> <td valign="middle">変換対象の時刻</td> <td><input type="text" id="in" /></td> </tr> <tr align="center"> <td valign="middle">変換後の時刻</td> <td> <input type="text" id="out" /> <button onclick="document.getElementById('out').value = ''"> Clear </button> </td> </tr> </table> </body> </html>
ツールの概要
このツールでは大きく分けて 3 つの機能を作りました
- リアルタイムで現在時刻を表示し続ける機能
- テキスト欄に入力された Unixtime を日付時分秒に変換する機能
- タイムゾーンの表示
順番に見ていきます
説明 1. リアルタイムで現在時刻を表示し続ける機能
Go ではclock
関数がこの機能を担当します
- まず
getNow(time.Now())
で現在時刻を取得して、それをもとに日付と時分秒のnowStr
と Unixtime のnowUnix
を作成します - これらをそれぞれ、
getElementByID
で取得した HTML のタグ、clock
とclock_unixtime
に設定しています - ポイントは、この
clock
関数を、setIntervalを使って 200 ミリ秒ごとに実行されるようにしていることですjs.Global().Call("setInterval", js.FuncOf(clock), "200")
- これにより、Web ツールの表示中、200 ミリ秒ごとに現在時刻が更新されるようになります
HTML 側は以下が対応します
<table border="1" align="center" width="600" height="100"> <tr align="center"> <td>現在時刻</td> <td><div id="clock"></div></td> <td><div id="clock_unixtime"></div></td> </tr> </table>
説明 2. テキスト欄に入力された Unixtime を日付時分秒に変換する機能
Go では、convTime
関数がこの機能を担当します
- この関数では、HTML の
in
テキスト欄に入力された文字をunixtimeToDate
関数で変換し、変換後の文字列を HTML のout
テキスト欄に設定します - この時、Unixtime として間違ったものを
in
に入力すると、out
に不正な時刻です
と出すようにしました - ポイントは、addEventListenerを使って、
in
テキスト欄に入力があったら(input
があったら)convTime
が実行されるようにしたことです - これにより、
in
に入力したのと同時にout
に変換後の値が表示されるようになります
HTML 側は以下のコードが対応します
<table border="1" align="center" width="300" height="200"> <tr align="center"> <td valign="middle">変換対象の時刻</td> <td><input type="text" id="in" /></td> </tr> <tr align="center"> <td valign="middle">変換後の時刻</td> <td> <input type="text" id="out" /> <button onclick="document.getElementById('out').value = ''">Clear</button> </td> </tr> </table>
- テキスト欄
out
の文字をクリアするボタンをつけたくなったのですが、これは次のように直下に書いたほうが(Go 側で実装して呼び出すよりも)簡単なのでこうしました <button onclick="document.getElementById('out').value = ''">Clear</button>
説明 3. タイムゾーンの表示
現在時刻
の下に、タイムゾーンを表示させました。- Go の time パッケージの
Zone
メソッドを使って取得したものを HTML のtime_zone
タグに出しています - ここでは、「計算機3」の時に作った
setJSValue
関数を転用しました - unixtime 全体に言えますが、ここではコードのわかりやすさを優先して、「計算機3」で用いたようなエラーハンドリングはここではしていません
- ここで、ページの読み込み時に
queueMicrotask
を使用しました- この
queueMicrotask
を使った経緯は長くなるので詳しくは後に説明を書きましたが、ここで簡単に説明すると、実行したい処理をキュー
につめて後で実行されるようにしています
- この
js.Global().Call("queueMicrotask", js.FuncOf(setTimeZone)) js.FuncOf(setTimeZone).Release()
やっていることは上の
setInterval
とそっくりで、setInterval
が定期的に実行されるのに対して、こちらは単発での実行となりますsetTimeZone
のように、一度呼びだされたら二度と使わない関数は、Release
メソッドを使ってメモリを解放しておくとメモリの節約になってよいのでjs.FuncOf(setTimeZone).Release()
を後ろに書いておきます
参考
- https://pkg.go.dev/syscall/js#Func.Release
- https://zenn.dev/nobonobo/books/85e605893d44ebe7dd3f/viewer/b5ac64d9135e123e367a
動作確認
このプログラムのビルドと実行方法は以下の通りです
[~/go/src/github.com/ludwig125/githubpages/docs/unixtime] $GOOS=js GOARCH=wasm go build -o unixtime.wasm
unixtime.wasm を出力したバイナリファイル名としました
サーバを実行します
goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'
Web ページを最初に見たときはこんな感じです
現在時刻の部分は 200 ミリ秒ごとにリアルタイムで現在時刻の日付時分秒と Unixtime を表示し続けます
- 「現在時刻」の下に
UTC+9
とタイムゾーンが表示されることも確認できます。
「変換対象の時刻」のテキスト欄に Unixtime を入力すると、「変換後の時刻」に変換後の日付時分秒が出力されます
日付に変換できない文字を入れると、エラー文が表示されることも確認できます
Clear
ボタンを押すとこの文字は消えます
WebAssembly.instantiateStreaming()が Promise であるということと、queueMicrotask を使った理由について
queueMicrotask
について上では簡単に説明しただけだったのですが、
ここは個人的にものすごくはまった個所なので少し詳しく説明します。
これは、私が Javascript 未経験だったことも大きいので、詳しい方は読み飛ばしていい箇所です。
WebAssembly.instantiateStreaming()は Promise
まず、この記事で何回も書いてきた WASM ファイルのロード部分をあらためて書きます。
<script src="wasm_exec.js"></script> <script> const go = new Go(); WebAssembly.instantiateStreaming( fetch("XXX.wasm"), go.importObject ).then((result) => { go.run(result.instance); });
ここで使っているWebAssembly.instantiateStreaming
ですが、
返値 Promise で、次の 2 つのフィールドを持つ ResultObject で解決します。
公式ドキュメントのこちらの記載のとおり、
WebAssembly.instantiateStreaming
は Promise、つまり非同期で実行されます。
Promise 処理が成功したらthen
のあとの部分が実行されます。
Promise については以下の記事などが詳しいですが、
- https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises
- https://qiita.com/cheez921/items/41b744e4e002b966391a
また、go.importObject
やgo.run
ですが、これはwasm_exec.js
に定義されたもので、Go ファイルに書いた関数を読み込む部分と実行する部分となります。
つまり、WebAssembly.instantiateStreaming
部分でやっていることをあらためて説明すると、
WebAssembly.instantiateStreaming
で WASM ファイルをフェッチして Go 関数を Import する処理を Promise で実行- Promise が成功したら then 内の Go の関数が実行
となります。
ここまで当然のことを書いているようですが、
ここで重要なのは、Go に書いた任意の関数を実行しようとしても、
then
内に定義しないと「まだその関数が認識されない可能性がある」ということです。
以下問題となる例を書きます。
ページ読み込み時に Go の 関数が実行できない問題
上の Unixtime ツールで作成したsetTimeZone
関数は、Web ページ読み込み時にページが実行される地域のタイムゾーン(以下の UTC+9
部分)を Web ページに設定するためにつくりました。
一般的には、Web ページ読み込み時に Javascript の関数を即座に実行する方法として、onload
や、DOMContentLoaded
を使った方法が多く見つかります。
最初、setTimeZone
関数をこの方法で実行させようとしてうまくいかずはまりました。
うまくいかない例
setTimeZone
という Go の関数を Javascript 側で実行させるために、前述までのイベント処理と同じく、
Go 側で以下のようにsetTimeZone
を Javascript の関数setTimeZoneFunc
として登録します。
js.Global().Set("setTimeZoneFunc", js.FuncOf(setTimeZone))
- ここで
setTimeZone
にsetTimeZoneFunc
という名前をつけているのは、単にどちらを指しているのか分かりやすくするためです
この関数を Web ページの読み込み時に実行させるために、
HTML のヘッダー部分に以下のようにwindow.onload
や、document.addEventListener("DOMContentLoaded", 関数)
を書いて実行させると、ブラウザのコンソールは次のようになります。
<script> const go = new Go(); WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => { go.run(result.instance); }); window.onload = function () { console.log("test1"); }; document.addEventListener("DOMContentLoaded", function () { console.log("test2"); }); setTimeZoneFunc(); </script>
console.log
に書いた文字は表示されるのに、Go 側で定義したsetTimeZoneFunc
は
Uncaught ReferenceError: setTimeZoneFunc is not defined
と、関数が存在しないというエラーが出てしまいました。
(time_zone
の div タグは置き換わらずにそのままです)
この理由は、上で書いた通りWebAssembly.instantiateStreaming
が Promise で非同期の呼び出しとなっていて、
setTimeZoneFunc
を実行されたタイミングではまだロードが終わっておらずこの関数が認識されないためです。
【脱線】test1
とtest2
の実行順序について
ちなみに上の例で、test1
よりもtest2
の方を後に書いているのに、ブラウザで順序が入れ変わっている理由ですが、
onload がページや画像などのリソースを読み込んでから処理を実行されるのに対し、DOMContentLoaded は HTML の読み込みと解析が完了したとき、スタイルシート、画像などの読み込みが完了するのを待たずに実行するためです。
以下のページが詳しいです。
【補足】ボタンのクリックなどのイベント処理で関数がうまく実行できた理由
上のように、Javascript でページ読み込み時に Go の関数の呼び出しに失敗してReferenceError
が出ましたが、
それまでに紹介したボタンのクリックやテキスト欄への入力では、Go の関数が呼び出せました。
この理由は単純で、ボタンのクリックなどを実行する頃には、Go の関数のロードが終わっていて呼び出せる状態になったからです。
実際、上で Uncaught ReferenceError: setTimeZoneFunc is not defined
と出た直後に、
コンソールにsetTimeZoneFunc()
と入力すると、この時点ではもうロードが終わっていて、正しく実行されます。
time_zone
部分がUTC+9
に変わりました。
同様の理由で、Javascript で意図的に Sleep をさせたあとに Go の関数を呼び出しても成功します。
以下では、Promise でsetTimeout
をすることで、3 秒待ってからsetTimeZoneFunc
を呼び出すコードを書きました。
3秒も待てばロードが終わるので、呼び出しに失敗することがありません。
ただし、これは厳密に WebAssembly.instantiateStreaming
の完了を待っているわけではないので良いコードとは言えません。
<script> const go = new Go(); WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => { go.run(result.instance); }); async function waitGoLoad() { console.log("wait 3 seconds..."); await new Promise((s) => setTimeout(s, 3000)); setTimeZoneFunc(); } waitGoLoad();
ページ読み込み時に Go の関数を実行させる方法 その1
もっとも単純な解決方法は、
WebAssembly.instantiateStreaming
の Promise が成功した後、つまりthen
のなかのgo.run(result.instance);
のあとにsetTimeZoneFunc
を設定することです。
こうすれば確実に Go 関数のロードが完了しているので、問題なく呼び出すことができます。
<head> 略 <script src="wasm_exec.js"></script> <script> const go = new Go(); WebAssembly.instantiateStreaming( fetch("unixtime.wasm"), go.importObject ).then((result) => { go.run(result.instance); setTimeZoneFunc(); }); </script> </head>
上の記事のように、go.run(result.instance);
後に Web ページ読み込み時に必要な処理を書いていく方法は他にもいくつか見つけたのですが、今回は次のqueueMicrotask
を使う方法を採用しました。
ページ読み込み時に Go の関数を実行させる方法 その2
今回の用途では上の方法でも良かったのですが、
もしこの方法で他の処理も書いていくと<head>
の<script>
部分がどんどん肥大化していくことになります。
個人的にはこの部分はシンプルにしたい思いがありました。
また、Unixtime ツールの機能のうち、
「1. リアルタイムで現在時刻を表示し続ける機能」が Go のjs.Global().Call("setInterval", js.FuncOf(clock), "200")
で完結しているのに、「3. タイムゾーンの表示」を HTML 側でも呼び出さないといけないのがどうにも気に入りませんでした。
そこで、queueMicrotask
を使う方法にしました。
queueMicrotask
の仕様は以下が詳しいです
- https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide
- https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask
また、そもそも Macrotasks と Microtasks について知らなかったので以下の記事が大変参考になりました。
- https://hidekazu-blog.com/javascript-macrotasks-microtasks/
- https://ja.javascript.info/event-loop#ref-473
- https://christina04.hatenablog.com/entry/2017/03/13/190000
- https://tech.wwwave.jp/entry/javascript-async-execution
詳しい説明は上の記事に譲るとして、ここでは結論として、queueMicrotask
関数にsetTimeZone
を登録しておくことで、Go の実行時に即時にsetTimeZone
を実行することができるようになります。
また、蛇足ですが、上で紹介した
js.Global().Call("setInterval", js.FuncOf(clock), "200")
は、200 ミリ秒ごとにclock
を呼び出しているので、Web ページ表示後最初の 200 ミリ秒間、一瞬だけ時刻の部分が空になる瞬間があります。
これを防ぐ方法として、clock
に対しても以下のようにqueueMicrotask
を使うことで、
Web ページ読み込み時に最初にすぐにclock
を実行し、そのあと 200 ミリ秒毎に実行されることで、一瞬空になる瞬間をなくすことができます。
js.Global().Call("queueMicrotask", js.FuncOf(clock)) js.Global().Call("setInterval", js.FuncOf(clock), "200")
繰り返しですが、私は Javascript 初心者なので、このqueueMicrotask
を使った方法が最適なのかどうかまでは確認していません。