はじめに
こんにちは。プロダクト開発部のWebエンジニアの荒川です。部内では最年少のエンジニアで、普段はSansanスマートフォン向けアプリのサーバサイドエンジニアをやっています。
今回は業務とは関係ない技術の話をします! 普段はC#を書いてますが、今回はGoを使って何かアプリを作りたいと思いました。 また以前からWebAssemblyの技術に興味を持っており、単純に面白そうなので試したいなと!
そこでGoとWebAssemblyを使って、シンプルなWebアプリ(電卓)を開発します。*1
ソースコードはこちらからどうぞ。 github.com
完成した電卓Webアプリ
足し算、引き算、掛け算、割り算、リセットができる程度のWebアプリです。 動作自体は普通にブラウザで動作しているわけですが、特筆すべき点として
JavaScriptを一行も書かずに、WebAssemblyで動いていること
が挙げられます。*2
GoとWebAssemblyについて
さて今回使う技術であるGoとWebAssemblyについて解説します。本題ではないので軽く説明します。
Go
シンプルな仕様かつ柔軟な構文を兼ね備えており、特に並行性に関してはチャネルという機構があったりと強力なプログラミング言語です。 ジェネリクスが無いなどのデメリットはあるものの、それを補完するような機能も備えており、大体のことはそつなくこなせます。
Go1.11からWebAssemblyへのビルドができるようになりました。
個人的には、これまで触ってきた言語の中でもトップクラスで好きな言語です。なんと言っても仕様のシンプルさが最高と言った感じです。
WebAssembly
JavaScriptの諸問題を補完する目的で開発された言語で、モダンなブラウザでは大抵動作します。
JavaScriptの代替ではなく共存を目指す思想のもと開発が進められています。*3。
Wasm
と略されるため、この記事でも同じように表記します。
今回はGoをビルドしてWebAssemblyを生成し、それをブラウザ上で動作をさせるために利用します。 WebAssemblyの仕組みについては、手前味噌ですが私が発表した資料を参考にしてください。
動作環境
動作環境は以下の通りです。
Windows10 Pro 17134
Go 1.11.2 windows/amd64
Google Chrome バージョン: 71.0.3578.98(Official Build) (64 ビット)
シェルはWindows Subsystem for Linuxを使用しています。
$ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 16.04.5 LTS Release: 16.04
これらがすべてインストール済みだと仮定して進めていきます。 またHTML/JavaScript/Goの文法の説明は行いません。
プロジェクトを作る
早速、プロジェクトを立ち上げていきましょう。
今後はmain.go
を編集することで開発を進めていきます。
$ mkdir wasm_calc $ cd wasm_calc $ touch main.go
まずはHello, Worldを!
皆さん大好き、Hello, Worldから始めましょう。 GoからWebAssemblyを触ってみるところまで作っていきます。*4
// main.go package main import "fmt" func main() { fmt.Println("Hello, WebAssembly!") }
Goで実装するコードはこれだけです。GoのHello,Worldと同じですね。 そしてこのコードをWasm向けにビルドします。
$ set GOOS=js $ set GOARCH=wasm $ go build -o main.wasm $ ls main.go main.wasm
GOOS
とGOARCH
を環境変数に設定してビルドするだけでWasmが生成されました! 非常に簡単ですね。
しかし、このままではブラウザ上で動かすことができません。
JavaScriptが単体では意味をなさないように、Wasmも単体では実行できません。
更にWasmはJavaScriptから呼び出す必要があります。そのための準備をindex.html
で行います。
<!-- 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を実行するシンプルな構成になっています。おそらくこれが現時点では最低限のコードです。
なお4行目でwasm_exec.js
を読み込んでいますが、これはGoからWasmを呼び出すために必要なモジュールです。
このままでは読み込めませんが、実はGoをインストールしたときに含まれているので、それをカレントディレクトリにコピーします。
$ cp /mnt/c/Go/misc/wasm/wasm_exec.js . $ ls index.html main.go main.wasm wasm_exec.js
これで準備完了です!
簡易的にサーバを立てるためにGoを使いましょう。とりあえずサーバを立てたいので、goexec
を使います。これはperl -e
やpython -c
とほぼ同じ挙動でシェルコマンドのように使えます。
$ go get -u github.com/shurcooL/goexec $ goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'
ブラウザからhttp://localhost:8080
にアクセスをしましょう。
ChromeからConsoleを開き、以下のように表示されれば成功です。
デザインをつくる
それでは早速ロジックを、と言いたいところですが、まずはベースとなるデザインをHTML+CSSで書きます。 ここでは特に解説はしませんが、Sansanカラーをイメージして仕上げました。*5
イベントを発火させる
見た目が完成したので、一番基本的な動作である、ボタンをクリックしたときに呼び出されるコールバック関数を書きます。 ここではボタンをクリックするとその文字列をコンソール出力する処理を実装します。完成品は以下です。
JavaScriptで書くとHTMLだけで完結します。
<button onClick="console.log(1);">1</button>
が、今回はそんなことはしません。Go+Wasmで実装すると以下のようなコードになります。
<!-- index.html --> <button onClick="print('1');">1</button>
// main.go package main import( "fmt" "syscall/js" ) func print(i []js.Value){ fmt.Println(i) } func registerCallbacks() { js.Global().Set("print", js.NewCallback(print)) } func main(){ c := make(chan struct{}, 0) registerCallbacks() <-c }
一気に新しいコードが登場したので、分割しながら解説します。
func main(){ c := make(chan struct{}, 0) registerCallbacks() <-c }
まずmain()
から見ていきましょう。make
にて新しいチャネルを生成しています。
registerCallbacks()
を呼び出してJavaScriptからGo(Wasm)のメソッドを呼び出すための設定を行います。
そして、チャネルを利用して待受けすることで、JavaScriptからGo(Wasm)を常時呼び出せるようになりました。*6
func registerCallbacks() { js.Global().Set("print", js.NewCallback(print)) }
次にregisterCallbacks()
ではGo(Wasm)とJavaScriptのメソッドの橋渡しをします。
今回の場合は、JavaScript側でprint
という名称でGoで書いたメソッドを使用できることを示します。
またjs
モジュールはsyscall/js
をimportすることで使用可能になります。詳しいAPIドキュメントはjs package - syscall/js - pkg.go.devにあります。
js.Global()
はJavaScriptのグローバルなオブジェクトwindow
を取得するメソッドです。そのオブジェクトに名称とコールバック関数を登録します。js.NewCallback()
はコールバック関数としてJavaScriptからGoのメソッドを呼び出せるようにする命令です。コールバック関数に与える引数は[]js.Value
を引数とし、返り値がvoid
型のメソッドである必要があります。
以上のような仕組みでGoからJavaScriptを呼びだすことができました。
func print(i []js.Value){ fmt.Println(i) }
最後にprint(i []js.Value)
ですが、JavaScriptの値の配列が引数として宣言されています。
これはJavaScript側から呼び出すときに指定する実引数がそのまま代入されます。
仮に引数なしでメソッドを呼び出せば、配列には0件のデータが入った状態になります。
DOM要素を操作する
Goで記述したWasmからクリックイベントを発火させることができました。 次はそのイベントを受け取って、DOM要素に反映させてみましょう。
まったく意味はないですが、1をクリックすると「WebAssembly!」と表示を変更させてみます。
これをJavaScriptで書くと、当然ですが以下のようになります。
<button onClick="document.getElementById('result').textContent = 'WebAssembly!'">1</button>
Go+wasmで実現すると、
<!-- index.html -- > <button onClick="manipulateDom()">1</button>
// main.go func manipulateDom(i []js.Value){ js.Global().Get("document").Call("getElementById", "result").Set("textContent", "WebAssembly!") } func registerCallbacks() { js.Global().Set("manipulateDom", js.NewCallback(manipulateDom)) }
GoからDOM操作をするためにDOM要素を取得し、その関数を呼び出しています。 つまりDOM操作はJavaScriptのAPIを間接的に呼ぶことで実現されるようです。 だとするとDOM操作をWebAssebmblyで書くメリットがないのでは...などと思ったりしましたが気にせず進めます。
電卓を実装する
これらを応用すれば、電卓アプリ程度は作れそうです。まずは四則演算のうち加算処理を目指します。
数値入力の実装
前述した「イベントの発火」と「DOM操作」を利用し、数値の入力をできるようにします。
<!-- index.html -- > <button onClick="inputNum('1')" >1</button> <button onClick="inputNum('2')" >2</button> <button onClick="inputNum('3')" >3</button>
// main.go // 現在入力中の数字 var inputtingNum = "" func inputNum(i []js.Value){ // 引数を文字列に変換して連結する inputtingNum += i[0].String() js.Global().Get("document").Call("getElementById", "result").Set("textContent", inputtingNum) } func registerCallbacks() { js.Global().Set("inputNum", js.NewCallback(inputNum)) }
+演算子の実装
+の演算子を実装します。Wasmの話は特にないので詳細は割愛します。
<!-- index.html --> <button onClick="doPlus()">+</button>
// main.go // 保存している値 var accumulator = "" func doPlus(i []js.Value){ accumulator += inputtingNum inputtingNum = "" js.Global().Get("document").Call("getElementById", "result").Set("textContent", "0") } func registerCallbacks() { js.Global().Set("inputNum", js.NewCallback(inputNum)) js.Global().Set("doPlus", js.NewCallback(doPlus)) }
等号(イコール)の実装
文字列をstrconv.Atoi
にて整数型に変換していますが、それ以外は今まで通りです。
<!-- index.html --> <button onClick="doEqual()">=</button>
// main.go func doEqual(i []js.Value){ // String -> int int1, _ := strconv.Atoi(inputtingNum) int2, _ := strconv.Atoi(accumulator) accumulator = "" js.Global().Get("document").Call("getElementById", "result").Set("textContent", int1 + int2) } func registerCallbacks() { js.Global().Set("inputNum", js.NewCallback(inputNum)) js.Global().Set("doPlus", js.NewCallback(doPlus)) js.Global().Set("doEqual", js.NewCallback(doEqual)) }
ここまで実装すると加算ができる様子が確認できます🎉
クリアボタンの実装
値をリセットする処理を実装します。これも特に問題ないかと思います。
<!-- index.html --> <button onClick="clearNum()">C</button>
// main.go func clearNum(i []js.Value){ inputtingNum = "" accumulator = "" js.Global().Get("document").Call("getElementById", "result").Set("textContent", "0") } func registerCallbacks() { js.Global().Set("inputNum", js.NewCallback(inputNum)) js.Global().Set("doPlus", js.NewCallback(doPlus)) js.Global().Set("doEqual", js.NewCallback(doEqual)) js.Global().Set("clearNum", js.NewCallback(clearNum)) }
他の演算子も実装する
ここまで理解できれば、他の演算子もあっという間に実装できます。 このタイミングで、足し算のメソッドを汎用化して他の演算子でも利用できるようにします。
<button onClick="doOperate('+')">+</button> <button onClick="doOperate('-')">-</button> <button onClick="doOperate('*')">×</button> <button onClick="doOperate('/')">÷</button>
// 選択中の演算子 var operator = None type Operator int const ( Plus Operator = iota Sub Mul Div None ) func doOperate(i []js.Value){ accumulator += inputtingNum inputtingNum = "" js.Global().Get("document").Call("getElementById", "result").Set("textContent", "0") // どの演算子が押されたかを保存する switch i[0].String() { case "+": operator = Plus case "-": operator = Sub case "*": operator = Mul case "/": operator = Div default: operator = None } }
上記に合わせて等号(イコール)ボタン押下の処理も修正します。*7
func doEqual(i []js.Value){ // String -> int int1, _ := strconv.Atoi(inputtingNum) int2, _ := strconv.Atoi(accumulator) accumulator = "" // 演算子ごとに計算する var result int switch operator{ case Plus: result = int1 + int2 case Sub: result = int2 - int1 case Mul: result = int1 * int2 case Div: result = int2 / int1 } js.Global().Get("document").Call("getElementById", "result").Set("textContent", result) }
ついに完成!
完成しました!冒頭でもお見せしましたがこんな感じで動作します。*8
JavaScriptを書かず(?)にWebアプリが開発できました! ちなみに例外処理を一切行っていないので、電卓としては使い物になりません。あしからず。*9
WebAssemblyを掘り下げる
正直ここまではJavaScriptでも簡単にできちゃいますよね。
そこでWebAssemblyを使うメリットについて、開発して感じたことを踏まえて、少し掘り下げてみます。 公式サイトによると、以下がWebAssemblyのメリットとして挙げられています。
- ファイルサイズが軽量
- パフォーマンスが速い
- サンドボックス環境で安全
- テキスト形式を持ち、可読性が高いこと
「ファイルサイズ」と「パフォーマンス」を今回のコードを基に考察してみます。
ファイルサイズについて
そもそもWasmを呼び出すまでに書かなければいけないコードがそれなりに存在しました。Goを呼び出すためのwasm_exec.wasm
もその一つです。
さらに、今回ビルドしたmain.wasm
のファイルサイズを見てみましょう。
バイナリではありますが、1.4MBとかなり巨大なファイルです。これは予想外でした。 一度読み込んだらキャッシュして再度読み込まない、先にダウンロードしておく、などアーキテクチャを工夫する必要がありそうです。
パフォーマンスについて
では、WebAssemblyの速度面どうなの? と気になりますよね。
今回のコードでもあったように、DOM操作は実質JavaScriptのAPIをラップする形式で呼ぶので、むしろ呼び出しオーバーヘッドがかかってしまいます。しかし、この記事によるとオーバーヘッドは相当小さくなりつつあるようです。
WebAssemblyでDOM操作が可能なAPIができる*10と状況は変わりそうですが、そもそもDOMを操作する程度のアプリではWebAssemblyをわざわざ選択する必要はないですね(当たり前ですが)。
数値計算やグラフィック描画などクリティカルな操作でWebAssemblyの真価は発揮されることが再認識できました。
まとめ
電卓アプリ自体は何も変哲も無い車輪の再開発ですが、新しい技術を使って開発すると新しい知見が得られますね。 今回はDOM操作が中心になってしまいましたが、大量に計算を行う必要のあるアプリではどういう結果になるんでしょうか? 次の機会に検証してみたいです。
そもそもWebAssemblyは発展途上の技術です。現在なお多くの議論が続けられており将来が期待できる技術の一つです。
また、WebAssemblyはGoからではなく、C、C++、Rustなどからも同様のことができます。他のプログラミング言語からもぜひ触ってみてください。
最後に
調子に乗って書きすぎました。技術ゴリゴリのエントリでしたが、いかがだったでしょうか。 機会があればテック記事をじゃんじゃん書いていこうと思いますので、よろしくお願いします!
おまけ
ここからはGo+WebAssemblyの開発過程において私の環境で発生したエラーとその対策を記載します。 日本語での情報がほとんど見つからかったので、参考になればと思います🙇
Wasmがブラウザで実行されない
Uncaught (in promise) CompileError: AsyncCompile: Wasm decoding failed: expected magic word 00 61 73 6d, found 4d 5a 90 00 @+0
Hello,Worldを試そうとmain.go
からmain.wasm
へのビルドは成功したのですが、ブラウザで立ち上げてみると上記のエラーが発生しました。
実は公式サイトでは、
$ GOOS=js GOARCH=wasm go build -o main.wasm
を実行するように記載があるのですが、WindowsのWSLでビルドして実行するとmagicが異なるというエラーが表示されます。
4d 5a
とあるので、どうやらPEフォーマットにビルドされてしまっているようです。つまりこのままでは、exe
として実行できる形式というわけです。Windowsの環境変数の問題ですが、要はGOOS=js GOARCH=wasm
が効いていないんですね😢
set
コマンドを使う、もしくは環境変数を正しく設定・読み込みすることで解決できます。
こういった場合は拡張子に騙されず、ファイルが本当にWasmにビルドされているかを確認しましょう。
ビルドができない
main.go:4:2: build constraints exclude all Go files in C:\Go\src\syscall\js
GOOS
とGOARCH
を設定しない状態でsyscall/js
をインポートすると発生します。
私はGolandで開発していたのでconfigureにて以下のように設定すると直りました。
参考にしたサイト🎉
Go+WebAssembly
https://youtu.be/4kBvvk2Bzis WebAssembly · golang/go Wiki · GitHub Go WebAssembly Tutorial - Building a Calculator Tutorial | TutorialEdge.net
Go
go channel - Google 検索 js package - syscall/js - pkg.go.dev
WebAssembly
その他
button要素のcssをリセットしたい - Qiita https://blog.mach3.jp/2011/01/25/css-button-on-active.html
*1:電卓にした意味は特にない
*2:厳密にはscriptタグからフックしてるから違いますが
*3:https://developer.mozilla.org/ja/docs/WebAssembly/Concepts#WebAssembly_%E3%81%AF%E3%81%A9%E3%81%AE%E3%82%88%E3%81%86%E3%81%AB%E3%82%A6%E3%82%A7%E3%83%96%E3%83%97%E3%83%A9%E3%83%83%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E3%81%AB%E9%81%A9%E5%90%88%E3%81%99%E3%82%8B%E3%81%AE%E3%81%8B
*4:本章はほとんど公式サイトの例のままですがWindows用に最適化している箇所が数ヶ所あります
*5:色味がちょっと違いますが
*6:メソッドが常に非同期に実行されるので注意
*7:除算はint型なので小数点以下は切り捨てられます
*8:四則演算を実装したけど、GIFが重くてデモでは加算のみになってしまった
*9:ゼロ除算、オーバーフロー、0.1問題...エンジニアリングの難しさはむしろこの後ですね
*10:https://github.com/WebAssembly/proposals/issues/16 にて議論はされています。