ludwig125のブログ

頑張りすぎずに頑張る父

github pages でWASMを使ったGoのWebツールを動かす【その3】(WebAssemblyでの計算機)

ページの構成

wasm で計算機

もう少し複雑なケースを見てみます。 そこで、 https://github.com/golang/go/wiki/WebAssembly#getting-started の下にあった https://tutorialedge.net/golang/go-webassembly-tutorial/ を参考に足し算引き算だけの計算機を作ってみます。

ただ、このページは情報が古かったので、自分なりにかなり改変しました。 その結果が以下です。

計算機1(値は固定)

wasm-calculator ブランチをmainから新しく切って修正をします。

index.html

<html>
    <head>
       <meta charset="utf-8" />
       <title>wasam-calculator</title>
       <link rel="shortcut icon" href="#" />
       <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>
        <button onClick="add(2,3);" id="addButton">Add</button>
        <button onClick="subtract(10,3);" id="subtractButton">Subtract</button>
    </body>
</html>

説明

  1. <title>wasam-calculator</title>

  2. Web ページのタイトルをつけてみました

  3. Chrome ではこれがタブに表示されます

shortcut iconの役割は、のように設定して任意の画像をタブに出すことです。

<link rel="shortcut icon" href="名前" type="<画像のパス>">

この設定がないと Console 上で以下のようなfavicon.ico 404 (Not Found)のエラーが出ます image

  1. button

  2. <button onClick="add(2,3);" id="addButton">Add</button> のように、クリックされるとadd関数に2と3を引数に与えて実行します

  3. このaddsubtractの処理内容は後述の Go プログラムで定義します

main.go

package main

import (
    "fmt"
    "syscall/js"
)

func main() {
    c := make(chan struct{})

    fmt.Println("Hello, WebAssembly!")
    registerCallbacks()
    <-c
}

func add(this js.Value, args []js.Value) interface{} {
    println(args[0].Int() + args[1].Int())
    return nil
}

func subtract(this js.Value, args []js.Value) interface{} {
    println(args[0].Int() - args[1].Int())
    return nil
}

func registerCallbacks() {
    js.Global().Set("add", js.FuncOf(add))
    js.Global().Set("subtract", js.FuncOf(subtract))
}

説明

上のコードについて説明を書きます。

  1. "syscall/js"

  2. Go で js の操作を行うためには syscall/js という標準パッケージを import する必要があります

  3. c := make(chan struct{})<-c

  4. ボタンを押すなどのイベント処理をするときにこれが必要になります

  5. イベント処理では、まず Web ページが表示されて、そのあとユーザがボタンを押して対応する処理が走るいう順番になりますが、Go のプログラムを普通に終わらせてしまうと、ボタンを押されても対応する処理ができずに以下のようにUncaught Error: Go program has already exitedのエラーが発生します

image

  • channel を使うことで main 関数の実行が終了するのを防ぐことができます。
  • channel を使う以外に select {} のように select で待ち続けることでプログラムの終了を防ぐやり方をしている人もいるようです

  • registerCallbacks()

  • js.Global().Set("property名", property)Javascript の property を登録することができます

  • ここで登録するaddsubtract関数は前述の HTML に対応するものです
  • Go 側で関数を定義して、イベント発生時に javascript として実行されるものなのでいわゆる Callback 関数です

image

  1. js.FuncOf()

  2. JavaScript の関数を返します

  3. この関数は以前はjs.NewCallbackという名前でしたが、Go1.12 で名前もインターフェースも大きく変わりました。そのため少し古い資料ではjs.FuncOf()ではなくjs.NewCallbackが多く使われていて、混乱の原因になっています

  4. addsubtract関数

  5. 上のjs.FuncOf()の package の定義に沿って、(this js.Value, args []js.Value) を引数として取って、interface{} を返す関数です

  6. args[0].Int()のように引数2つをそれぞれ Int 型にしてから足しています。
  7. この引数のうち、thisJavaScriptglobal object で、argsadd(またはsubtract)関数に与えられる引数に相当します

Valueについて

このValueが曲者です。

これが Javascript の世界と Go の世界の橋渡しをするものですが、型が動的なので、 例えば Int に変換しようとしてできない、などの場合にいとも簡単に Panic します

どこで問題が起きたのか非常に分かりにくいです

実行

ここまでで保存して、以下の通り build してサーバを立ち上げます

