はじめに
自作言語コンパイラがウェブブラウザで動くと面白いという発想でえいやっとした話です。
自作言語コンパイラの概要
既存の言語への理解を深めるためにoriglangという仮題で自作言語のコンパイラを開発しています。詳細は GitHubレポジトリをご覧ください。
ウェブブラウザで動かすための技術選定
ウェブブラウザのサポート対象はモダンブラウザ (Internet Explorerや古いSafariを除いたブラウザの総称) としました。理由としては古いブラウザをサポートする特別の理由がないからです。
そして、モダンブラウザのみに限定するならJavaScript (やasm.js) よりWebAssemblyだろうという結論に至りました。その理由として、次の3点が挙げられます。
- WebAssemblyは静的型付け
- JavaScriptは人間に読めるのでバンドルサイズが大きい
- WebAssemblyのほうが保証されている性質が多いので、よりよい最適化が期待できる
この記事を書くにあたって事後調査をしたらasm.jsはコーナーケースで実装の差がある という記事を発見しました。なのでasm.jsを使わなかったのは正解なんだと思います。
クレートの分割
コンパイラは1つのクレートの中にあるサブモジュール群から構成されていましたが、それらのサブモジュール群をlib
クレートとしてそれぞれ別々のクレートに分割しました。理由は2つあります:
- Rustのコンパイラを見習った
-
#[cfg]
で実装を切り替えるのが手間だと思っていた
こうすることで原因が切り分けやすくなるのでやって良かったと思います。実際は次のように分割しました。
-
origlang-ast
- AST (抽象構文木) の構造のモデリング -
origlang-cli
- OS上におけるツールチェインのフロントエンド -
origlang-compiler
- レキサー、パーサー、型チェック -
origlang-runtime
- スコープや中間コードなど、実行時の情報の格納 -
origlang-platform
- プラットフォーム事情のカプセル化 -
origlang-typesystem
- 型システムのモデル
次にuse
がどこを指しているかわからないと言ってくるので、適宜サブモジュールのCargo.toml
に依存を追加しながらstruct
や関数の可視性を調整します。
ルートディレクトリでcargo c
を実行し、コンパイルが通ればOKです。
WebAssembly用サブモジュールの追加
次にWebAssembly用のサブモジュールを追加します。ここではoriglang-interop
と命名しました。
Cargo.toml
を開いて、次のスニペットを追記します1:
[lib]
crate-type = ["cdylib"]
[dependencies]
# 必要に応じてバージョンを変えてください
wasm-bindgen = "0.2.74"
lib.rs
にかかれているであろうコードを全て削除します。Hello Worldを見ながら適切なシグネチャでJavaScript側に後で作る関数を宣言します。宣言するためにはwasm-bindgen
の#[wasm_bindgen]
というproc-macro2を使います。これをextern
3節につけるとJavaScript側で宣言されている関数を宣言し、pub fn
4につけるとWebAssembly側で宣言する関数を宣言します。
サブモジュール側で適切にコンパイラをインスタンス化してコンパイルを開始する関数を呼びます。WebAssemblyは標準出力を持たないので、もし式の結果を出力することがあれば都度JavaScriptに委譲します。実際の実装はこうなりました:
これをビルドするためにwasm-pack
というツールをインストールする必要があります。
フロントエンドの作成
簡単に5フロントエンドを作っておきます。extern
節につけた#[wasm_bindgen]
と同じ名前、互換性のあるシグネチャを受理できる関数を作っておきましょう。また、それよりも前の<script>
でwasm-script
から出力されたoriglang_interop.js
をtype="module"
として、origlang_interop_bg.wasm
をtype="application/wasm"
としてそれぞれ読み込んでおきましょう。
以下に例を示します。
ローカルで立ち上げるHTTPサーバー用のサブモジュールの追加
最近のブラウザはCORSの制限が厳しいので、それを避けるために手元で動かすことを想定したHTTPサーバーを立ち上げるサブモジュールを新しく作ります。ここではoriglang-interop-frontend-webserver
と命名しました。使うクレートは何でもいいですが、actix-web
とactix-files
を使いました。
以下に実際の実装を示します。
一通り立ち上がることが確認できたら、次へ進みます。
GitHub Actionsの設定
wasm-packのインストール
wasm-pack
はHosted runnerに入っていないので、自分でインストールする必要があります。とりあえず一行で済む
cargo install wasm-pack
で入れました。
cargo install wasm-pack が遅い
しかし、cargo install
は何も設定を渡さないとリリースモードでソースからコンパイルを行います。それは許容できなかったので--debug
をつけたのですがあまり有意な縮み方にはなりませんでした。
そこで、GitHubのReleasesからすでにビルドされたバイナリを持ってきて動かすことにしました。*.tar.xz
という名前から、適切に解凍してやる必要があると推測できます6。また、今回必要なのは実行ファイルのみなので実行ファイルのみを解凍します。
wasm-pack が見つからない?
最初はwasm-packが見つからないと言われました。実行ファイルをフルパスで指定しなかったときに見つからなかったと言われる原因の99.99%はその実行ファイル (を含んでいるディレクトリ) がPATHに通ってないからです。mv wasm-pack /usr/local/bin
として解決しました。
WindowsだとPowerShellと解釈される
origlangのworkflowでは過去の失敗からUbuntuとWindowsの両方においてコンパイルが通るかチェックしていますが、WebAssemblyをビルドする変更を入れるとCIが失敗するようになってしまいました。これは好ましくありません7。
その筆頭としてWindowsのrunnerが直書きのスクリプトをPowerShellとして解釈し、失敗するという問題がありました。これはshell: bash
として明示的にインタプリタを指定することで解消しました。
Windowsのbashには/usr/local/bin
がない
なぜかははっきりしていませんが、Windowsのbashは/usr/local/bin
がないようです。仕方がないのでもし移動に失敗した場合は/usr/bin
への移動を試すことにしました。
WindowsのbashはLinux向けにコンパイルされたバイナリを解釈できない
当たり前といえば当たり前かも知れませんが、WindowsのbashはLinux向けにコンパイルされたバイナリを解釈できないようです。そのため、適切なターゲットトリプルをworkflowのmatrixに持たせ、ダウンロードするURLを変更するようにしました。
GitHub Actionsのworkflowにおけるmatrixがデカルト積を生成する
workflowのYAMLにmatrix
という名前でキーを指定すると、workflowに渡る値の組み合わせを切り替えられる仕組みがあります (GitHub)。これ自体はいい機能です。デフォルトで設定の値のデカルト積 (Wikipedia 日本語版の記事) を生成し、その組み合わせの全てを試す実装になっています。今回のワークフローにおいては、これは望ましい実装ではありません。なぜならWindowsがLinux向けにコンパイルされたバイナリを解釈しようとしたり、その逆が起きたりするためです。
各設定項目をまとめたオブジェクトの配列をmatrix
に渡すと正しく解釈されず、workflowの実行自体がスキップされてしまいました。幸いにも、matrix
の代わりにinclude
を指定することでデカルト積を生成せずに各設定の組み合わせを追加することができたのでこれを採用しました。
実行ファイルに拡張子が付いていることがある
wasm-packに含まれている実行ファイルは、Linux向けにコンパイルされたものだと拡張子が付いていません。一方で、Windows向けにコンパイルされたものは拡張子が付いています。これは文化圏の差ですが、すっかり忘れていました。
実行ファイルの名前もworkflowのmatrixに持たせることで解消しました。
NGシーン: build.rs
の内部から wasm-pack
当初はoriglang-interop
の build.rs
からwasm-pack
を実行すればCIでも手元でも同じような実装を繰り返さなくて済むと考えていたことがありました。しかし、これはうまく行きませんでした。なぜかはわかっていませんが、wasm-pack
の出力から推察される理由として、wasm-pack
自体がcargo build
に相当することをやっていることが挙げられます。cargo build
自体はビルドに対する専有ロックを取得しますが、取得できなかった場合は取得できるまで実行がブロックされます。
それによって、次のようなデッドロックが生まれてしまったと推察できます。
パッケージ | 先に進むための条件 |
---|---|
origlang |
origlang-interop のビルドが終わること |
origlang-interop |
wasm-pack によるビルドが終わること |
(wasm-pack ) |
origlang が抱えているビルドのロックが開放されること |
三すくみです。これはどう頑張っても無限ループです。
build.rs
によるアプローチを諦めて、build.sh
からwasm-pack
を実行して都度ビルドする運用に切り替えたら解消しました。
おわりに
オチは特にありません。
-
もし忘れても
wasm-pack
がエラーで指示してくるので忘れても大丈夫です ↩ -
アノテーションみたいなものです。 ↩
-
ABIを省略すると
extern "C"
と同様の意味になります。今回は私が「JavaScriptなのにextern "C"
は実装の都合なのでextern
を選択する」という選択をしたので、このまま省略しておきます ↩ -
pub
でない場合、サポートされていないというエラーが出てコンパイルできません ↩ -
必要最低限のインターフェースしか持たない実装 ↩
-
と言っても、私がやる必要があるのは
tar xf hoge.tar.xz
だけです ↩ -
2つ理由があります。1つ目はビルドが失敗するたびに Notification に通知されて大変愉快なことになるということです。2つ目はそしてレポジトリのステータスとして部分的であってもビルドが失敗しているのは見栄えが悪いと私が考えていることです。 ↩