10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ruby.wasm で遊ぶ ~Web Workerを使ってバックグラウンドで動かすまで

Posted at

Ruby 3.2 から WASI ベースの WebAssembly がサポートされるということで、すでに Preview 版も公開されています。

この記事は、正直 WebAssembly とか WASI とかよく分かっていない1人間がブラウザ上で Ruby を色々動かしてみる記事です。とりあえず動けばいいという感じなので、おそらく無駄な記述も多いかと思います。ご了承ください。

作るもの

テキストボックス等に記述された任意の Ruby スクリプトをブラウザ上で動かして、その実行結果を得られるようなもの。

要するに RubyOnBrowser とか TryRuby とかの二番煎じを作りたいのです。

とりあえず Ruby スクリプトを動かす

ruby.wasm の github 上に Quick Start (for Browser) が載っているので、まずはこれをほぼそのまま。

ruby1.html
<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.umd.js"></script>
  <script>
    const { DefaultRubyVM } = window["ruby-wasm-wasi"];
    const main = async () => {
      const response = await fetch(
        "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm"
      );
      const buffer = await response.arrayBuffer();
      const module = await WebAssembly.compile(buffer);
      const { vm } = await DefaultRubyVM(module);

      alert(vm.eval(`(1..10).inject(:*)`).toString());
    };

    main();
  </script>
  <body></body>
</html>

これで、(1..10).inject(:*) の計算結果がアラートで表示されます。

標準ライブラリを使用できるようにする

上の HTML で読み込んでいる https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/ruby.wasm には、標準ライブラリは基本的に入っていません。2 ですので require "date" とか書くとエラーになります。標準ライブラリを使いたい場合は、別のファイルを読み込む必要があります。

注意しなければならないのは、latest( https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/ )の場所には現在標準ライブラリ入りのファイルは置かれていない、ということです。nightly release 版にしか標準ライブラリ入りは無いとのことなので、上のリンク先の右上にあるセレクトボックスで「ruby-head-wasm-wasi@0.3.0-(日付)-a」を選び、distruby+stdlib.wasm を使います。また、先の HTML ファイル 2 行目の <script src="~"> で読み込んでいるファイルも併せて同じ日付版の browser.umd.js に変更しておきましょう。

おそらく 3.2 正式版リリースの際には latest の場所にも ruby+stdlib.wasm が置かれるのだと思いますが、とりあえず現状はこうした注意が必要なようです。

※ 今回の記事では、以後とりあえず 2022/09/29 版を使用して進めていきます。

vm.eval

vm.eval に与えた文字列が、Ruby スクリプトとして実行されます。Kernel.#eval 同様、最後の式の値が戻ってきます。

戻ってきたデータはなんだかよく分からないオブジェクトですが、.toString() してやることで、Ruby で言えば .to_s した時の文字列が得られるようです。

このように Ruby 側から JavaScript 側に文字列データを送ることはできるので、例えば JSON にした文字列を送るようにすれば配列やハッシュのデータも送れます。(もしかしたら もっと簡単な方法も用意されているのかもしれませんが、あまり調べていません。)

テキストボックスに入力されたスクリプトを実行できるように

ruby2.html
<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/browser.umd.js"></script>
  <script>
    let RubyModule;
    const { DefaultRubyVM } = window["ruby-wasm-wasi"];
    const main = async () => {
      const response = await fetch(
        "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/ruby+stdlib.wasm"
      );
      const buffer = await response.arrayBuffer();
      const module = await WebAssembly.compile(buffer);
      RubyModule = module;
      document.getElementById("run").disabled = false;
    };

    main();

    async function run(){
      const script = document.getElementById("script").value;
      const result = document.getElementById("result");
      const { vm } = await DefaultRubyVM(RubyModule);
      result.value = "";
      result.value += vm.eval(script).toString();
    }
  </script>
  <body>
    <textarea id="script">(1..10).inject(:*)</textarea>
    <button id="run" onclick="run()" disabled>実行</button>
    <textarea id="result" readonly></textarea>
  </body>
</html>

とりあえずこんな感じで可能です。

実行するたびにリセットされるようにするために