$ GOOS=js GOARCH=wasm go build -o main.wasm
$ goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'

ブラウザを見ると以下のように、AddボタンやSubtractボタンを押すと Console 上に結果が出力されます

image

計算機2(値は任意)

決まった数の足し算引き算では面白くないので、TextBox に数字を入力できるようにします。

wasm-calculatorから新たにwasm-calculator2ブランチを切ります

index.html

以下のように修正を加えます

        <body>
-               <button onClick="add(2,3);" id="addButton">Add</button>
-               <button onClick="subtract(10,3);" id="subtractButton">Subtract</button>
+               <input type="text" id="value1" />
+               <input type="text" id="value2" />
+
+               <button onClick="add('value1', 'value2');" id="addButton">Add</button>
+               <button onClick="subtract('value1', 'value2');" id="subtractButton">Subtract</button>
+
+               <div align="left">answer:</div>
+               <div id="answer"></div>
        </body>

説明

  • add(2,3);の代わりに、text入力値をvalue1,value2として受け取り、これをaddsubtractに渡すようにしました
  • 後述の Go プログラム側で、<div id="answer"></div>に計算結果を出力するようにします

main.go

package main

import (
    "fmt"
    "strconv"
    "syscall/js"
)

func main() {
    c := make(chan struct{})

    fmt.Println("Hello, WebAssembly!")
    registerCallbacks()
    <-c
}

func registerCallbacks() {
    js.Global().Set("add", js.FuncOf(add))
    js.Global().Set("subtract", js.FuncOf(subtract))
}

func add(this js.Value, args []js.Value) interface{} {
    value1 := textToStr(args[0])
    value2 := textToStr(args[1])

    int1, _ := strconv.Atoi(value1)
    int2, _ := strconv.Atoi(value2)
    fmt.Println("int1:", int1, " int2:", int2)
    ans := int1 + int2

    printAnswer(ans)
    return nil
}

func subtract(this js.Value, args []js.Value) interface{} {
    value1 := textToStr(args[0])
    value2 := textToStr(args[1])

    int1, _ := strconv.Atoi(value1)
    int2, _ := strconv.Atoi(value2)
    fmt.Println("int1:", int1, " int2:", int2)
    ans := int1 - int2

    printAnswer(ans)
    return nil
}

func textToStr(v js.Value) string {
    return js.Global().Get("document").Call("getElementById", v.String()).Get("value").String()
}

func printAnswer(ans int) {
    println(ans)
    js.Global().Get("document").Call("getElementById", "answer").Set("innerHTML", ans)
}

説明

  1. textToStr

  2. HTML の一行 Text ボックスをgetElementByIdで取得します

  3. この関数で、Javascript の世界の値を Go の文字列として変換しています

  4. printAnswer

  5. 計算結果を Print して、そのあと HTML 側で用意したanswerに値をセットします

実行結果

image

左のテキスト入力欄と右のテキスト入力欄の値の和や差が answer としてブラウザ上にプリントされることが確認できました

[脱線] Go の WASM はライブラリではなくアプリケーションである

GoのWASMはライブラリではなくアプリケーションである この言葉が最初が分かりませんでしたが、以下のような意味だと理解しています

  • C/C++/Rust などの言語の WASM では、JavaScript に変換して「ライブラリ」として扱うことができる
  • Go の WASM は、「アプリケーション」なので、HTML 側から実行しないといけない

そのため、イベント処理をするときは Go 側で終了させないようにチャネルで永久に待たせるとか、HTML 側で以下のようにgo.runで Go を実行させる処理が必要になります

const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
    go.run(result.instance);
});

計算機3(エラーハンドリング)

前述までで、計算機としての最低限の機能は作れましたが、いくつか重要な欠点があります。

  1. 数値のバリデーションチェックがない&エラーハンドリングできていない

  2. テキスト欄にaなど、整数変換ができないものが入力された場合、int1, err := strconv.Atoi("a")の結果、int1 には 0が設定されてしまいます

  3. このとき、errを適切にエラーハンドリングしたいです

  4. Web ページ上でエラーが分かりにくい

  5. 上のエラーハンドリングができたら、Web ページにエラーメッセージを出して不正な入力値であることを分かりやすくしたいです

  6. Panic を起こしやすい

