更新サマリー
- 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" <- コメントアウト
標準出力の修正
- 2017/11/28追記
fflush()
関数の実装の仕様で\n
がないfflush()
関数は動作しないため対処を追記- https://github.com/kripken/emscripten/issues/2770
実はもう1つ修正すべきところがあります。
mrubyのputs
やp
メソッドは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行で書くと下記のようになります。
- 標準出力されたストリームはごにょごにょされて、C言語の世界では最後にシステムコールの
SYS_writev
が呼ばれJavaSciptの世界へいきます。 - またJavaScriptの世界でこにょごにょされ最終的に
console.log
が呼ばれます。C言語の標準出力の果てはここ - ごにょごにょの部分はバッファリングや改行がある場合はバッファからフラッシュするなど地味だけど大事なことをやっています。
3行目にさらっと書いていますが、改行がある場合はフラッシュということは、改行があるまで出力されない ということです。
この仕様のため、標準のmrubyの実装ではうまく動作しません。そのため、今回は以下のように修正して動作するようにします。
C言語側の修正
- 文字列の最後が改行の場合
- 修正なし
- 文字列の最後が改行でない場合
- Emscriptenのシェルは処理中の文字コード
0
の場合もフラッシュする挙動になっているため、0
を書き込むことで強制的にフラッシュさせる。(ここ)
- Emscriptenのシェルは処理中の文字コード
Ruby側の修正
- Rubyの
puts
やp
は文字列と改行を分けて出力しているがまとめる。
具体的には、以下のようにします。
// ./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
動作確認
ブラウザでレッツアクセス!
デベロッパーツールも確認してみる
ちゃんと出力されてます!
※追記
画像のエラーはApacheの MIMEがちゃんと設定されてないから表示されるようです。
AddTypeなどで application/wasm を追加すれば消えます。
総括
ブラウザで、Opal等のエミュレータではなく、ネイティブのRubyが動かせる時代がきそうです!
(個人的にはRPGツクールのRGSSとか移植できたら面白そうとか思ったりした)
おまけ
サンプルで利用したものを公開しておきます。