ruby.wasmはじめの一歩: Ruby Next Playground構築の舞台裏(翻訳)
はじめに
当初はHTMLやCSSのレンダラーに過ぎなかったWebブラウザは、やがて洗練された実行環境へと進化し、かつてのシンクライアントから高機能なクライアントに、そしてそれ以上のものになりました(サーバーなしで動くのにクライアントと名乗るのはどうなんでしょう?)。今やWebAssemblyサポートのおかげで、(ほぼ)どんな言語で書いたプログラムでもWebページ上で動かせるようになりました。もちろんRubyも例外ではありません。Rubyをオンラインに乗せたときの私の第一印象が鮮烈なうちに記事にしたいと思います。
WebAssembly(Wasm)は2017年にリリースされて以来、今や97%のデバイスでサポートされています(caniuse.com)。ただし、Wasm移植性の本領を発揮するには、「実行環境(ブラウザなど)」と「プログラミング言語とランタイム」の両面でのサポートが必要です。主要なWebブラウザは既にWasm対応済みですが、Ruby言語はどうでしょうか?
Ruby(厳密にはCRubyまたはMRI)はC言語で書かれているので、emscripten経由でいつでも(といっても簡単ではありませんが)RubyコードをWasmモジュールにコンパイルできます(emrubyのデモをご覧ください)。
EmscriptenはJavaScriptランタイムを対象としていますが、WebAssemblyそのものはWebやNode.jsに限定されません。たとえば、Wasmモジュールをサーバーレス環境で利用することも、(ユーザーによるスクリプティング目的で)他のプログラムに埋め込むことも可能です。Wasmの移植性を向上させるために、2019年にWASI(WebAssembly System Interface)という新たな標準が導入されました。
WASIで用いるソースコードは、相互運用性のために低レベル(システム)コールを利用する形で準拠しなければなりません。そのため、ソースコードがWASIに対応する必要が生じます。そして2022年のRubyではまさにこれが行われ、(Yuta Saitoさんの素晴らしい取り組みのおかげで)MRIがWASI互換になったのです。WASI互換はRuby 3.2.0リリースの目玉機能のひとつでしたが、当時の私にはさっぱり理解できませんでした😄。そして1年後にこの機能を詳しく調べているうちにruby.wasmプロジェクトを発見したのです。
本記事では、私がruby.wasmを用いて初めてRubyプログラム(後述のRuby Next)をWasmにパッケージングし、オンラインで利用可能にしたときの体験を共有したいと思います。技術的詳細に進む前に、ruby.wasmでどんなことができるかを見てみましょう。
Ruby Next Playgroundがブラウザで動いている様子
🔗 ruby.wasmについて
ruby.wasmプロジェクトが誕生した目的は、RubyコードをWasm-WASIモジュールとしてパックできるようにすることです。すぐ使えるWasmモジュールも同梱されているので、自分で構築せずにブラウザでRubyを試せます。たとえば以下のスニペット例を見てみましょう。
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.4.1/dist/browser.script.iife.js"></script>
<script type="text/ruby">
require "js"
trick = ((1..6).to_a+(3..9).to_a+(6..12).to_a+[2]*4).map{|i|("#"*i*4).center(80, " ")}
version = "Hello from #{RUBY_VERSION} (#{RUBY_PLATFORM})"
content = JS.global[:document].querySelector("p")
tid = JS.global.setInterval(proc {
content[:innerText] = content[:innerText].to_s + "\n" + trick.shift
JS.global.clearInterval(tid) if trick.empty?
}, 240)
JS.global[:document].querySelector("h2")[:innerText] = version
</script>
</head>
<body>
<div style="font-family: monospace; text-align: center; width: 100%">
<h2 style="color: #b70000"></h2>
<p style="color: #004c00"></p>
</div>
</body>
</html>
ここで必要なのは、「JavaScriptのruby.wasmライブラリを読み込む」「MIMEタイプにtext/ruby
を指定したscript
タグにRubyコードを書く」ことだけです。上のコードを実行するとどうなるか知りたいですか?以下の「Run Project」ボタンを押してください(訳注: 記事上で動かない場合はstackblitz.comで動かせます)。
プリコンパイル済みのruby.wasmモジュールで使えるgemはRubyのいわゆるdefault gemだけなので、ちょっとした実験には十分ですが、実際に何かを作るにはもう少しgemを追加する必要があります。しかし心配は無用です。ruby.wasmがすべてをカバーしてくれます。
ruby.wasmは、Wasmモジュールの提供の他に、Rubyアプリケーションとすべての依存関係をパッケージ化するツールも提供しています。それでは実例で方法を学びましょう。
🔗 Ruby Nextをオンラインに乗せる
RubyのトランスパイラであるRuby Nextの主な目的の1つは、Ruby構文の実験をシンプルに行えるようにすることです。パーサーや文法やコンパイラなどを扱わなくても構文の提案を試す方法はあるはずだと信じています。私たちは、Ruby Next v1.0でこのアイデアに可能な限り肉薄し、実行時に堅牢な構文変換器をRubyで書ける機能を追加しました(Paco gemというパーサーコンビネータを利用)。
実験を次のレベルに進めるために、これまでなかったRubyをハックする手段をより使いやすくする方法を追加しました。つまりブラウザから離れることなくRuby Nextを動かして実験し、リンクを送信するだけで実験結果を他のユーザーと共有できる機能です。
こうしてRuby Nextとruby.wasmが出会い、Ruby Next Playgroundが誕生したというわけです。
Ruby Next Playgroundは、Ruby言語設計者たちや愛好家たちが新しいRuby構文に取り組むテストの場となることを目指しています。
🔗 Rubyアプリケーションをrbwasm build
でパッケージ化する
ruby.wasmリポジトリを調べているうちに、bundlerをサポートするという実にクールな機能がつい最近でマージされたことに気づきました(#358で)。この機能を使えば、Gemfile.lock
にあるすべての依存関係も取り込んだRuby Wasmモジュールをコンパイルできるようになり、(通常のrequire "bundler/setup"
コマンドで)bundlerでそれらの依存関係を管理できるようになります。このプロセスを詳しく見てみましょう。
先に進む前に、Rust toolchainが自分のシステムにインストールされていることを確認してください(ruby_wasm gemはRustで書かれたネイティブ拡張に依存しています)。
それでは、以下を実行してプロジェクトで使うGemfileを作成しましょう。
bundle init
bundle add ruby_wasm ruby-next
bundle install
最後に、以下のrbwasm build
コマンドを実行して、Rubyとプロジェクトの依存関係をすべて含むWasmモジュールを生成します。
$ bundle exec rbwasm build -o ruby.wasm
# ...
# 割と時間がかかりますので、しばらくお待ち下さい...
#
# ...
#
# 最終的に、Wasmバンドルに含まれる全ライブラリのリストと
# 全体のサイズが表示されます
#
INFO: Packaging gem: ast-2.4.2
INFO: Packaging gem: diff-lcs-1.5.0
INFO: Packaging gem: paco-0.2.3
INFO: Packaging gem: racc-1.7.3
INFO: Packaging gem: parser-3.3.0.5
INFO: Packaging gem: require-hooks-0.2.2
INFO: Packaging gem: ruby-next-core-1.0.0
INFO: Packaging gem: ruby-next-parser-3.2.2.0
INFO: Packaging gem: unparser-0.6.12
INFO: Packaging gem: ruby-next-1.0.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 58.92 MB
さて、このruby.wasm
が動くかどうかをテストするにはどうすればよいでしょうか?本記事の冒頭で、WASI互換のWasmモジュールはブラウザ以外の環境でも実行可能であると述べたことを覚えていますか。wasmtimeもそうした環境の1つです。
以下のようにwasmtimeをインストールして実行しましょう。
$ wasmtime run ruby.wasm -W0 -r/bundle/setup -ruby-next -e 'proc { puts it }.call("hello")'
hello
wasmtime run ruby.wasm
を実行することは、通常のruby
実行ファイルを実行することと似ています。ruby
のときと同じオプションを渡せますし(上では-W0
で警告表示を抑制し、-r/bundle/setup
でbundlerを有効にしてから-ruby-next
でRuby Nextを有効にしています)、puts
で標準出力に表示することもできます。上のRubyスニペットでは、Ruby 3.4の新機能である暗黙のit
引数をProc内で使っています。私たちが使っているのはまだRuby 3.3ですが、このスニペットはちゃんと動きます。つまりRuby Nextが代わりにトランスパイルしてくれたのです!
これで、Rubyプロジェクトをビルドしてruby.wasmを生成するというハッピーパスを経験できました。しかし注意事項や落とし穴はつきものです。私たちはここまでにいくつかの問題に遭遇しました。
- WasmでサポートされないRuby APIがある(詳しくは制限事項を参照)。
たとえば、トップレベルでのgem
呼び出しがgemバージョンを解決できないことを発見したので、Ruby NextではWasm内でのgem
呼び出しをやめました(Ruby Nextのパッチを参照)。 - 同様に、ruby.wasmはC拡張のWasmへのコンパイルも試みますが、複雑なものについてはうまくいきません(
nio4r
やsqlite3
など)。
🔗 Webで動かすための準備
このruby.wasm
バンドルをブラウザで動かすためには、これをJavaScriptに対応させる必要があります。そのために、js
gemをバンドルに追加して再コンパイルしなければなりません1。
bundle add js
bundle exec rbwasm build -o ruby-web.wasm
このjs
gemは、WASI WasmモジュールをJavaScriptのWebAssemblyランタイムと紐づけます。これで、再コンパイルしたruby-web.wasm
モジュールはブラウザで実行可能になります。
JavaScript側では、@ruby/wasm-wasi
パッケージを用いてRuby VMをWasm内で設定および初期化します。以下は設定のサンプルです。
import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser";
import ruby from "./ruby-web.wasm";
export default async function initVM() {
const module = await ruby();
const { vm } = await DefaultRubyVM(module);
vm.eval(`
require "/bundle/setup"
require "ruby-next/language"
def transform(...) = RubyNext::Language.transform(...)
`);
return vm;
}
これで、Ruby VMのインスタンスを用いて任意のRubyコードを実行できるようになりました。
const vm = await initVM();
const source = `
greet = proc do
case it
in hello: hello if hello =~ /human/i
'🙂'
in hello: 'martian'
'👽'
end
end
puts greet.call(hello: 'martian')
`
// 最新バージョンのRubyに合わせるため
// 最初にRubyコードをトランスパイルします
const transpiled = vm.eval(`transform("${source})`).toString();
// 次にこれを実行して、期待通りに動くことを確かめます
vm.eval(transpiled);
以上でほぼすべてです。ブラウザ内で完全に動作するRuby Next Playgroundのコア機能がこれで完成しました。
ただし、puts
呼び出しがインターセプトされる件について触れておきたいと思います。
私は、プログラムの出力結果を(ブラウザのDevToolsコンソールだけでなく)Webページにも出力したいと思いました。ruby-wasm-wasiのドキュメントによるとDefaultRubyVM
を使えばカスタムのprinterオブジェクトを提供できるとのことでしたが、うまくいきませんでした。
しかしbrowser_wasi_shim(DefaultRubyVM
関数内で使われるライブラリ)のドキュメントで解決方法を見つけました。つまりこれを行うには仮想マシン(VM)のインスタンスを自分で作成しなければならず、そのために以下のようなちょっとした定型文を書く必要があります。
import { RubyVM } from "@ruby/wasm-wasi";
import { File, WASI, OpenFile, ConsoleStdout } from "@bjorn3/browser_wasi_shim";
// ここにログを保存する
const output = [];
output.flush = function () {
return this.splice(0, this.length).join("\n");
};
const setStdout = function (val) {
console.log(val);
output.push(val);
};
const setStderr = function (val) {
console.warn(val);
output.push(`[warn] ${val}`);
};
// ここでVMを手動でセットアップする
const fds = [
new OpenFile(new File([])), // stdin
ConsoleStdout.lineBuffered(setStdout), // stdout
ConsoleStdout.lineBuffered(setStderr), // stderr
];
const wasi = new WASI([], [], fds, { debug: false });
const vm = new RubyVM();
const imports = {
wasi_snapshot_preview1: wasi.wasiImport,
};
vm.addToImports(imports);
const instance = await WebAssembly.instantiate(module, imports);
await vm.setInstance(instance);
wasi.initialize(instance);
vm.initialize();
// 最後に、出力への参照を手作りVMオブジェクトに保存する
vm.$output = output;
WASI環境内の標準出力や標準エラーデバイスのファイルデスクリプタを手動で構成します。これで以下のように、Rubyコードを実行するたびに出力を得られるようになります。
function evaluate(source) {
const result = vm.eval(source).toString();
const output = vm.$output.flush();
return {result, output}
}
Ruby Next Playgroundのそれ以外のコードベースでは、コードエディタやスタイル設定、コード共有機能を扱っています。GitHubにruby-next.github.ioを置いてありますので心ゆくまでご覧いただけます。
もちろん、ruby-next.github.ioサイトでRubyの構文について心ゆくまで実験することも結果を共有することもできます!
本記事のレビューに時間を割いてくださったYuta Saitoさん、およびruby.wasmにおける彼のあらゆる取り組みに心から感謝申し上げます!
Evil Martiansは、成長段階のスタートアップ企業をユニコーン企業に飛躍させるためにサポートいたします。開発ツールの構築やオープンソース製品の開発も行っています。ワープの準備が整ったお客様、ぜひフォームまでご相談をお寄せください!
関連記事
-
訳注: この
js
gemは単独のリポジトリではなくruby.wasmリポジトリにあります(rubygems.org)。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。