github pages でWASMを使ったGoのWebツールを動かす【その3】(WebAssemblyでの計算機)
ページの構成
- 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のブログ
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>
説明
<title>wasam-calculator</title>
Web ページのタイトルをつけてみました
Chrome ではこれがタブに表示されます
shortcut icon
の役割は、のように設定して任意の画像をタブに出すことです。
<link rel="shortcut icon" href="名前" type="<画像のパス>">
この設定がないと Console 上で以下のようなfavicon.ico 404 (Not Found)
のエラーが出ます
button
<button onClick="add(2,3);" id="addButton">Add</button>
のように、クリックされるとadd
関数に2と3を引数に与えて実行します- この
add
とsubtract
の処理内容は後述の 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)) }
説明
上のコードについて説明を書きます。
"syscall/js"
Go で js の操作を行うためには syscall/js という標準パッケージを import する必要があります
c := make(chan struct{})
と<-c
ボタンを押すなどのイベント処理をするときにこれが必要になります
- イベント処理では、まず Web ページが表示されて、そのあとユーザがボタンを押して対応する処理が走るいう順番になりますが、Go のプログラムを普通に終わらせてしまうと、ボタンを押されても対応する処理ができずに以下のように
Uncaught Error: Go program has already exited
のエラーが発生します
- channel を使うことで main 関数の実行が終了するのを防ぐことができます。
channel を使う以外に
select {}
のように select で待ち続けることでプログラムの終了を防ぐやり方をしている人もいるようですregisterCallbacks()
js.Global().Set("property名", property)
で Javascript の property を登録することができます- ここで登録する
add
とsubtract
関数は前述の HTML に対応するものです - Go 側で関数を定義して、イベント発生時に javascript として実行されるものなのでいわゆる Callback 関数です
js.FuncOf()
JavaScript の関数を返します
この関数は以前は
js.NewCallback
という名前でしたが、Go1.12 で名前もインターフェースも大きく変わりました。そのため少し古い資料ではjs.FuncOf()
ではなくjs.NewCallback
が多く使われていて、混乱の原因になっていますadd
とsubtract
関数上の
js.FuncOf()
の package の定義に沿って、(this js.Value, args []js.Value)
を引数として取って、interface{}
を返す関数ですargs[0].Int()
のように引数2つをそれぞれ Int 型にしてから足しています。- この引数のうち、
this
は JavaScript の global object で、args
はadd
(または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 上に結果が出力されます
計算機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
として受け取り、これをadd
やsubtract
に渡すようにしました- 後述の 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) }
説明
textToStr
HTML の一行 Text ボックスを
getElementById
で取得しますこの関数で、Javascript の世界の値を Go の文字列として変換しています
printAnswer
計算結果を Print して、そのあと HTML 側で用意した
answer
に値をセットします
実行結果
左のテキスト入力欄と右のテキスト入力欄の値の和や差が 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(エラーハンドリング)
前述までで、計算機としての最低限の機能は作れましたが、いくつか重要な欠点があります。
数値のバリデーションチェックがない&エラーハンドリングできていない
テキスト欄に
a
やあ
など、整数変換ができないものが入力された場合、int1, err := strconv.Atoi("a")
の結果、int1 には0
が設定されてしまいますこのとき、
err
を適切にエラーハンドリングしたいですWeb ページ上でエラーが分かりにくい
上のエラーハンドリングができたら、Web ページにエラーメッセージを出して不正な入力値であることを分かりやすくしたいです
Panic を起こしやすい
js.Global().Get("document").Call("getElementById", v.String()).Get("value").String()
- 例えば
textToStr
関数のこの式ですが、getElementById
で対象の ID が取得できない状態でGet
メソッドを呼ぶと Panic を起こします - 同様に、
Get("value")
の結果が空の時にString
メソッドを呼んでも Panic となります - 可能な限り Panic で異常終了しないようにしたいです
そこで、以下の資料を参考に次の通り修正しました
- https://golangbot.com/go-webassembly-dom-access/
- https://dev.bitolog.com/go-in-the-browser-using-webassembly/
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. add
とsubtract
関数を統合して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
の中身の関数をadd
やsubtract
としていましたが、それらをラップしてcalculatorWrapper
にしました - これにより、
js.FuncOf
のインターフェースに縛られず、今回のope
のように自由に引数を与えることができます - 今回の場合は、
add
とsubtract
には共通部分が多かったのでこれらを統合して、演算部分だけope
に応じてswitch
で条件分岐させるようにしました
wrapResult
:
getJSValue
やsetJSValue
で返したエラーと返り値をこれでラップしています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
で統合したので、add
とsubtract
は与える引数の違いだけになりましたcalcAdd
とcalcSubtract
は後述のindex.html
の javascript で使います
<-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
を使っていますが、もしanswer
がinput
やtextarea
などの入力フォームの場合はanswer.value = "";
とするのが正しいです
実行結果
テキスト欄に、5
と3
を入れてAdd
ボタンを押すと以下のように8
が表示されます(計算機2と同じ)
テキスト欄に、5
と3
を入れてSubtract
ボタンを押すと以下のように2
が表示されます(計算機2と同じ)
5
の代わりにa
などの数値変換できない文字を入れると、
Go で設定したfailed to convert value1 to int: strconv.Atoi: parsing "a": invalid syntax
のエラーがポップアップとして表示されます。
また、Console にGo return value
が表示されていることが分かります
ポップアップを閉じるとanswer
の中身が消えています
answer
が空になってからポップアップが表示されると思っていましたがよしとします- ここの実行順序は分かっていません
以上で、エラーハンドリングまで対応できるようになりました