js.Global().Get("document").Call("getElementById", v.String()).Get("value").String()
  • 例えばtextToStr関数のこの式ですが、getElementByIdで対象の ID が取得できない状態でGetメソッドを呼ぶと Panic を起こします
  • 同様に、Get("value")の結果が空の時にStringメソッドを呼んでも Panic となります
  • 可能な限り Panic で異常終了しないようにしたいです

そこで、以下の資料を参考に次の通り修正しました

wasm-calculator2から新たにwasm-calculator3ブランチを切って修正しました

main.go

修正後のコードを最初に書くと以下の通りです。

package main

import (
    "errors"
    "fmt"
    "strconv"
    "syscall/js"
)

func main() {
    registerCallbacks()
    <-make(chan struct{})
}

func registerCallbacks() {
    js.Global().Set("calcAdd", calculatorWrapper("add"))
    js.Global().Set("calcSubtract", calculatorWrapper("subtract"))
}

func calculatorWrapper(ope string) js.Func {
    calcFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        value1, err := getJSValue(args[0].String())
        if err != nil {
            return wrapResult("", err)
        }
        value2, err := getJSValue(args[1].String())
        if err != nil {
            return wrapResult("", err)
        }
        fmt.Println("value1:", value1, " value2:", value2)

        int1, err := strconv.Atoi(value1)
        if err != nil {
            return wrapResult("", fmt.Errorf("failed to convert value1 to int: %v", err))
        }
        int2, err := strconv.Atoi(value2)
        if err != nil {
            return wrapResult("", fmt.Errorf("failed to convert value2 to int: %v", err))
        }

        var ans int
        switch ope {
        case "add":
            ans = int1 + int2
        case "subtract":
            ans = int1 - int2
        default:
            return wrapResult("", fmt.Errorf("invalid operation: %s", ope))
        }
        fmt.Println("Answer:", ans)

        if err := setJSValue("answer", ans); err != nil {
            return wrapResult("", err)
        }
        return nil
    })
    return calcFunc
}

func getJSValue(elemID string) (string, 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)
    }

    jsValue := jsElement.Get("value")
    if !jsValue.Truthy() {
        return "", fmt.Errorf("failed to Get value: %s", elemID)
    }
    return jsValue.String(), nil
}

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 wrapResult(result string, err error) map[string]interface{} {
    return map[string]interface{}{
        "error":    err.Error(),
        "response": result,
    }
}

説明

分かりやすいところから書きます

1. textToStr関数を修正してgetJSValueに改名
func getJSValue(elemID string) (string, error) {
    jsDoc := js.Global().Get("document")
    if !jsDoc.Truthy() {
        return "", errors.New("failed to get document object")
    }
略
}
  • Truthy メソッドはオブジェクトがfalse, 0, "", null, undefined, NaNのどれかの時にfalseを返します
  • これを使うことで Panic を起こす前にエラーを返して呼び出しもとでエラーハンドリングできるようになります
  • 関数名はより汎用的にgetJSValueにしました
2. printAnswer関数を修正してsetJSValueに改名
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
}
  • こちらもgetJSValueと同様にTruthy で逐一判定するようにしました
  • また、値を設定したい要素の ID をelemIDとして、設定する値をvalueとして引数にすることで任意の ID に対して設定できるようにしました
  • 合わせて関数名も print よりも set の方がふさわしいことと、より汎用的にするためsetJSValueに変えました
3. addsubtract関数を統合してcalculatorWrapperでラップ
func calculatorWrapper(ope string) js.Func {
    calcFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        value1, err := getJSValue(args[0].String())
        if err != nil {
            return wrapResult("", err)
        }
        value2, err := getJSValue(args[1].String())
        if err != nil {
            return wrapResult("", err)
        }
        fmt.Println("value1:", value1, " value2:", value2)

        int1, err := strconv.Atoi(value1)
        if err != nil {
            return wrapResult("", fmt.Errorf("failed to convert value1 to int: %v", err))
        }
        int2, err := strconv.Atoi(value2)
        if err != nil {
            return wrapResult("", fmt.Errorf("failed to convert value2 to int: %v", err))
        }

        var ans int
        switch ope {
        case "add":
            ans = int1 + int2
        case "subtract":
            ans = int1 - int2
        default:
            return wrapResult("", fmt.Errorf("invalid operation: %s", ope))
        }
        fmt.Println("Answer:", ans)

        if err := setJSValue("answer", ans); err != nil {
            return wrapResult("", err)
        }
        return nil
    })
    return calcFunc
}

