Ruby
C
mruby
WebAssembly

mrubyをブラウザで実行するまで (WebAssembly)

更新サマリー
- 2017/11/28 print問題について加筆した。/独自マクロを除去。標準のものへ変更した。その他細かい文を修正した。
- 2018/7/19 幾つか正しくない表記を変更した。

はじめに

Rubyがブラウザで動作する

先日、主要ブラウザでWebAssemblyを利用できる環境が整ったと話題になりました。

このことから、今後はWebアプリ(特にフロントエンド)でJavaScript系列以外の言語が選択肢にはいるようになります。(検索すると、Rustがよく引き合いに出されています。)

本コンテンツはmrubyインタプリタWebAssemblyに変換し、ブラウザ上でRubyコード"p 'hello world! ...'を動作させるところまでを目標とします。なお、余談ですが、理屈上CRubyもWebAssembly化できるはずですが、ここではmrubyをつかいます。

対象者

「mrubyとは」や「WebAssemblyとは」といった説明はありませんので基本的な概念は知っておく必要があります。

ただし、「よくわからんけど、mrubyはRubyの軽量版で、WebAssemblyはブラウザでC言語がうごくやつやろ?」くらいのノリでも読めるようには工夫します。

環境準備

本コンテンツ執筆にあたり試した環境は以下になります。

項目 備考
OS macOS Sierra
ブラウザ Google Chrome 62.0.3202.94
mruby 31ce73dd (2017/11/18時点のmaster) 導入方法は後述
コンパイラ(ネイティブ) clang-900.0.38 Apple LLVM version 9.0.0 デフォルトのもの
コンパイラ(クロス) Emscripten Compiler Frontend 1.37.22 導入方法は後述
その他 git,cmake brew等でインストールしてください

その他、最後の動作確認で webサーバが必要ですが、本ドキュメントでは Dockerで紹介しています。
(WebサーバであればなんでもOK。 nodejs の http-server や apache など。)

Emscripten Compiler Frontend(ビルドツール)のセットアップ

Emscripten Compiler Frontendの構築方法

Emscripten Compiler Frontend(以降:emcc)とは、C言語のソースをWASM(WebAssemblyのバイナリ)に変換するためのツールです。
厳密には途中でLLVMのビットコードに変換されてたりしますが使用上では気にしなくて良さそうです。

以下のドキュメント「Emscripten の環境設定」までを一通り実行します。

[C/C++からWebAssemblyにコンパイルする]
https://developer.mozilla.org/ja/docs/WebAssembly/C_to_wasm

ターミナルからemcc -vなどemccコマンドが使える状態になればOKです。
参考までに私が試したコマンドを記述します。(コマンドの詳細は上記を参照ください)

git clone https://github.com/juj/emsdk.git
cd emsdk

# 20-30分くらい時間がかかります。溜まったアニメを消化しましょう。
./emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit

# emccのパスが通るので展開場所を意識したほうがいいかもしれません。
./emsdk activate --global --build=Release sdk-incoming-64bit binaryen-master-64bit

# 下記を実行することで`emcc`へのパスが張られます。
source ./emsdk_env.sh

mrubyのセットアップ

mrubyのダウンロード

mruby公式リポジトリからcloneします。cloneできたらディレクトリに入ります。

git clone https://github.com/mruby/mruby.git
cd mruby

実はこの時点で make を叩くとビルドされ、実行されているホスト向けのmrubyバイナリ(->mrbc等)とライブラリ(->libmruby.a)ができます。さすがmruby、お手軽ですね。

WebAssembly向けにビルド設定の変更

クロスコンパイルとは

今回は、PC向けではなくWebAssemblyのバイナリを吐いてほしいのでビルドの設定を変更します。
ホストとは異なるアーキテクチャへのコンパイルを行うことをクロスコンパイルと言います。
mrubyは組み込みでの利用が想定されているため、クロスコンパイルのコンフィグレーションはとても洗練されています。

クロスコンパイルの設定

ここでは概ね、公式ドキュメントのクロスコンパイルの方法に沿って設定します。
まず、直下の build_config.rb を開き、一番最後に下記を追加します。

