先日さくらのクラウド向けCLI Usacloud
をブラウザ上で動かせるChrome拡張UsaCon
をリリースしました。
このUsaConを作る際にいろいろ調べたことを備忘をかねて書いておきます。
(もし間違えている箇所などあったらご指摘いただけると嬉しいです。)
今回はfmt.Println()
でHello WorldするだけのWASMを題材に、fmt.Println()
からWebブラウザで出力されるまでのコードを追ってみます。
今回の環境
- Go: 1.15.6
- ブラウザ(動作確認用): Chrome 87
今回の題材アプリ
Hello Worldするだけの単純なアプリをWASMをしてビルドして実行します。
コードは↓↓だけの非常に単純なものです。
package main import "fmt" func main() { fmt.Println("hello wasm!") }
あとはこれをビルドしてブラウザから実行可能にします。
実行にはGoに付属しているwasm_exec.html
とwasm_exec.js
を利用します。
この辺は以下のページに手順が書いてますので気になる方は参照してください。
WebAssembly · golang/go Wiki · GitHub
ビルドして実行可能にするまでの詳細はこちら(読み飛ばし可)
GOOSとGOARCHをセットしてビルド
$ GOOS=js GOARCH=wasm go build -o test.wasm main.go
Goに付属しているwasm_exec.html
とwasm_exec.js
をコピー
$ cp `go env GOPATH`/misc/wasm/wasm_exec.html ./ $ cp `go env GOPATH`/misc/wasm/wasm_exec.js ./
適当なWebサーバを起動
ここでは前述のWikiに従いgoexec
を利用する形にしています。
既存のWebサーバを使う形やnpmでserve
を使ったりする形でもOKです。
# install goexec $ go get -u github.com/shurcooL/goexec
$ goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'
ブラウザで開く
$ open http://localhost:8080/wasm_exec.html
画面上のRun
ボタンをクリックするとブラウザのコンソールにhello wasm!
を表示されるはずです。
fmt.Println()の中を追う
早速fmt.Println()
の中身を追ってみます。
func Println(a ...interface{}) (n int, err error) { return Fprintln(os.Stdout, a...) } func Fprintln(w io.Writer, a ...interface{}) (n int, err error) { p := newPrinter() p.doPrintln(a) n, err = w.Write(p.buf) p.free() return }
os.Stdout
(標準出力)のWrite()
を呼んでますね。
ではWASMにした際の標準出力はどういう扱いになっているのでしょうか? まずこの辺をみていきます。
GOOS=js GOARCH=wasm
の場合のos.Stdout
os.Stdout
の定義
os.Stdout
は*os.File
型で、以下のように定義されています。
var ( Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr") )
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L62-L66
syscall.Stdout
は以下のとおりです。
const ( Stdin = 0 Stdout = 1 Stderr = 2 )
別段変わったところはないですね。
NewFile()
で何か変わったことをしているのでしょうか?
こちらも追ってみます。
os.NewFile()
の実装の中身
// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris func NewFile(fd uintptr, name string) *File { kind := kindNewFile if nb, err := unix.IsNonblock(int(fd)); err == nil && nb { kind = kindNonBlock } return newFile(fd, name, kind) } func newFile(fd uintptr, name string, kind newFileKind) *File { fdi := int(fd) // ...中略... f := &File{&file{ pfd: poll.FD{ Sysfd: fdi, IsStream: true, ZeroReadIsEOF: true, }, name: name, stdoutOrErr: fdi == 1 || fdi == 2, }} // ...中略... // poll.FDのInitを呼んできる if err := f.pfd.Init("file", pollable); err != nil { // ...中略... } }
ソース: https://github.com/golang/go/blob/go1.15.6/src/os/file_unix.go
こちらはビルドタグ(ソース先頭のやつ)が+build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris
となっていますので、WASM用に特別なことをしてるわけじゃないですね。
もう少し中の方ではWASM向けに分岐しているのですが、今回は省略します。
次は*os.File
のWrite()
がどう実装されているか見ていきます。
*os.File
のWrite()
の実装
*os.File
のWrite()
go/src/os/file.go
で実装されています。
func (f *File) Write(b []byte) (n int, err error) { // ...中略... n, e := f.write(b) // ...中略... }
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L173
write()
が呼ばれてるのでそちらを見てみます。
*os.File
のwrite()
今度はgo/src/os/file_posix.go
func (f *File) write(b []byte) (n int, err error) { // poll.FDのWriteを呼んでいる n, err = f.pfd.Write(b) // ...中略... }
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_posix.go#L48
f.pfd.Write()
が呼ばれています。
*os.File
のpfd
フィールドはos.NewFile()
の中でこっそり登場していましたね。
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_unix.go#L114-L118
次にpoll.FD
のWrite()
を見てみます。
poll.FD
のWrite()
// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris // Write implements io.Writer. func (fd *FD) Write(p []byte) (int, error) { // ...中略... // syscall.Writeを呼んでいる n, err := ignoringEINTR(func() (int, error) { return syscall.Write(fd.Sysfd, p[nn:max]) }) // ...中略...
syscall.Write()
が呼ばれています。
GOOS=js GOARCH=wasm
の場合のsyscall.Write()
go/src/syscall/fs_js.go
で実装されています。
func Write(fd int, b []byte) (int, error) { // ...中略... // jsに値を渡す js.CopyBytesToJS(buf, b) // fsCall?? n, err := fsCall("write", fd, buf, 0, len(b), nil) // ...中略... }
ここでようやくWASMっぽい部分が出てきました。
js.CopyBytesToJS()
でバッファに値を入れてfsCall("write", ...)
してます。
ちょっと寄り道: GoでビルドしたWASMとJavaScriptの値のやりとりはどうやってるの?
WASM/JavaScript両方からアクセスできるメモリ領域を介して値のやりとりをしています。
参考: WebAssembly.Memory
GoでビルドしたWASMの場合は、mem
という名前でこのMemoryがエクスポートされています。
先ほど出てきたjs.CopyBytesToJS()
はこの共有メモリ領域への書き込みを行なっています。
jsからも読み書きできるところにデータを書いておいてfsCall("write", ...)
を呼んでいるということですね。
次にfsCall("write", ...)
を追ってみます。
fsCall("write", ...)
の実装
fsCall
は以下のように実装されています。
func fsCall(name string, args ...interface{}) (js.Value, error) { // ...中略... // jsFSのCallを呼んでる jsFS.Call(name, append(args, f)...) // ...中略... }
jsFS
はこちらです。
var jsFS = js.Global().Get("fs")
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L20
syscall.js
パッケージのGlobal()
経由でGet("fs")
してます。
syscal/js
syscall.js
でGlobal()
は以下のように定義されています。
func Global() Value { return valueGlobal } var ( // valueGlobalは106行目で定義されている valueGlobal = predefValue(5, typeFlagObject)
syscall/js
のValue
型ですね。Value
のGet()
は以下のように実装されています。
func (v Value) Get(p string) Value { // ...中略... r := makeValue(valueGet(v.ref, p)) // ...中略... } func valueGet(v ref, p string) ref
最終的にvalueGet()
を呼んでいます。
funcのボディがないですが、これはasmで実装されています。
TEXT ·valueGet(SB), NOSPLIT, $0 CallImport RET
CallImport
というやつがいますね。これは最終的にWebAssemblyにインポートしているfuncを呼ぶようになっているみたいです。
(この辺はまだちゃんと追えていません…)
インポートしてるfuncというのは、JavaScriptでWebAssemblyのインスタンスを作る時に使うWebAssembly.instantiateStreaming()
の引数で渡すやつですね。
参考: MDN: WebAssembly.instantiateStreaming()
GoでビルドしたWASMの場合は、wasm_exec.js
の中で定義されているものを利用することになります。
global.Go = class { constructor() { // ...中略... this.importObject = { go: { // func valueGet(v ref, p string) ref "syscall/js.valueGet": (sp) => { const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); sp = this._inst.exports.getsp(); storeValue(sp + 32, result); }, // func copyBytesToJS(dst ref, src []byte) (int, bool) "syscall/js.copyBytesToJS": (sp) => { const dst = loadValue(sp + 8); const src = loadSlice(sp + 16); if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { this.mem.setUint8(sp + 48, 0); return; } const toCopy = src.subarray(0, dst.length); dst.set(toCopy); setInt64(sp + 40, toCopy.length); this.mem.setUint8(sp + 48, 1); }, } }; } // ...中略...
先ほど出てきたcopyBytesToJS
もここで定義されていますね。
ということで最終的にjs上で定義されたfuncを呼ぶということがわかりました。
fsCall("write", ...)
の実装(再び)
ということで改めてfsCall("write", ...)
の実装を見てみます。
func fsCall(name string, args ...interface{}) (js.Value, error) { // ...中略... // jsFS.Call("write", ...)という引数になっている jsFS.Call(name, append(args, f)...) // ...中略... } var jsFS = js.Global().Get("fs")
js.Global().Get("fs")
はjs上で定義されたvalueGet()
を呼んでいました。 ここでjs上のglobal.js
を取得しています。取得したglobal.js
に対してCall("write", ...)
してます。
これによりjs上のglobal.fs
のwrite()
を呼ぶようになっています。
ゴール: js上でconsole.log()
を呼ぶ部分
global.fs
には何が入ってる?
ブラウザ上で実行している場合は前述のwasm_exec.js
により以下のように定義されています。
if (!global.fs) { let outputBuf = ""; global.fs = { constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused writeSync(fd, buf) { outputBuf += decoder.decode(buf); const nl = outputBuf.lastIndexOf("\n"); if (nl != -1) { console.log(outputBuf.substr(0, nl)); outputBuf = outputBuf.substr(nl + 1); } return buf.length; }, write(fd, buf, offset, length, position, callback) { if (offset !== 0 || length !== buf.length || position !== null) { callback(enosys()); return; } const n = this.writeSync(fd, buf); callback(null, n); }, chmod(path, mode, callback) { callback(enosys()); }, chown(path, uid, gid, callback) { callback(enosys()); }, close(fd, callback) { callback(enosys()); }, fchmod(fd, mode, callback) { callback(enosys()); }, fchown(fd, uid, gid, callback) { callback(enosys()); }, fstat(fd, callback) { callback(enosys()); }, fsync(fd, callback) { callback(null); }, ftruncate(fd, length, callback) { callback(enosys()); }, lchown(path, uid, gid, callback) { callback(enosys()); }, link(path, link, callback) { callback(enosys()); }, lstat(path, callback) { callback(enosys()); }, mkdir(path, perm, callback) { callback(enosys()); }, open(path, flags, mode, callback) { callback(enosys()); }, read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, readdir(path, callback) { callback(enosys()); }, readlink(path, callback) { callback(enosys()); }, rename(from, to, callback) { callback(enosys()); }, rmdir(path, callback) { callback(enosys()); }, stat(path, callback) { callback(enosys()); }, symlink(path, link, callback) { callback(enosys()); }, truncate(path, length, callback) { callback(enosys()); }, unlink(path, callback) { callback(enosys()); }, utimes(path, atime, mtime, callback) { callback(enosys()); }, }; }
global.fs
が未定義だったら最低限の実装で埋めています。
write()
は以下の部分です。
writeSync(fd, buf) { outputBuf += decoder.decode(buf); const nl = outputBuf.lastIndexOf("\n"); if (nl != -1) { console.log(outputBuf.substr(0, nl)); outputBuf = outputBuf.substr(nl + 1); } return buf.length; }, write(fd, buf, offset, length, position, callback) { if (offset !== 0 || length !== buf.length || position !== null) { callback(enosys()); return; } const n = this.writeSync(fd, buf); callback(null, n); },
write()
はwriteSync()
を呼び、最終的にconsole.log()
されていますね。
長かったですがこれでGoのfmt.Println()
からコンソール出力されるところまでたどり着きました。
まとめ
無理やりまとめると
fmt.Println()
はsyscall.Write()
を呼ぶsyscall
(のGOOS=js GOARCH=wasm
向けの実装)やsyscall/js
はjs側と協調して動作するようになっている- js側の基本的な実装は
wasm_exec.js
で行われている wasm_exec.js
でのglobal.fs
の実装は最低限しかない
みたいな感じでしょうか。
ひとまずGoから標準出力に出力することでブラウザのコンソールに出力できることはわかりました。
でも上記の通りglobal.fs
は最低限の実装しかないです。CLIの場合は標準入力から読んだり、その他のファイルを開いたりしたいことがあるかと思います。
CLIを動かしたい場合は自分で実装しないといけない部分が結構ありそうです。
次回以降の記事でUsaConでこの辺の問題をどう解決していったかを取り上げたいと思います。
今回は以上です。