💬

Go+WebAssemblyで時刻表示を実装してみた

2021/02/17に公開

前々からWebAssemblyでなんか作ってみたいと思っていたので、今回はJSをなるべく書かないように時刻表示の実装をやってみようと思いました。
説明よりもコードを見たい方はこちらを御覧ください
https://github.com/komisan19/go-clock

完成したのはこちらです。
https://komisan19.github.io/go-clock/

WebAssembly

WebAssembly は最近のウェブブラウザーで動作し、新たな機能と大幅なパフォーマンス向上を提供する新しい種類のコードです。基本的に直接記述ではなく、C、C++、Rust 等の低水準の言語にとって効果的なコンパイル対象となるように設計されています。
https://developer.mozilla.org/ja/docs/WebAssembly/Concepts#WebAssembly_はどのようにウェブプラットフォームに適合するのか

ここではC, C++, Rustが紹介されていますが、Goでも対応しています。
Goの場合は、GoからWasmを呼び出すためにwasm_exec.jsを用意する必要があります。
もちろん1から記述必要はなく、Goをインストール時に含まれています。

準備

  • まずは適当なディレクトリを作って、go modmain.goを作っておきましょう
package main

import "fmt"

func main(){
  fmt.Println("Hello World!")
}
  • go buildをしてwasmを作ってみましょう。
    単純にbuildしてしまうと、バイナリファイルができてしまうので、環境変数を指定してあげてください。
GOOS=js GOARCH=wasm go build -o main.wasm

これでwasmは完成です!

  • 次の上記で記述したようにwasm_exec.jsを用意します
    wasm_exec.jsはGOROOT配下にあるので、カレントディレクトリにコピーします。
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
  • このままではwasmは動かないのでindex.htmlに以下のように記述しましょう。
<html>
    <head>
        <meta charset="utf-8"/>
        <script src="wasm_exec.js"></script>
        <script>
            const go = new Go();
            WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
                go.run(result.instance);
            });
        </script>
    </head>
    <body>
    </body>
</html>

スクリプトタグでは、先程作成したmain.wasmをfetchしてGoを実行するようにしています。

  • goexec実行後http://localhost:8080を開き、consoleを確認しこのように表示されていれば完成です🎉
goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'


今回はもう少し凝ったものを作りたいのでこのようなディレクトリ構成にしました。

ディレクトリ構成

.
├── README.md
├── build.go // wasmのメインとなるもの
├── docs
│   ├── build.wasm // go buildして生成されたもの
│   ├── index.html
│   └── wasm_exec.js
├── go.mod
├── go.sum
└── main.go // httpServer

index.htmlは上記で記述したものを使っています。変更点はfetch対象をbuild.wasmに変更してbodyに<div id="clock">を追加しました。
また、goexecを使わないでhttpServerを作成しました。

package main

import (
        "log"
        "net/http"
)

func main() {
        port := "8080"
        http.Handle("/", http.FileServer(http.Dir("./docs/")))
        log.Printf("Listen on port: %s", port)
        http.ListenAndServe(":"+port, nil)
}

Go標準ライブラリで実装

手始めにGo標準ライブラリtimeで実装してみましょう。
build.goに以下の記述をします。

package main

import (
        "syscall/js"
        "time"
)

func clock() {
        now := time.Now()

        date := js.Global().Get("Date").New(now.Unix() * 1000)
        js.Global().Get("document").Call("getElementById", "clock").Set("innerHTML", date)
}

func main() {
        clock()
        select {}
}

注意するべき点は下記の部分です。

 date := js.Global().Get("Date").New(now.Unix() * 1000)

JSではUNIXの時間をミリで表示しているので1000かけて上げる必要があります。
できあがったものがこちら

ただこれだと味気ないのでもう少しリアルタイムに表示させましょう。

リアルタイム実装

build.goをこのように変更します。

package main

import (
        "syscall/js"
)

func clock(this js.Value, args []js.Value) interface{} {
        date := js.Global().Get("Date").New().Call("toLocaleString").String()

        js.Global().Get("document").Call("getElementById", "clock").Set("innerHTML", date)
        return nil
}

func main() {
        js.Global().Call("setInterval", js.FuncOf(clock), "200")
        select{}
}

ライブラリはsyscall/jsを使います。
clockで実処理を行っています。一行づつ解説していきます。

date := js.Global().Get("Date").New().Call("toLocaleString").String()

js.Global()はwindowからいろいろなオブジェクトに対してアクセスしています。Get()を使ってDateオブジェクトを取得してあげます。Dateはjs同様インスタンスを生成して上げる必要があるので、New()を記述してあげます。Call()を使うことによってJavaScriptの呼び出しを行ってあげます。

js.Global().Get("document").Call("getElementById", "clock").Set("innerHTML", date)

js.Global().Get("document")は上記と同様です。今回はdocumentを取得し、getElementByIdで初めの方に作った<div id="clock">を指定してあげます。

ファイルサイズについて

wasmのファイルサイズは以下のように2Mを下回っています。(記述量が少ないのもありますが、、、)
ただ、Goの場合2~10MB以上に変動するのでこれ以下に抑えたい場合は、手動でzipしてあげるか、TinyGoを使うのを推奨しています。

  1. Manually compress the .wasm file.
  2. Use TinyGo to generate the Wasm file instead.
    https://github.com/golang/go/wiki/WebAssembly#reducing-the-size-of-wasm-files

Discussion