# build_config.rb
...
# Define wasm build settings
MRuby::CrossBuild.new('wasm') do |conf|
  toolchain :clang

  # C compiler settings
  conf.cc do |cc|
    cc.command = 'emcc' # 通常のコンパイルは emccコマンド(フロントエンド) を使います。
    cc.flags = [ENV['CFLAGS'] || %w()]
    cc.include_paths = ["#{root}/include"]
    cc.option_include_path = '-I%s'
    cc.option_define = '-D%s'
    cc.compile_options = "%{flags} -c %{infile} -s WASM=1 -o %{outfile}"
  end

  # Archiver settings
  # ※本来はオブジェクトファイルを固めるものですが、ここではemccでbitcodeを固めます。
  conf.archiver do |archiver| 
    archiver.command = 'emcc'
    archiver.archive_options = '%{objs} -s WASM=1 -o %{outfile}'
  end

  # file extensions
  # 成果物、中間ファイルの拡張子を設定します。
  conf.exts do |exts|
    exts.object = '.bc'
    exts.executable = '' # '.exe' if Windows
    exts.library = '.bc'
  end

  conf.gembox 'wasm'  # 後述
end

gemboxのセットアップ

gemboxとは

gemboxとは使用するmrbgemのセットです。つまり、conf.gembox 'wasm'はwasm.gembox を使うという意味で、gembox自体は ./mrbgems/内に設置します。

mrbgemってなに
mrbgemとはCRubyでいうgemのmrubyバージョンです。CRubyと異なりビルド時にすべて包括します。
gemboxは使うmrbgemを列挙したものです。

実はデフォルトでdefault.gemboxが存在しそれを使うことができるのですが、不必要なものがあるためいくつか取り除きます。また、どうやらemccでは timespec_get(...)の実装がないらしく、今回は残念ながらTimeクラスは妥協します。

gemboxの作成(for wasm)

default.mrbgemをコピーしてwasm.gemboxを作成します。

cp mrbgems/default.gembox mrbgems/wasm.gembox

下記のようにコメントアウトします。

 11   # Use standard Time class
 12   # conf.gem :core => "mruby-time"  <- コメントアウト (実装上の理由)
...
 65   # Generate mirb command
 66   #conf.gem :core => "mruby-bin-mirb"  <- コメントアウト
 67
 68   # Generate mruby command
 69   #conf.gem :core => "mruby-bin-mruby"  <- コメントアウト
 70
 71   # Generate mruby-strip command
 72   #conf.gem :core => "mruby-bin-strip"  <- コメントアウト

標準出力の修正

実はもう1つ修正すべきところがあります。
mrubyのputspメソッドはC言語のprintstr()関数が呼び出されています。
35行目付近が実際に出力しているところで、よく知られている、printf()関数ではなくfwrite()で標準出力しています。
(printstr()の実装は./mrbgems/mruby-print/src/print.cにあります。)

# mrbgems/mruby-print/src/print.c
...
 35    fwrite(RSTRING_PTR(obj), RSTRING_LEN(obj), 1, stdout);
 36    fflush(stdout);

標準出力されたストリームの旅を3行で書くと下記のようになります。

  1. 標準出力されたストリームはごにょごにょされて、C言語の世界では最後にシステムコールのSYS_writevが呼ばれJavaSciptの世界へいきます。
  2. またJavaScriptの世界でこにょごにょされ最終的にconsole.logが呼ばれます。C言語の標準出力の果てはここ
  3. ごにょごにょの部分はバッファリングや改行がある場合はバッファからフラッシュするなど地味だけど大事なことをやっています。

3行目にさらっと書いていますが、改行がある場合はフラッシュということは、改行があるまで出力されない ということです。
この仕様のため、標準のmrubyの実装ではうまく動作しません。そのため、今回は以下のように修正して動作するようにします。

C言語側の修正

  • 文字列の最後が改行の場合
    • 修正なし
  • 文字列の最後が改行でない場合
    • Emscriptenのシェルは処理中の文字コード0の場合もフラッシュする挙動になっているため、0を書き込むことで強制的にフラッシュさせる。(ここ)

Ruby側の修正

  • Rubyのputspは文字列と改行を分けて出力しているがまとめる。

具体的には、以下のようにします。