func wrapResult(result string, err error) map[string]interface{} {
    return map[string]interface{}{
        "error":    err.Error(),
        "response": result,
    }
}
  • 今まではjs.FuncOfの中身の関数をaddsubtractとしていましたが、それらをラップしてcalculatorWrapperにしました
  • これにより、js.FuncOfのインターフェースに縛られず、今回のopeのように自由に引数を与えることができます
  • 今回の場合は、addsubtractには共通部分が多かったのでこれらを統合して、演算部分だけopeに応じてswitchで条件分岐させるようにしました

wrapResult:

  • getJSValuesetJSValueで返したエラーと返り値をこれでラップしています
  • map[string]interface{}として返すことで、後述の javascript でエラーハンドリングできるようになります
  • 今回wrapResultの中のresponseは全部空にしているので使いません。コールバック関数から値を返したい場合はここに値を設定します
4. registerCallbacksの中で引数を指定してcalculatorWrapperを呼ぶ
func main() {
    registerCallbacks()
    <-make(chan struct{})
}

func registerCallbacks() {
    js.Global().Set("calcAdd", calculatorWrapper("add"))
    js.Global().Set("calcSubtract", calculatorWrapper("subtract"))
}
  • calculatorWrapperで統合したので、addsubtractは与える引数の違いだけになりました
    • calcAddcalcSubtract は後述のindex.htmljavascript で使います
  • <-make(chan struct{})ここは、channel の定義とまとめたほうが簡潔なのでこのようにしました

index.html

index.html は以下のように修正しました。

<html>
    <head>
       <meta charset="utf-8" />
       <title>wasam-calculator</title>
       <link rel="shortcut icon" href="#" />
       <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>
        <input type="text" id="value1" />
        <input type="text" id="value2" />

        <button onClick="addOrErr('value1', 'value2');" id="addButton">Add</button>
        <button onClick="subtractOrErr('value1', 'value2');" id="subtractButton">
            Subtract
        </button>

        <div align="left">answer:</div>
        <div id="answer"></div>

        <script>
           function checkError(result) {
               if (result != null && "error" in result) {
                   console.log("Go return value", result);
                   answer.innerHTML = "";
                   alert(result.error);
               }
           }

           var addOrErr = function (value1, value2) {
               var result = calcAdd(value1, value2);
               checkError(result);
           };
           var subtractOrErr = function (value1, value2) {
               var result = calcSubtract(value1, value2);
               checkError(result);
           };
       </script>
    </body>
</html>

説明

  • いままでonClickで直接 Go で書いたaddコールバック関数を呼び出していましたが、ここではaddOrErrという新しく定義した関数を呼び出しています
  • addOrErrの中身を分かりやすいようにcheckError部分を展開して書くと以下の通りです
var addOrErr = function (value1, value2) {
    var result = calcAdd(value1, value2);
    if (result != null && "error" in result) {
        console.log("Go return value", result);
        answer.innerHTML = "";
        alert(result.error);
    }
};
  • この関数は、テキスト欄から入力されたvalue1, value2を引数として取ります
  • 内部で、Go 側で用意したcalcAddコールバック関数を呼び出してresultを返します
  • このresultにはwrapResultで入れたマップデータが入っています
  • そこで、result.errorを見ることで Go 側の処理でエラーを返したかどうかが判定できます
  • ここでは、エラーがある場合は answer の値を空にして、alert でポップアップを出すようにしています
  • 【注意点】今回は、answerが div の HTML タグなのでinnerHTMLを使っていますが、もしanswerinputtextareaなどの入力フォームの場合はanswer.value = "";とするのが正しいです

実行結果

テキスト欄に、53を入れてAddボタンを押すと以下のように8が表示されます(計算機2と同じ) image

テキスト欄に、53を入れてSubtractボタンを押すと以下のように2が表示されます(計算機2と同じ)

image

5の代わりにaなどの数値変換できない文字を入れると、 Go で設定したfailed to convert value1 to int: strconv.Atoi: parsing "a": invalid syntax のエラーがポップアップとして表示されます。

また、Console にGo return valueが表示されていることが分かります

image

ポップアップを閉じるとanswerの中身が消えています

  • answerが空になってからポップアップが表示されると思っていましたがよしとします
  • ここの実行順序は分かっていません

image

以上で、エラーハンドリングまで対応できるようになりました