Ruby Playground (ruby/play-ruby) の仕組みを調べてみた

目次

はじめに

こんにちは、okarin です。弊社はコーディング試験サービスを提供しており、Ruby、Go、Rust、Python、C++など、合計 16 言語の実行環境をサポートしています(本記事執筆時点)。コーディング試験のように任意のコードを実行する仕組みがどのように実装されているか気になる方も多いのではないでしょうか。本記事では、コード実行環境の一例として ruby/play-ruby の仕組みを調べてみたので、解説したいと思います。

(本記事は Kyoto.rb で発表したスライドの内容を記事に書き起こしたものになります)

play-ruby を触ってみる

play-ruby の挙動確認

まずは play-ruby を実際に触ってみて、挙動を確認してみたいと思います(https://ruby.github.io/play-ruby/)。

Chrome DevTools の各操作がイメージしにくい方は以下の gif を参考にしてみてください。

Ruby のコードを実行する

ページを開くと、以下のような画面が表示されます。左側に実行したいコードを入れて、 Evaluate の右側にある実行ボタンをクリックするとコードが実行できます。

ruby-3.3.zip ファイルのダウンロード

Chrome DevTools の Network タブを開いておいてから、play-ruby の URL を入力すると、 ruby-3.3.zip というファイルをダウンロードしていることが確認できます。この zip ファイルに関しては後述します。

インターネット通信がないことの確認

Devtools の Network タブを開いておいて、実行ボタンをクリックすると、インターネット通信が発生していないことが確認できます。

ruby worker スレッドの確認

Devtools の Sources タブを開くと、 Main スレッドの他にも ruby.worker.js という Worker が立ち上がっていることが確認できます。

さらに、 Performance タブの Record ボタンをクリックしてから、 Ruby のコードを実行すると、以下のような ruby.worker.js のプロファイルを得ることができます。

よくみてみると wasm-function の span があり、 WebAssembly を利用していることが分かります。

play-ruby を支える仕組みとその実装

実際に play-ruby を触ってみることで様々な情報が得られたので、実際のコードがどのようになっているのかをみていきます。

Playground 起動時に zip ファイルをダウンロードする

zip ファイルをダウンロードしている箇所のコードを一部抜粋すると、以下のようになります。ダウンロードしていたファイルは ruby.wasm であることがわかります。これを利用することで、ブラウザ上で Ruby のコードを実行できます。

async function downloadBuiltinRuby(version, rubyVersion) {
    const tarball = `ruby-${rubyVersion}-wasm32-unknown-wasi-full.tar.gz`
    const url = `https://github.com/ruby/ruby.wasm/releases/download/${version}/${tarball}`
    const destination = `./dist/build/ruby-${rubyVersion}/install.tar.gz`
    const zipDest = `./dist/build/ruby-${rubyVersion}.zip`

    ...

    await downloadUrl(url, destination)

    ...

    const zip = spawn("zip", ["-j", zipDest, destination])
}

Ruby のコードを実行する Web Worker を生成する

Worker を生成しているコードは以下のようになります。 ComlinkWeb Workers を扱いやすくするもので、 Worker 側でエクスポートされた関数やクラスなどをメインスレッドから簡単に呼び出すことができます。

initRubyWorkerClass 関数は、 Ruby の Worker を生成するための関数を返します。

async function initRubyWorkerClass(rubySourc, service, setStatus, setMetadata) {

  const RubyWorkerClass = Comlink.wrap(new Worker("build/src/ruby.worker.js", { type: "module" }))

  ...

  return async () => {
    return await RubyWorkerClass.create(zipBuffer, stripComponents, Comlink.proxy(setStatus))
  }
}

入力された Ruby のコードを Web Worker 上で実行する

以下のコードで、上述した initRubyWorkerClass 関数を実行して、 Ruby の Worker を生成する関数を取得します。 Worker を生成したら、 worker.run を Worker 用のスレッドで実行する runCode 関数を作成し、イベントリスナに登録します。

const makeRubyWorker = await initRubyWorkerClass(rubySource, downloader, setStatus, setMetadata)

const worker = await makeRubyWorker()
const runCode = async (code: string) => {

  ...

  await worker.run(codeMap, mainFile, selectedAction, args, Comlink.proxy((text) => outputWriter.write(text)))
  outputWriter.finalize()
}

const run = async () => await runCode(getCode());

buttonRun.addEventListener("click", () => run())

Ruby のコードを WASI で実行する

run 関数をもう少しみていくと、以下のようなコードになります。

@bjorn3/browserwasishim はブラウザ環境で WASI API をエミュレートするもので、 WebAssembly モジュールがエミュレートされたシステムコールを利用することができます。これにより、ファイルの読み書きなどのコードも実行することができるようになります。

printer 周りの処理では、 Ruby のコードを実行したときの出力を log 関数に渡して実行します。 log 関数を HTMLElement の innerText に書き込むような関数にしておくと、 Ruby のコードの出力をブラウザ上に表示することができます。

import { File, OpenFile, PreopenDirectory, WASI } from "@bjorn3/browser_wasi_shim"

async run(code: { [path: string]: string }, mainScriptPath: string, action: string, extraArgs: string[], log: (message: string) => void) {

    ...

    // Run the Ruby module with the given code
    const wasi = new WASI(
        ["ruby"].concat(extraArgs).concat([mainScriptPath]),
        [],
        [
            new OpenFile(new File([])), // stdin
            new OpenFile(new File([])), // stdout
            new OpenFile(new File([])), // stderr
            new PreopenDirectory("/", rootContents),
        ],
        {
            debug: false
        }
    )
    const imports = {
        wasi_snapshot_preview1: wasi.wasiImport,
    }
    const printer = consolePrinter((fd, str) => { log(str) })
    printer.addToImports(imports)

    const instnace: any = await WebAssembly.instantiate(this.module, imports);
    printer.setMemory(instnace.exports.memory);
    try {
        wasi.start(instnace)
    } catch (e) {
        log(e)
        throw e
    }
}

Ruby のコードを実行するまでの全体のおさらい

ここまでの情報を整理すると、以下のような図になります。

メインスレッドとは別の Worker 用のスレッドが立ち上がっており、そこでは Ruby の WASM を利用して、 Ruby のコードを実行しています。実行結果は log 関数を通じて HTMLElement に書き込むようになっています。

このように、Ruby のコードを実行する流れがブラウザのプロセス内で閉じており、インターネット通信が不要であることが分かります。

まとめ

本記事では、 play-ruby というブラウザで Ruby のコードを実行できる環境の仕組みを調べてみました。意外と小さなプロジェクトなのでコードも読みやすく、比較的理解しやすいのではないかと思います。さらに興味が出てきた方は実際にコードを読んでみたり、他の言語の Playground の仕組みを調べてみたりすると面白いかもしれません。

※本記事で引用した play-ruby のコードは記事執筆時点(2024 年 8 月 20 日)で MIT ライセンスで公開されています

https://github.com/ruby/play-ruby Copyright (c) 2023 Yuta Saito