// ./src/print.c
...
static void
printstr(mrb_state *mrb, mrb_value obj)
{
  if (mrb_string_p(obj)) {
#if defined(_WIN32)
    if (isatty(fileno(stdout))) {
      DWORD written;
      int mlen = (int)RSTRING_LEN(obj);
      char* utf8 = RSTRING_PTR(obj);
      int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8, mlen, NULL, 0);
      wchar_t* utf16 = (wchar_t*)mrb_malloc(mrb, (wlen+1) * sizeof(wchar_t));
      if (utf16 == NULL) return;
      if (MultiByteToWideChar(CP_UTF8, 0, utf8, mlen, utf16, wlen) > 0) {
        utf16[wlen] = 0;
        WriteConsoleW(GetStdHandle(STD_OUTPUT_HANDLE),
          utf16, wlen, &written, NULL);
      }
      mrb_free(mrb, utf16);
    } else
#endif
      fwrite(RSTRING_PTR(obj), RSTRING_LEN(obj), 1, stdout);
#ifndef EMSCRIPTEN
    fflush(stdout);
#else // WASMビルド時
    if(RSTRING_LEN(obj)>0){
      if(RSTRING_PTR(obj)[RSTRING_LEN(obj)-1] != 10){ // LF(改行)じゃなかったら
        fwrite(NULL, 1, 1, stdout);        // 強制flush
      }
    }
#endif
  }
}
...

Ruby側も修正します。

# ./mrblib/print.rb
...
  def puts(*args)
    i = 0
    len = args.size
    while i < len
      s = args[i].to_s
      s += "\n" if (s[-1] != "\n") # 一気に出力させる
      __printstr__ s
      i += 1
    end
    __printstr__ "\n" if len == 0
    nil
  end
...
  def p(*args)
    i = 0
    len = args.size
    while i < len
      __printstr__ args[i].inspect + "\n" # 一気に出力させる
      i += 1
    end
    args[0]
  end

mrubyのビルド

もう少しです。がんばりましょう。
ここまでできたら、makeを実行しビルドします。

make

ちょっとだけ時間がかかりますが、emccほどではありません。
以下のようなメッセージがでればビルド成功です。

================================================
      Config Name: wasm
 Output Directory: build/wasm
    Included Gems:
             mruby-sprintf - standard Kernel#sprintf method
             ... #中略
             mruby-class-ext - class/module extension
             mruby-compiler - mruby compiler library
================================================

これで、WebAssembly向けのmrubyライブラリができました。

Hello Worldを作る

ここでは、作成したmrubyライブラリを使った HelloWorldを作成します。
ビルドしたmrubyから、1つ上のディレクトリに移動しhelloディレクトリを作ります。
ディレクトリを作成したら移動します。

cd ..
mkdir hello
cd hello

ほぼ[公式ドキュメント]どおりですが、直下にhello.cを作成し、以下のようにコーディングします。

#include <stdio.h>
#include <mruby.h>
#include <mruby/compile.h>

int
main(void)
{
  mrb_state *mrb = mrb_open();
  if (!mrb) { /* handle error */ }
  puts("Executing Ruby code from C!");
  mrb_load_string(mrb, "p 'hello world! This message is executed Ruby!'"); // <- ここのRubyコードが実行される!
  mrb_close(mrb);
  return 0;
}

コンパイルします。オプションについては[公式ドキュメント]を参照してください。

emcc hello.c -I ../mruby/include ../mruby/build/wasm/lib/libmruby.bc -O2 -s WASM=1 -o hello.html

なお、-O2(最適化)をつけていますが、外した場合はサイズが大きいため以下のようなメッセージがでます。
warning: emitted code will contain very large numbers of local variables, which is bad for performance (build to JS with -O2 or above to avoid this - make sure to do so both on source files, and during 'linking')
(ファイルサイズも 400KBほど大きくなります)

ここまでの時点で以下のファイルができているはずです。

ls
# hello.c    hello.html hello.js   hello.wasm

ブラウザで試してみる

Webサーバへアップロードする

WebAssemblyのAPI自体が他からリソースをとる(XHR,Fetch)形式のためローカルで実行できません。
お好みのWebサーバに 、hello.html / hello.js / hello.wasm をアップロードしましょう。

ここでは、DockerでAapcheを立てますがお好きなWebサーバでお試しください。

下記コマンドでカレントディレクトリ内がドキュメントルートになったWebサーバができあがります。

docker run -dit --name my-apache-app -p 8080:80 -v "$PWD":/usr/local/apache2/htdocs/ httpd:alpine

動作確認

ブラウザでレッツアクセス!

image.png

デベロッパーツールも確認してみる

image.png

ちゃんと出力されてます!

※追記
画像のエラーはApacheの MIMEがちゃんと設定されてないから表示されるようです。
AddTypeなどで application/wasm を追加すれば消えます。

総括

ブラウザで、Opal等のエミュレータではなく、ネイティブのRubyが動かせる時代がきそうです!
(個人的にはRPGツクールのRGSSとか移植できたら面白そうとか思ったりした)

おまけ

サンプルで利用したものを公開しておきます。