ブラウザ上で`go run ./main.go`するために(経過報告)
この記事はWebAssembly Advent Calendar 2023 25日目の記事です.
GoWasmへのビルドをサポートしているため,GOOS
とGOARCH
を次のように指定することでWasmへビルドすることができます.
GOOS=js GOARCH=wasm go build -o main.wasm main.go
Goをインストールするとき,パッケージマネージャやインストーラ以外に「ソースコードからのビルド」という選択肢があります.
Goの面白いところとして,「GoはGoでビルドできる」というのがあります.
Goのリポジトリをクローンし,src
直下の make.bash
を実行することで,bin
直下にgo
コマンドが作られます.
git clone https://go.googlesource.com/go goroot
cd goroot/src
./make.bash
GoをGoでビルドできるなら,Wasmにもビルドできるのでは?
「GoをビルドするにはGoが必要で,GoはWasmへのビルドをサポートしている」ということは,Wasm版のGoコマンドをビルドできるのではないか? と思いませんか?
実際にGoのビルド方法を紹介しているページ「Installing Go from source」にもコンパイラがサポートしている命令セットとしてWasmが載っていることがわかります.
The Go compilers support the following instruction sets:
...
wasm
WebAssembly.
go build
同様にmake.bash
も実行時にGOOS
とGOARCH
を指定することで,特定のターゲット向けにビルドすることができます.
GOOS=js GOARCH=wasm ./make.bash
この時, bin/js_wasm
直下にWasmバイナリのgo
コマンドが生成されます.
Goが提供しているJSのグルーコードを利用してビルドされたWasmを動かしてみます.
cd ../bin/js_wasm
cp $(go env GOROOT)/misc/wasm/wasm_exec.js .
次のような go.js
を書くことで,go help run
の実行結果を得ることができます.
import "./wasm_exec.js";
const go = new Go();
const { instance } = await WebAssembly.instantiateStreaming(
fetch(new URL("go", import.meta.url)),
go.importObject,
);
go.argv = ["go", "help", "run"];
go.run(instance);
(今回は実行にdeno
を利用しています)
deno run --allow-read ./go.js
このようにgo help run
の実行結果を得ることができました.
なんだか動きそうな雰囲気があります.
go run ./main.go
を実行できるのか?
go help run
の代わりに, go run ./main.go
を実行してみましょう.
-go.argv = ["go", "help", "run"];
+go.argv = ["go", "run", "./main.go"];
go.js
の引数を書き換えて実行すると次のようなエラーが出ます.
go: cannot find GOROOT directory: /your/repository/clone/path/goroot
exit code: 2
GOROOT
のディレクトリが見つからないと旨のエラーが出ていることがわかります.
go.js
に次のように書くことでGOROOT
のパスを変更することができますが,エラーの内容はディレクトリのパスが変わるだけで変わりはありません.
go.env = {
GOROOT: "/usr/local/go",
};
go run ./main.go
を実行するために (1)
そもそも,ブラウザ上でファイルへのアクセスをどのように行うのでしょうか?
そのヒントを知るために wasm_exec.js
の中身を見てみると,次のように実装されています.
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
globalThis.fs = {
// ...
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()); },
};
何も実装されていませんね.
実際に実装されているのはwrite
くらいです.
ファイルシステムを実装する
これらのファイルシステムの関数を実装してあげることで,go run ./main.go
を動かせるかもしれません.
これらの関数がどのように呼ばれているのか知るために,次のデバッグコードを仕込みます[1].
これで呼ばれている関数とパラメータの内容が分かるようになるので,必要な関数を実装することができるようになります.
go.js
を実行し,実際に呼ばれている関数を実装していくと次のようになります.
今回必要なfs
関数を実装するために,次のようなブラウザ上で扱える仮想ファイルシステムを用意しました.
src
に変更を加える
ファイルシステム関数を良い感じに実装していくと,fs.read
が呼ばれるようになるのですが,GOOS=js
にはio.EOF
が実装されていないという問題があります.
グルーコードからも分かるように,ファイルシステムにアクセスするという前提がない無いため,仕方のない部分でもありますが,それではファイルの内容を読み出すことができません.
Goのos
パッケージには func ReadFile(name string) ([]byte, error)
という便利な関数が用意されているので,ファイルの中身を読み出すのは非常に簡単ですが,より古典的な方法でファイルの中身を読み出すには次のように書く必要があります.
- ファイルを開く(
open
) -
io.EOF
になるまでファイルを読む(read
) - ファイルを閉じる(
close
)
package main
import (
"bytes"
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("example.txt") // 1. ファイルを開く
if err != nil {
panic(err)
}
defer file.Close() // 3. ファイルを閉じる
bin := make([]byte, 32)
var buf bytes.Buffer
for {
n, err := file.Read(bin) // 2. `io.EOF`になるまでファイルを読む
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
buf.Write(bin[:n])
}
fmt.Println(buf.String())
}
GOOS=js
で実際にここの実装を行なっているのは src/syscall/fs_js.go
とsrc/syscall/tables_js.go
の2つのファイルです.
fs_js.go
の中ではfsCall("read", ...)
として,JSのコードを呼び出しています.
func Read(fd int, b []byte) (int, error) {
f, err := fdToFile(fd)
if err != nil {
return 0, err
}
if f.seeked {
n, err := Pread(fd, b, f.pos)
f.pos += int64(n)
return n, err
}
buf := uint8Array.New(len(b))
n, err := fsCall("read", fd, buf, 0, len(b), nil)
if err != nil {
return 0, err
}
js.CopyBytesToGo(b, buf)
n2 := n.Int()
f.pos += int64(n2)
return n2, err
}
このfsCall
の中でエラーのマッピングを行なっているのがtables_js.go
になるのですが,ここには io.EOF
のマッピングが存在しません.
// TODO: Auto-generate some day. (Hard-coded in binaries so not likely to change.)
const (
// native_client/src/trusted/service_runtime/include/sys/errno.h
// The errors are mainly copied from Linux.
EPERM Errno = 1 /* Operation not permitted */
ENOENT Errno = 2 /* No such file or directory */
ESRCH Errno = 3 /* No such process */
EINTR Errno = 4 /* Interrupted system call */
EIO Errno = 5 /* I/O error */
// ...
)
// ...
// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e Errno) error {
switch e {
case 0:
return nil
case EAGAIN:
return errEAGAIN
case EINVAL:
return errEINVAL
case ENOENT:
return errENOENT
}
return e
}
// ...
var errnoByCode = map[string]Errno{
"EPERM": EPERM,
"ENOENT": ENOENT,
"ESRCH": ESRCH,
"EINTR": EINTR,
"EIO": EIO,
// ...
}
そのため,tables_js.go
内にio.EOF
のマッピング定義を追加し,go.js
から io.EOF
を伝播できるように書き換える必要があります.
// TODO: Auto-generate some day. (Hard-coded in binaries so not likely to change.)
const (
// native_client/src/trusted/service_runtime/include/sys/errno.h
// The errors are mainly copied from Linux.
EPERM Errno = 1 /* Operation not permitted */
ENOENT Errno = 2 /* No such file or directory */
ESRCH Errno = 3 /* No such process */
EINTR Errno = 4 /* Interrupted system call */
EIO Errno = 5 /* I/O error */
// ...
+ EEOF Errno = 100_000 // 仮置き
)
// ...
// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e Errno) error {
switch e {
case 0:
return nil
case EAGAIN:
return errEAGAIN
case EINVAL:
return errEINVAL
case ENOENT:
return errENOENT
+ case EEOF:
+ return io.EOF
}
return e
}
// ...
var errnoByCode = map[string]Errno{
"EPERM": EPERM,
"ENOENT": ENOENT,
"ESRCH": ESRCH,
"EINTR": EINTR,
"EIO": EIO,
// ...
+ "EEOF": EEOF,
}
func Read(fd int, b []byte) (int, error) {
f, err := fdToFile(fd)
if err != nil {
return 0, err
}
if f.seeked {
n, err := Pread(fd, b, f.pos)
f.pos += int64(n)
return n, err
}
buf := uint8Array.New(len(b))
n, err := fsCall("read", fd, buf, 0, len(b), nil)
- if err != nil {
- return 0, err
- }
js.CopyBytesToGo(b, buf)
n2 := n.Int()
f.pos += int64(n2)
return n2, err
}
この変更を加えて再度ビルドし,go.js
にもEOF
のエラー定義を追加すると,go run -h
が実行出来るようになります.
go run ./main.go
を実行するために (2)
Goをブラウザ上で動かそうと思い至ってから現状できているのがここまでになります.
ファイルシステムを実装していくとある程度動きそうな雰囲気はあるのですが,ファイルシステムを実装する以外にもGoのコードに手を加えないと動かなそうな感じがしています.
デバッグしている感じ,おそらくGoのコンパイラのキャッシュ機構なのですが,そこを良い感じにWasmでも機能するようにしてあげる必要がありそうです.
ひとまず今回はブラウザ上でgo run ./main.go
をできる可能性がありそうだというところまで分かりました.
また経過報告を出せればと思います.
-
コードを読み進めることで呼ばれている関数を知る方法もありますが,取っ掛かりがなかったのでこのような手段をとっています. ↩︎
Discussion
はじめまして。以前自分も似たようなことをして、ブラウザ上でのコンパイルと実行はできました。ご参考になれば幸いです
初めまして!
この情報助かります.
ありがとうございます〜