今回作りたいものの性質を考えると、実行するたびにリセットされることが望ましいでしょう。つまり最初にテキストボックスに $a = 1 を入力して実行し、続いてテキストボックスを $a に変えて実行したら、1 ではなく nil になってほしいのです。

最初の ruby1.html では、最終的に Ruby スクリプトを実行する際に必要な vm を用意する際に、response, buffer, module という中間生成物が作られていました。

試してみたところ、vm を保存して使い回すと前回の実行結果が残るのですが、module を保存しておいて そこから vm を生成して使えば、前回の実行結果はリセットされるようです。上の ruby2.html ではそうしています。

Ruby から JavaScript を操作

require "js"
JS::eval("実行したい JavaScript コード")

とすれば、Ruby から JavaScript を実行できます。DOM 操作等もできるので、Ruby 側からブラウザをコントロールできます。

出力を得る

そのままでは Ruby スクリプト内で出力された内容は、console には表示されるものの、値として得ることができません。

RubyOnBrowser や TryRuby では、WasmFswriteSync を置き換えることで出力を得ています。そのやり方を使う場合は、RubyOnBrowser の該当箇所TryRuby の該当箇所を見てください。 (私はよく分かっていません。)

が、もっと単純な方法としては、以下のようなやり方でも出力を得ることが可能です。

vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
`);
let output;
try {
  vm.eval(script);
  output = vm.eval(`$stdout.string`).toString();
} catch(err) {
  output = err.toString();
}

要するに Ruby 内で標準出力をリダイレクトさせ、最後にその値を戻しています。

入力を与える

出力の応用で、標準入力に何らかの文字列を与えることもできます。

vm.eval(`
require "stringio"
$stdin = StringIO.new("foo")
`);

それから入力をダイアログで対話的に取得したいのであれば、こんな方法もあります。(※ この記事を書いている時点では、latest のところに置かれたファイルでは正しく動きません。ある程度新しい日付のものを使う必要があるようです。)

vm.eval(`
require "js"
module Kernel
  def gets
    JS::eval("return prompt()").to_s + "\n"
  end
end
`)

出力があるたびに値を得る

なお、上述の出力取得方法ではスクリプトの実行が終わるまで出力を得ることができませんが、出力があるたびに得たい場合は、無理矢理ですがこんな方法も可能です。

vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
require "js"
def $stdout.write(str)
  JS::eval("result.value += \`" + str.gsub("\\\\", "\\\\\\\\").gsub("\`", "\\\\\`") + "\`")
end
`)

ただしこの方法を使って puts 1; sleep 1; puts 2 のようなスクリプトを実行させても、結局スクリプトが終了するまでテキストボックスの値は変わりません。出力があるたびに画面にも即表示させたい場合は、この後の Web Worker を用いる方法と組み合わせる必要があります。

Web Worker を用いてバックグラウンドで動作させる

ここまでのやり方には実は大きな問題がありました。Ruby スクリプト実行中はページが固まってしまうのです。

今回作りたいものが「テキストボックス等に記述した任意の Ruby スクリプトを実行する」というものである以上、無限ループする Ruby スクリプトが実行される可能性もあります。そうした場合にページが完全に固まり、最悪の場合タブやブラウザを閉じる必要があります。

しかし、Web Worker という仕組みを利用すれば、バックグラウンドで Ruby スクリプトを動作させることで、たとえ無限ループであろうがページが固まらなくて済むようになります。

ruby3.html
<html>
  <script>
    let worker = new Worker("worker.js");
    worker.addEventListener("message", workerEvent, false);

    function workerEvent(e) {
      if (e.data[0] == "init") {
        document.getElementById("run").disabled = false;
      } else if (e.data[0] == "output") {
        document.getElementById("result").value += e.data[1];
      }
    }

    async function run(){
      const script = document.getElementById("script").value;
      document.getElementById("result").value = "";
      worker.postMessage(["script", script]);
    }
  </script>
  <body>
    <textarea id="script">puts (1..10).inject(:*)</textarea>
    <button id="run" onclick="run()" disabled>実行</button>
    <textarea id="result" readonly></textarea>
  </body>
