ludwig125のブログ

頑張りすぎずに頑張る父

github pages でWASMを使ったGoのWebツールを動かす 【その4】(WebAssemblyでのUnixTime変換ツール作成)

ページの構成

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 つの機能を作りました

  1. リアルタイムで現在時刻を表示し続ける機能
  2. テキスト欄に入力された Unixtime を日付時分秒に変換する機能
  3. タイムゾーンの表示

順番に見ていきます

説明 1. リアルタイムで現在時刻を表示し続ける機能

Go ではclock関数がこの機能を担当します

  • まずgetNow(time.Now())で現在時刻を取得して、それをもとに日付と時分秒のnowStrと Unixtime のnowUnixを作成します
  • これらをそれぞれ、getElementByIDで取得した HTML のタグ、clockclock_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()を後ろに書いておきます

参考

動作確認

このプログラムのビルドと実行方法は以下の通りです

[~/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タイムゾーンが表示されることも確認できます。

image

「変換対象の時刻」のテキスト欄に Unixtime を入力すると、「変換後の時刻」に変換後の日付時分秒が出力されます

image

日付に変換できない文字を入れると、エラー文が表示されることも確認できます

image

  • Clearボタンを押すとこの文字は消えます

image

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ですが、

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly/instantiateStreaming

返値 Promise で、次の 2 つのフィールドを持つ ResultObject で解決します。

公式ドキュメントのこちらの記載のとおり、 WebAssembly.instantiateStreamingは Promise、つまり非同期で実行されます。

Promise 処理が成功したらthenのあとの部分が実行されます。

Promise については以下の記事などが詳しいですが、

また、go.importObjectgo.runですが、これはwasm_exec.jsに定義されたもので、Go ファイルに書いた関数を読み込む部分と実行する部分となります。

つまり、WebAssembly.instantiateStreaming部分でやっていることをあらためて説明すると、

  1. WebAssembly.instantiateStreamingで WASM ファイルをフェッチして Go 関数を Import する処理を Promise で実行
  2. Promise が成功したら then 内の Go の関数が実行

となります。

ここまで当然のことを書いているようですが、 ここで重要なのは、Go に書いた任意の関数を実行しようとしても、 then内に定義しないと「まだその関数が認識されない可能性がある」ということです。

以下問題となる例を書きます。

ページ読み込み時に Go の 関数が実行できない問題

上の Unixtime ツールで作成したsetTimeZone関数は、Web ページ読み込み時にページが実行される地域のタイムゾーン(以下の UTC+9 部分)を Web ページに設定するためにつくりました。

image

一般的には、Web ページ読み込み時に Javascript の関数を即座に実行する方法として、onloadや、DOMContentLoadedを使った方法が多く見つかります。

最初、setTimeZone関数をこの方法で実行させようとしてうまくいかずはまりました。

うまくいかない例

setTimeZoneという Go の関数を Javascript 側で実行させるために、前述までのイベント処理と同じく、 Go 側で以下のようにsetTimeZoneJavascript の関数setTimeZoneFuncとして登録します。

js.Global().Set("setTimeZoneFunc", js.FuncOf(setTimeZone))
  • ここでsetTimeZonesetTimeZoneFuncという名前をつけているのは、単にどちらを指しているのか分かりやすくするためです

この関数を 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>

image

console.logに書いた文字は表示されるのに、Go 側で定義したsetTimeZoneFunc

Uncaught ReferenceError: setTimeZoneFunc is not defined

と、関数が存在しないというエラーが出てしまいました。 (time_zoneの div タグは置き換わらずにそのままです)

この理由は、上で書いた通りWebAssembly.instantiateStreamingが Promise で非同期の呼び出しとなっていて、 setTimeZoneFuncを実行されたタイミングではまだロードが終わっておらずこの関数が認識されないためです。

【脱線】test1test2の実行順序について

ちなみに上の例で、test1よりもtest2の方を後に書いているのに、ブラウザで順序が入れ変わっている理由ですが、 onload がページや画像などのリソースを読み込んでから処理を実行されるのに対し、DOMContentLoaded は HTML の読み込みと解析が完了したとき、スタイルシート、画像などの読み込みが完了するのを待たずに実行するためです。

以下のページが詳しいです。

【補足】ボタンのクリックなどのイベント処理で関数がうまく実行できた理由

上のように、Javascript でページ読み込み時に Go の関数の呼び出しに失敗してReferenceErrorが出ましたが、 それまでに紹介したボタンのクリックやテキスト欄への入力では、Go の関数が呼び出せました。

この理由は単純で、ボタンのクリックなどを実行する頃には、Go の関数のロードが終わっていて呼び出せる状態になったからです。

実際、上で Uncaught ReferenceError: setTimeZoneFunc is not definedと出た直後に、 コンソールにsetTimeZoneFunc()と入力すると、この時点ではもうロードが終わっていて、正しく実行されます。

time_zone部分がUTC+9に変わりました。

image

同様の理由で、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の仕様は以下が詳しいです

また、そもそも Macrotasks と Microtasks について知らなかったので以下の記事が大変参考になりました。

詳しい説明は上の記事に譲るとして、ここでは結論として、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を使った方法が最適なのかどうかまでは確認していません。