RailsアプリケーションをWebAssembly化してブラウザ内で実行する(翻訳)
元記事編集者注
本記事のオリジナル版はweb.devで公開されました。
ブラウザだけで完全なブログ機能を実行できるとしたらどうなるか、想像してみてください。フロントエンドだけでなく、バックエンドもです。しかもサーバーもクラウドも一切無用です。WebAssemblyは、従来のWeb開発の境界を取り払い、ドキドキするような新しい可能性を切り開きます。本記事では、Ruby on Railsを Wasm対応およびブラウザ対応にする方法がどれほど進歩したかを共有します。
お品書きは以下のとおりです。
🔗 あの「15分でブログアプリを作る」がブラウザでできるようになった
ソフトウェアエンジニアリングは、多くの点で芸術と似ています。鑑賞する人によって意味の解釈が異なったり、クリエイターの本来の意図から逸脱する可能性があったりするように、開発者が技術ツールを設計の意図を大きく飛び越えて再利用することで、解釈におけるある種の柔軟性を発揮する傾向があります。
たとえば、WebAssembly(Wasm)を見てみましょう。
もともとWasmは、Webに高性能な機能を安全に導入するためのものでしたが、現在ではクラウドコンピューティングや組み込みシステムの構成要素の1つでもあります。もちろん、従来のWebAssemblyランタイムとブラウザによる方法もこれまで通り存在し続けます。ただし、これから紹介するランタイム活用法は、標準からかなり逸脱する可能性があります。
たとえば、一般的なサーバー側アプリケーションをブラウザに直接埋め込めば、一般的なWebアプリケーションのフロントエンドとバックエンドの距離をなくせるのです。早速やってみましょう。
Ruby on Rails は、開発者の生産性と迅速な出荷に重点を置いたWebフレームワークであり、GitHubやShopifyなど多くの業界リーディングカンパニーが利用しているテクノロジーです。
Railsフレームワークの人気に火がついたのは、随分昔にかの有名な「"How to build a blog in 15 minutes"」という歴史的な動画↓をDavid Heinemeier HanssonことDHHが公開したときでした。2005年の当時は、完全なWebアプリケーションをこんなに短時間で構築できるとは想像もつきませんでした。まるで魔法を見ているような心地になったものです。
本日は、この魔法の感覚を再び味わえるようにしてみたいと思います。
最初にRailsアプリをいつものように構築し、次にそれをWasmにパッケージ化します。
🔗「15分でブログアプリを作る」の準備
ここでは、既にRubyとRuby on Railsが自分のコンピュータにインストール済みであることが前提です。オリジナルの「15分でブログ」動画と同じように、最初に新しいRuby on Railsアプリケーションを作成して、いくつかの機能をscaffoldで生成しましょう。
$ rails new --css=tailwind web_dev_blog
create .ruby-version
...
$ cd web_dev_blog
$ bin/rails generate scaffold Post title:string date:date body:text
create db/migrate/20241217183624_create_posts.rb
create app/models/post.rb
...
$ bin/rails db:migrate
== 20241217183624 CreatePosts: migrating ====================
-- create_table(:posts)
-> 0.0017s
== 20241217183624 CreatePosts: migrated (0.0018s) ===========
これで、コードベースに触らずにアプリケーションを実行して動くようになります。
$ bin/dev
=> Booting Puma
=> Rails 8.0.1 application starting in development
...
* Listening on http://127.0.0.1:3000
ブラウザでhttp://localhost:3000/posts
を開くことで、ブログアプリケーションで記事を書けるようになります。
必要最小限のセットアップですが、それでも完全に機能するブログアプリケーションをわずか数分で構築できました。これはフルスタックのサーバー制御アプリケーションでもあります。つまり、データを処理するデータベース(SQLite)、HTTPリクエストを処理するWebサーバー (Puma)、ビジネスロジックを保持し、UIを提供し、ユーザー操作を処理するRubyプログラムを備えています。
最後に、ブラウザ操作のエクスペリエンスを効率化する薄いJavaScriptレイヤ(Turbo)もあります。
Rails公式のデモは、このアプリケーションをベアメタルサーバーにデプロイしてproduction環境で利用可能な状態にするという流れになりますが、私たちの旅は逆の方向に進みます。
つまり、アプリケーションをどこか遠く離れた場所に置くのではなく、ローカルに「デプロイ」します。
🔗 次のレベル:「15分で作ったブログアプリ」をWasm化する
WebAssemblyがブラウザに追加されて以来、ブラウザはJavaScriptコードだけでなく、Wasmにコンパイル可能なあらゆるコードも実行可能になりました。Rubyも例外ではありません。確かにRailsはRuby以上のものですが、両者の違いを掘り下げる前に、デモに続けてRailsアプリをwasm化(wasmify-railsライブラリで生まれた造語)してみましょう。
これは、ブログアプリケーションをWasmモジュールにコンパイルしてブラウザで実行するためのコマンドをいくつか実行するだけで完了します。
まず、Bundler(Rubyにおけるnpm
のようなツール)でwasmify-railsライブラリをインストールし、Railsコマンドでジェネレーターを実行します。
$ bundle add wasmify-rails
$ bin/rails wasmify:install
create config/wasmify.yml
create config/environments/wasm.rb
...
info ✅ The application is prepared for Wasm-ificaiton!
wasmify:rails
コマンドは、Railsデフォルトのdevelopment
/test
/production
環境に加えて専用のwasm
実行環境を構成し、必要な依存関係をインストールします。空き地状態のRailsアプリケーションなら、これで十分Wasm対応になります。
次に以下を実行して、Rubyランタイム、標準ライブラリ、およびすべてのアプリケーション依存関係を含むコアWasmモジュールをビルドします。
$ bin/rails wasmify:build
==> RubyWasm::BuildSource(3.3) -- Building
...
==> RubyWasm::CrossRubyProduct(ruby-3.3-wasm32-unknown-wasip1-full-4aaed4fbda7afe0bdf4e22167afd101e) -- done in 47.37s
INFO: Packaging gem: rake-13.2.1
...
INFO: Packaging gem: wasmify-rails-0.2.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 73.77 MB
この手順には時間がかかる可能性があることにご注意ください。
サードパーティのライブラリからネイティブ拡張機能(Cで記述)を適切にリンクするには、Rubyをソースからビルドする必要があるためです。この手順の(一時的な)欠点については本記事の後半で詳しく説明します。
このようにしてコンパイルされたWasmモジュールは、アプリケーションの基盤にすぎません。アプリケーションコード自体とすべてのアセット(画像、CSS、JavaScript など)もパッキングする必要があります。その前に、wasm化されたRailsをブラウザで実行するのに使えるシンプルなランチャーアプリケーションを作成しておきましょう。それ用のジェネレータコマンドbin/rails wasmify:pwa
もあります。
$ bin/rails wasmify:pwa
create pwa
create pwa/boot.html
create pwa/boot.js
...
prepend config/wasmify.yml
上のコマンドを実行すると、Viteでビルドした最小限のPWAアプリケーションを生成します。このランチャーアプリケーションは、コンパイルされたRails Wasmモジュールをローカルでテストしたり、静的にデプロイしたアプリを配布したりするのに使えます。
後は、このランチャーでアプリケーション全体を1個のWasmバイナリにパッキングするだけです。
$ bin/rails wasmify:pack
...
Packed the application to pwa/app.wasm
Size: 76.2 MB
これで完了です。ランチャーアプリを実行すると、Railsブログアプリがブラウザ内で完全に実行されるのがわかります。
$ cd pwa/
$ yarn dev
VITE v4.5.5 ready in 290 ms
➜ Local: http://localhost:5173/
ブラウザでhttp://localhost:5173を開き、「Launch」ボタンがアクティブになるまで少し待ってからクリックします。クリックすると、ブラウザでローカルに実行されているRailsアプリを操作できるようになります。
モノリシックなサーバーサイドアプリケーションを、自分のコンピュータ上だけでなく、ブラウザのサンドボックス内でも実行するのは魔法のように感じませんか?「魔法使い」である私ですら、まだある種の幻想のように思えますが、魔法ではなく単に技術の進歩なのです!
🔗 すぐ試せるデモ
アプリを試すには、スタンドアロンウィンドウで動くデモも利用できます。
GitHubのソースコードもぜひご覧ください。
🔗 Wasmで動くRailsの舞台裏
サーバーサイドアプリケーションをWasmモジュールにパッケージ化する際の課題(および解決策)をより深く理解するために、本記事の残りの部分では、このアーキテクチャを構成するコンポーネントについて解説します。
もちろん、Web アプリケーションは、アプリケーションコードを記述するプログラミング言語だけでなく、さまざまな要素に依存します。各コンポーネントは、ローカルのデプロイ環境(つまりブラウザ)にも取り込まれる必要があります。「15分でブログ」デモの興味深い点は、アプリケーションコードを書き直さずにこれを実現できることです。従来のサーバーサイドモードでアプリケーションを実行するのと同じコードが、ブラウザ上でも使われるようになります。
Ruby on Railsのようなフレームワークが提供するインターフェイスは、インフラストラクチャのコンポーネントと通信するための抽象化です。次のセクションでは、フレームワークアーキテクチャを活用して、やや難解なローカルサービスのニーズに対応する方法について解説します。
🔗 基盤となるruby.wasm
Rubyは2022年のバージョン3.2からWasmに対応しています。つまりRuby VMのCのソースコードをWasmにコンパイルすることでRuby VMをどこにでも持ち込めるようになりました。
ruby.wasmプロジェクトは、ブラウザ(またはその他のJavaScriptランタイム)でRubyを実行するためのコンパイル済みモジュールとJavaScriptバインディングをリリースしています。
このruby.wasmプロジェクトには、依存関係を追加したカスタムRubyバージョンを手軽にビルドできるビルドツールも含まれています。これは、C拡張機能を持つライブラリに依存しているプロジェクトにとって非常に重要です。そうです、ネイティブ拡張機能もWasmにコンパイルできるということです(まだすべてではありませんが、ほとんどの拡張機能はWasmにコンパイル可能です)。
現時点のRubyはWebAssemblyシステムインターフェースであるWASI 0.1を完全にサポートしています。WASI 0.2にはコンポーネントモデルが含まれており、完成まであと数ステップのアルファ状態です。WASI 0.2がサポートされると、新しいネイティブ依存関係を追加するたびに言語全体を再コンパイルする必要なしにコンポーネント化可能になります。副次的効果として、コンポーネントモデルはバンドル全体のサイズ削減にも有用です。
ruby.wasmの開発や進捗状況について詳しくは、以下のスライドをご覧ください。
これで、Wasmという方程式のRuby部分については解を得られました。ただし、WebフレームワークとしてのRailsには、引き続き前出の図に示したすべてのコンポーネントが必要です。他のコンポーネントをブラウザに配置してRailsでそれらをつなぎ合わせる方法については、続きをお読みください。
🔗 ブラウザ内で動作するデータベースに接続する
SQLite3には公式のWasmディストリビューションが存在し、それに対応する JavaScriptラッパーも付属しているため、ブラウザに埋め込むことが可能です。
Wasm用のPostgreSQLは、PGliteプロジェクトで入手できます。つまり、後はRails on Wasmアプリケーションからブラウザ内データベースに接続する方法を理解するだけです。
Railsでデータモデリングとデータベースアクセスを担当するコンポーネント(サブフレームワーク)は、Active Recordと呼ばれています(はい、この命名はORMのデザインパターン名 にちなんでいます)。Active Recordは、データベースアダプタを介して、実際のSQL対応データベース実装をアプリケーションコードから抽象化します。
Rails には、SQLite3、PostgreSQL、MySQLですぐに使えるアダプタがそれぞれ用意されています。ただし、どのアダプタもネットワーク経由でアクセス可能な実際のデータベースに接続することを前提としています。これを克服するために、ローカルのブラウザ内データベースに接続する独自のアダプタを作成できます。
SQLite3 WasmやPGliteアダプタ(このWasmify Railsプロジェクトの一部として実装)は以下のように作成します。
- アダプタクラスは対応する組み込みアダプタから継承されるため(例:
class PGliteAdapter < PostgreSQLAdapter
)、実際のクエリ準備や結果解析ロジックは再利用できます。 -
低レベルのデータベースコネクションの代わりに、JavaScriptランタイムに存在する外部インターフェースオブジェクトを使います。これは、Rails Wasmモジュールとデータベース間のブリッジです。
たとえば、SQLite3 Wasmのブリッジ実装は次のとおりです:
export function registerSQLiteWasmInterface(worker, db, opts = {}) {
const name = opts.name || "sqliteForRails";
worker[name] = {
exec: function (sql) {
let cols = [];
let rows = db.exec(sql, { columnNames: cols, returnValue: "resultRows" });
return {
cols,
rows,
};
},
changes: function () {
return db.changes();
},
};
}
アプリケーションの立場では、「実際の」データベースからブラウザ内のデータベースへの乗り換えは、設定の問題にすぎません。
# config/database.yml
development:
adapter: sqlite3
production:
adapter: sqlite3
wasm:
adapter: sqlite3_wasm
js_interface: "sqliteForRails"
ローカルデータベースの操作には、それほど多くの労力は必要ありません。ただし中央の「信頼できる情報源」とデータを同期する必要がある場合は、高度な課題に直面する可能性があります。とはいえ、この問いは本記事の範囲を超えています(ヒント: Rails on PGliteとElectricSQLのデモ をチェックしてみてください)。
🔗 Webサーバーとしてのサービスワーカー
Webアプリケーションでもう1つ重要なコンポーネントはWebサーバーであり、ユーザーはHTTPリクエストを介してWebアプリケーションと通信します。したがって、ページナビゲーションやフォーム送信によってトリガーされたHTTPリクエストをWasmモジュールにルーティングする方法が必要です。ありがたいことに、ブラウザにはその答えがあります。それがサービスワーカー(service worker)です。
サービスワーカーは、JavaScriptアプリケーションとネットワーク間のプロキシとして機能する特殊なWebワーカーで、リクエストをインターセプトして操作できます。たとえば「キャッシュ済みデータの配信」「他のURLへのリダイレクト」「Wasmモジュールへのリダイレクト」を行えます。以下は、Wasm上で動いているRailsアプリケーションでリクエストを処理するサービスのスケッチです。
// vm変数は、Ruby Vmで初期化されたWasmモジュールへの参照を保持する。
let vm;
// db変数は、ブラウザ内のデータベースインターフェイスへの参照を保持する
let db;
const initVM = async (progress, opts = {}) => {
if (vm) return vm;
if (!db) {
await initDB(progress);
}
vm = await initRailsVM("/app.wasm");
return vm;
};
const rackHandler = new RackHandler(initVM});
self.addEventListener("fetch", (event) => {
// ...
return event.respondWith(
rackHandler.handle(event.request)
);
});
"fetch"は、ブラウザからリクエストが行われるたびにトリガーされます。リクエスト情報(URL、HTTPヘッダー、body)を取得して独自のリクエストオブジェクトを構築できます。
Railsは、多くのRuby製Webアプリケーションと同様に、HTTPリクエストの処理にRackインターフェースに依存しています。
Rackインターフェースは、リクエストオブジェクトとレスポンスオブジェクトの形式、および背後のHTTPハンドラー(つまりアプリケーション)のインターフェイスを記述します。これらのプロパティは以下のように表現できます。
request = {
"REQUEST_METHOD" => "GET",
"SCRIPT_NAME" => "",
"SERVER_NAME" => "localhost",
"SERVER_PORT" => "3000",
"PATH_INFO" => "/posts"
}
handler = proc do |env|
[
200,
{"Content-Type" => "text/html"},
["<!doctype html><html><body>Hello Web!</body></html>"]
]
end
handler.call(request) #=> [200, {...}, [...]]
ちなみに、このリクエスト形式に見覚えのある方は、おそらく昔なつかしのCGIを使ったことがあるでしょう。
JavaScriptのRackHandler
オブジェクトは、JavaScriptとRubyの間でリクエストとレスポンスの変換を担当します。RackはほとんどのRuby Webアプリケーションで使われているため、実装はRails固有ではなく汎用的なものになります(ただし実際の実装は長すぎて本記事には載せきれません)。
サービスワーカーは、ブラウザ内Webアプリケーションで欠かせない重要な要素の1つです。サービスワーカーは、HTTPプロキシであるだけでなく、キャッシュレイヤでもありネットワークスイッチャーでもあります(つまり、オフラインで動作するローカル環境ファーストのアプリケーションを構築できます)。このコンポーネントは、ユーザーがアップロードしたファイルを配信するのにも有用です。
🔗 アップロードしたファイルをブラウザ内で保持する
新しいブログアプリケーションに実装する最初の追加機能の1つは、ファイルのアップロード機能、具体的には投稿に画像を添付する機能です。これを実現するには、ファイルをいったん保存してから配信する手段が必要です。
Railsフレームワークでは、ファイルのアップロードをActive Storageで処理します。Active Storageは、低レベルのストレージメカニズムを意識せずにファイルを操作するための抽象化とインターフェイスを提供します。ファイルをハードディスクに保存しようとクラウド上に保存しようと、アプリケーションコードで保存場所を意識する必要はありません。
Active Recordの場合と同様に、カスタムのストレージメカニズムをサポートするには、対応するストレージサービス用のアダプタを実装するだけで済みます。しかしブラウザでファイルをどこに保存すればよいのでしょうか?
従来であればデータベースを使うのがオプションでした。もちろん、ファイルをBLOBとしてデータベースに保存するのに追加のインフラストラクチャコンポーネントを必要としません。さらに、それ用のRailsプラグインとしてActive Storage Databaseが既にあります。
ただし、データベースに保存されたファイルをWebAssembly内で実行されるRailsアプリケーションで配信するとなると、シリアライズとデシリアライズのコストが生じるため、理想的ではありません。
ブラウザに最適化されたより良いソリューションは、ファイルシステムAPIを使うことです。これにより、ファイルのアップロードとサーバーにアップロードされたファイルがサービスワーカーで直接処理されます。この種のインフラストラクチャに最適な候補はOPFS(オリジンプライベートファイルシステム)です。これは最近のブラウザAPIであり、将来のブラウザ内アプリケーションで重要な役割を果たすことは間違いありません。
🔗 RailsのWasmの組み合わせでどんなことができるか
皆さんが本記事を読み始めたとき、おそらくこんな疑問が浮かんだことでしょう。なぜサーバーサイドフレームワークをわざわざブラウザで実行するのかと。
そうですね、フレームワークやライブラリがサーバーサイドであるとかクライアントサイドであるといった考えは、単なるラベルでしかありません。優れたコード(特に優れた抽象化)であればどんな場所でも動きます。新しい可能性を探求し、フレームワーク(ここではRuby on Rails)の境界とランタイム(WebAssembly)の境界を押し広げるうえで、こうしたラベルは妨げになりません。どちらも、このような型破りなユースケースから恩恵を受けられます。
しかし、従来の形に沿った実用的なユースケースもたくさんあるのです。
まず、フレームワークがブラウザで動くようになれば、学習やプロトタイピングの機会が大きく広がります。ライブラリ、プラグイン、パターンをブラウザ内で他の人と一緒に操作できると想像してみてください。私たちの顧客であるStackblitz様の案件では、JavaScriptフレームワークでこれを可能にしました。
もう1つの例は、WordPress Playgroundです。ここではWebページを離れずにWordPressテーマを操作できます。Wasmを使えば、Rubyとそのエコシステムでもこれと同様のことを実現できる可能性があります。
また、ブラウザ内コーディングという特殊なケースもあります。これはオープンソース開発者が問題のトリアージやデバッグを行ううえで特に便利です。
繰り返しになりますが、StackBlitz様の案件ではこれをJavaScriptプロジェクト向けに実現しました。最小限の問題再現スクリプトを作成し、GitHub issuesのリンクを指定すると、メンテナーがシナリオを再現する時間を節約できます。実際、Rubyコードをブラウザから実行できるRunRuby.devプロジェクトのおかげで、これは既にRubyで実現し始めています(ブラウザ内再現で解決されたissueの例: #53)。
さらに別のユースケースとして、オフライン対応(またはオフライン認識)アプリケーションもあります。オフライン対応アプリケーションは、通常はネットワークを用いて動作しますが、ネットワーク接続がない場合でも利用できます。
たとえば、オフライン時に受信トレイを検索できるメールクライアントがこれに該当します。
別の例としては、「デバイスに保存」機能を備えた音楽ライブラリアプリが挙げられます。このアプリは、ネットワークに接続していなくてもお気に入りの音楽を再生できます。どちらの例も、従来のPWAのようにキャッシュを使うだけでなく、ローカルに保存されたデータに依存します。
最後に、ローカルアプリケーション(つまり普通のデスクトップアプリケーション)をRailsで構築してもよいでしょう。フレームワークが提供する生産性は実行環境に依存しないからです。フル機能のフレームワークは、個人データやロジックを多用するアプリケーションの構築に適しています。また、Wasmをポータブルな配布形式として採用することも現実的な選択肢です。
これは、Rails on Wasmの旅の始まりにすぎません。課題や解決方法について詳しくは、制作中の電子書籍『Ruby on Rails on WebAssembly』をご覧ください。なお、この電子書籍自体がオフラインでも動作するRailsアプリケーションです。
概要
元サイトの許諾を得て翻訳・公開いたします。