</html>
worker.js
importScripts("https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/browser.umd.js");

let RubyModule;

const { DefaultRubyVM } = this["ruby-wasm-wasi"];
const main = async () => {
  const response = await fetch(
    "https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.3.0-2022-09-29-a/dist/ruby+stdlib.wasm"
    );
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  RubyModule = module;
  self.postMessage(["init", ""]);
};

main();

self.addEventListener("message", async function(e) {
  if (e.data[0] == "script") {
    const script = e.data[1];
    const { vm } = await DefaultRubyVM(RubyModule);
    vm.eval(`
require "stringio"
$stdout = $stderr = StringIO.new(+"", "w")
    `)
    let output;
    try{
      vm.eval(script);
      output = vm.eval(`$stdout.string`).toString();
    } catch(err) {
      output = err.toString();
    }
    self.postMessage(["output", output]);
  }
}, false);

このように、worker.js にバックグラウンド動作させたいスクリプトを移し、それから今までは HTML から <script> で読み込んでいた browser.umd.js を、worker.js から importScripts を用いて読み込むように変更してやります。(← この部分がなかなか分からなくて時間を費やしました…)

なお、Web Worker 上からは windows オブジェクト等にアクセスできなくなるので、先述の「入力をダイアログで対話的に取得」の方法は window.prompt を使用しているため使えなくなります。

ローカルでも動作するようにする

上記の ruby3.html + worker.js は、どこかの Web サーバーに載せたり、あるいはローカルで Web サーバーを立ち上げ localhost からアクセスしたりすれば問題なく動作するのですが、ローカルに置いたファイルをただ開いた場合、セキュリティの問題により動作しません。

が、以下のような対策があるようです。3

動作を停止できるようにする

Web Worker を使うことで、無限ループスクリプトによってページが固まる問題は解決します。しかし、結局は裏で無限ループが回り続けてしまうので CPU の無駄ですし、その状態では新たな実行もできなくなってしまいます。

ですので、例えば停止ボタンを用意したり、あるいは数秒経過した時点で、 Worker.terminate() を使って Web Worker を終了させる必要があります。

ただし、終了させるだけだと その後実行できなくなってしまうので、terminate で終了 + Web Worker 再生成+イベントリスナーの登録 をまとめて行う必要があります。

成果物

ここまでの内容を、少し手直ししたものを載せておきます。ソースは Github リポジトリを見てください。

  • ruby.wasm 利用サンプル (Without Web Worker)
    • Web Worker を使用していないバージョン。上記の「入力をダイアログで対話的に取得」の方法を用いて、gets でプロンプトから入力を得ることができます。
  • ruby.wasm 利用サンプル (With Web Worker)
    • Web Worker を使用したバージョン。出力があるたびに出力用 Textarea が反映され、停止ボタンを押すか 指定した秒数が経過すると自動的に停止することを確かめてください。

まだまだ ruby.wasm に関する情報は少ないので、これから ruby.wasm を使ってみたい人の参考になれば幸いです。

ちなみに私が調べる上で役立ったのは先述した RubyOnBrowser や TryRuby のほかに、Python の Wasm 実装である Pyodide でした。こちらのほうが歴史があるぶん情報が揃っています。(実際この記事中に書いた importScripts を使う必要があることは、Pyodide の情報のおかげで解りました。)

成果物その2

上の成果物は ruby.wasm を使って何か作ってみたい人の参考としては役立つかもしれませんが、実用性はありません。

実用的なものとして、こんなものも作ってみました。

Ruby での競技プログラマー向けに、複数の入力例に対してプログラムを実行し、その実行結果と出力例が合っているかを一括でチェックできるシステムです。もし Ruby で競技プログラミングをしている方が居たら、ご利用いただけると嬉しいです。(Pyodide を使った Python 版もあります。)

  1. そもそも JavaScript だってよく分かっていないし、Ruby だって分かっていると言えるか怪しい。

  2. まったく入っていないわけではなく、stringio は入っていました。他に何が入っているかは調べていません。

  3. そもそもどういうセキュリティ上の理由で制限されているのか、そしてこんな簡単に回避できるのに制限する必要があるのか、よく分かりません。

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?