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

  • 17
    Like
  • 0
    Comment

はじめに

Rubyがブラウザで動作する

先日、主要ブラウザでWebAssemblyを利用できる環境が整ったと話題になりました。
このことから、今後はWebアプリ(特にフロントエンド)でJavaScript系以外にも他の言語の採用が選択肢にはいるようになります。(検索すると、Rustがよく引き合いに出されています。)

本コンテンツはmrubyインタプリタWebAssemblyに変換し、「ブラウザ上でRuby(p "Hello world")が動作する(コンソール出力)」ところまでを目標とします。余談ですが、理屈上CRubyもWebAssembly化できるはずですが、ここではmrubyをつかいます。

対象者

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

・・・が

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

免罪符的な(読み飛ばしてOK)

中の人(noontage)の本業はWeb系ではありません。(エクセル大好きなおじさんが多いあの業界の人です。)
よって、誤った表記があるかもしれませんが、ツッコミは大歓迎ですのでフィードバックいただけますと幸いです。

環境準備

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

項目 備考
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サーバなら npm でも 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分くらい時間がかかります。アニメ1話でも消化しましょう。
./emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit

# emccのパスが通るので展開場所を意識したほうがいいかもしれません。
./emsdk activate --global --build=Release sdk-incoming-64bit binaryen-master-64bit
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.defines = %w(_WASM) # 後述
    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でllvmを固めます。
  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__(...)関数が呼び出されています。
__printstr__(...)の実装は./mrbgems/mruby-print/src/print.cにあります。

35行目付近が実際に出力しているところです。printf(...)ではなくfwrite(...)で標準出力に吐いています。

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

このままではうまく動作しませんでした。(空白が出力されます。)
少し調べましたがなぜうまく動作しないのか、私もよくわかりませんでした。(詳しい方がいらっしゃったらぜひ教えてください)

[ヒントになりそうなところ]
- FileSystemAPI#setting-up-standard-i-o-devices (URL)
- emccにおけるfwriteの実装 (URL)

今回はとりあえずprintf(...)に書き換えます。ここで、先程定義した_WASMのマクロを判定しています。

...
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
#if defined(_WASM)
    if((*(RSTRING_PTR(obj)+RSTRING_LEN(obj)) == '\0')){
      printf("%s",RSTRING_PTR(obj)); // WASMビルド時は printfを使う
    }
#else
      fwrite(RSTRING_PTR(obj), RSTRING_LEN(obj), 1, stdout);
    fflush(stdout);
#endif
  }
}
...

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

ブラウザで動作確認

レッツアクセス http://localhost:8080/hello.html

できあがり

image.png

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

image.png

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

(・・・なんか、エラーでてるけど、これ公式のチュートリアルでもでるんだよね)

総括

ブラウザで、Opal等のエミュレータではなくネイティブのRubyが動かせる時代がきそうです!

(個人的にはRPGツクールのRGSSとか移植できたら面白そうとか思ったりした)