たごもりすメモ

コードとかその他の話とか。

CRuby/JRubyで実行可能かつmrubyでビルド可能なコードを書く

msgpack-inspectを作った話に書いたが、このツールはエントリにも書いたとおり rubygems.org に公開されていて CRuby や JRuby でインストール・実行可能である。その一方でバイナリをダウンロードするだけで使えると便利だよねってことで、mrubyでクロスコンパイルしてリリース版が置いてある。

これは実はそこまで簡単ではなくて、Rubyの機能のうちmrubyでもサポートされている文法や組込みライブラリの範囲しか使えないのはもちろん、たとえば外部のライブラリに依存する機能*1などは mruby でクロスコンパイルしようとすると地獄を見ることなどもある。
そんな事情もあって、今回クロスコンパイルしたリリースに成功するまで、けっこうな手間をかけた。ここにそのへんをざらっと書いておこうと思う。

やったこと

mruby-cliを使う

基本的にmrubyでビルドするための準備はほぼmruby-cli頼り。クロスコンパイル用のDocker imageなども準備されてるので簡単に使える。mruby-cli -s NAME でプロジェクトツリーを生成するだけ。

ただし(特にCRubyとしての動作との共存を考えると)現在リリースされている v0.0.4 ではいくつか問題があった。ので、パッチを書いて pull-request を送り、自分はそのパッチ済みのものをビルドしなおして使っていた。今ではそのへんの pull-request は全部マージされているので、単に clone してビルド(docker-compose run release)すれば使えるバイナリが手に入ると思う。

なおCRubyとの共存を考えなくても、現在の master:HEAD には release とかの便利タスクが入っているので、mruby-cliを使うなら自分でビルドしなおして使うといいと思う。

注意点として bundle gem は name-part という名前を name/part というディレクトリに展開するが mruby-cli は name-part というディレクトリを作ってしまう。手作業で修正すればいい話だが、ビルド設定とかに不整合が残ると面倒なことになるので注意すること。

bundle gemの出力を統合する

mruby-cli だけだと mruby でビルドするだけのファイルしか作られないので、そこに普通のgemとしてリリースするためのファイルを生成して統合する。どうやるかというと別ディレクトリで bundle gem NAME してファイルツリーを生成し、以下のディレクトリおよびファイルを mruby-cli で生成したディレクトリツリーに持ってくるだけ。ほとんどのものは競合しない。

  • Gemfile
  • README.md
  • exe/
  • lib/
  • NAME.gemspec

もちろん bundle gem した結果に mruby-cli -s の内容を統合してもいいけど、mruby側のほうがビルドまわりで必要なものが多くてミスるとハマる*2ので、この順番のほうがいいと思う。

唯一衝突するのが Rakefile で、これを mruby 関連のタスクと rubygems まわりのタスクを衝突しないようマージしてやる必要がある。自分が作ったものはこちらにある。

msgpack-inspect/Rakefile at master · tagomoris/msgpack-inspect · GitHub

何をやるかというとつまり docker-compose で実行されているときは mruby 関連タスクを優先し、そうでない場合には rubygems まわりのタスクを有効化する、というだけ。特に release というタスクがカブるので実行時に有効にする方はどちらかを決めている。

# https://github.com/tagomoris/msgpack-inspect/blob/master/Rakefile#L103-L128
if is_in_a_docker_container?
  load File.join(File.expand_path(File.dirname(__FILE__)), "mrbgem.rake")

  current_gem = MRuby::Gem.current
  app_version = MRuby::Gem.current.version
  APP_VERSION = (app_version.nil? || app_version.empty?) ? "unknown" : app_version

  task default: :compile
else
  Rake::Task['release'].clear # to clear release tasks to create mruby binary
  require "bundler/gem_tasks"
  require 'rake/testtask'
  # require 'rake/clean'

  Rake::TestTask.new(:test) do |t|
    # To run test for only one file (or file path pattern)
    #  $ bundle exec rake test TEST=test/test_specified_path.rb
    #  $ bundle exec rake test TEST=test/test_*.rb
    t.libs << "test"
    t.test_files = Dir["test/**/test_*.rb"].sort
    t.verbose = true
    t.warning = true
    t.ruby_opts = ["-Eascii-8bit:ascii-8bit"]
  end
  task default: [:test, :release]
end
実行ファイルのエントリポイントを分ける

実行可能コマンドに仕立てるにあたり、入口の部分だけはちょっと分岐が必要。rubygems側は spec.bindir に書いたディレクトリに実行可能rubyスクリプトとして置いておく必要があるし、mruby-cliの場合は __main__ というメソッドで定義してやる必要がある。

このため、おおむね以下のような構成にした。

  • CRuby側
  • exe/msgpack-inspect: 実行ファイル
    • lib/msgpack/inspect/command.rb をrequireして実行する(だけ)
  • lib/msgpack/inspect/command.rb
    • 実行時に必要なファイルをすべてrequireする
    • コマンドラインオプションの解析
    • メインの実行コードを呼ぶ
  • mruby側
  • mrblib/msgpack-inspect.rb

この「メインの実行コードを呼ぶ」以降はすべて完全に同一のファイルを使っている。

ファイルをsymlinkで共有

「以降はすべて完全に同一のファイルを使っている」だが、これは mrblib 以下のファイルツリーを、エントリポイントにあたる1ファイルを除き、すべて lib 以下のファイルツリー(の必要なファイル)への symbolic link として作成することで実現している。コピーとかは邪悪なのでしない。Pure Rubyで書かれているならこれで問題ない。

問題ぽいものはひとつだけあって、普通にテスト等を実行すると Rakefile の構造上、かならず NAME.gemspec と mrbgem.rake の両方がロードされてしまう。デフォルトでこれらのファイルはそれぞれ lib/NAME/version.rb と mrblib/NAME/version.rb を読むようになっているが、これらは実際には同じファイルであって、このソフトウェアのバージョンを示す定数 Name::VERSION が2度定義されてしまう(警告が出る)。
これはもう面倒だったので mrbgem.rake 側の参照を lib/NAME/version.rb に変えてしまって対応した、ことにした。

requireをエントリポイントに並べる

mrubyはデフォルトでは mrblib 以下のものをすべて読み込みリンクした状態となるようビルドする*3。このためあるコードが他のファイルに書かれている定義を参照する場合でも require などは必要ない。し、できない。mruby-require というものはあるが、……あれ? これはなんでやめておいたんだっけ。忘れた。

まあ mruby-require を使っていない場合には require とコードに書かれていると動かない。ただしCRuby/JRubyではもちろん必要なファイルはrequireされなければならないので、次善の策としてCRuby/JRuby側でのみ読まれるファイル*4に必要なすべてのファイルをrequireするように書いてしまった。

テストはCRubyだけ

mruby-cli にもテスト記述の支援のための機構はあるようで bintest というディレクトリがある。が、コマンド全体に対しての end-to-end テストのみという感じなので unit test の粒度のテストを書きたいときにちょっときびしい。

なので、とりあえずとして CRuby で動かすのを前提として普通に unit test を書いた。これは rake test すればCRubyで普通に走る。CRubyとmrubyの互換性が保たれている限りは、これでも問題ないだろう。むしろ最終的に mruby にコンパイルするんであっても unit test が書けて便利。

CRubyとmrubyにおける挙動の分岐

msgpack-inspect には外部のRubyスクリプトを読んでそこに定義してある処理を実行する、という機能が存在する。またこの機能は外部の msgpack.gem を必要とするためmrubyでは実行できない。
これをどうやって実現するか……正確にはこの機能を実現しているコードをそのままmrubyで動かすか、ということをちょっと考える必要があった。

しかしまあ、そんなに難しくはない。単に外部スクリプトが読まれていたら存在するはずの定数を確認してみて、それを条件に分岐するだけ。

MSGPACK_LOADED = MessagePack.const_defined?('Unpacker')

def foobar(value)
  @value = MSGPACK_LOADED ? MessagePack.unpack(value) : nil
end

上記の内容だけだったらメソッド定義の内容そのものを変更する(ifの内側にdefを書く)とかの選択肢もあるけど、実際のコードではメソッド全体で分岐しているわけではない(共通の処理もけっこうある)ので、まあいいか、とコード内で if で分岐してしまった。

このコードは正確にはランタイム(CRubyとmruby)で分岐しているわけではないけど、逆に言うと、サポートされている機能で分岐したいならこれで充分、ということ。

ハマったこと

順調にはいかなくて、ハマったこともいろいろ。

キーワード引数が使えない

なつかしの def foo(a, b, opts={}) みたいなコードをひさしぶりに書いた。

使えるメソッドがわからない

API documentはあるんだけど、なんかちょいちょい抜けてることがある。あと普通のRubyのつもりで書いてたらうっかりサポートされてなかったりとか。無いなら無いで mruby-pack みたいなmrbgemがあったりでそっちを使えばいいんだけど、本当に無いの? みたいなのを確認できないのはちょっと困る。

で、今となっては、最終的にはコードを毎回見てる。mruby-cliを使っていると必ずmrubyのコードツリーがリポジトリの mruby/ 以下に展開されてるのでコードを読みにいく手間が少なくて便利。
ドキュメントに足りないものがあれば pull-req すればいいんだけど、YARDがまったくわからなくて……どうなってるのこれ。

mruby-yamlのビルド失敗

最初はYAML出力をCRubyの添付モジュールで済ませようかと思ってたんだけど、これはもちろん mruby ではサポートされていなくて、しょうがないからmruby-yamlを使おうとしてみたら mruby-cli のクロスコンパイルがぜんぜんうまくいかない。しばらく試行錯誤してたんだけど、あまりにも不毛だったんで諦めた。

で、どうしたかというと、YAMLのフォーマッタくらい自分で書けばいいじゃん! Pure Rubyで書けば mruby でも問題なく使えるし! (ピコーン!)

で、書いた。ついでにJSONのフォーマッタも書いた。ガッとな。

実はこのトラブルのせいだけでもなくて、他にもエラーハンドリング向上のためにストリームフォーマッタ*5が欲しかったので、どっちにしろそのうち書く必要はあったのだった。

mruby側で実行時にRubyで記述されている定義が見付からない

これはmrubyでのビルドに成功した後でバイナリを実行したら起きた現象で、mrblib以下に(symlinkが)置いてある .rb ファイルに定義されてるクラスが見付からねーぞ、というもの。なんでやねん。

あれこれ試行錯誤したけど mrbgem.rake でビルド対象(?)のファイルを明示的に指定するようにしたらうまくいくようになった。具体的には rbfiles の指定を足す(デフォルトでは存在しない)。

spec = MRuby::Gem::Specification.new('msgpack-inspect') do |spec|
  spec.rbfiles = [
    "mrblib/msgpack/inspect/version.rb",
    "mrblib/msgpack/inspect/node.rb",
    "mrblib/msgpack/inspect/streamer.rb",
    "mrblib/msgpack/inspect/inspector.rb",
    "mrblib/msgpack/inspect.rb",
    "mrblib/msgpack-inspect.rb",
  ]
  spec.bins    = ['msgpack-inspect']
  spec.add_dependency 'mruby-io', mgem: 'mruby-io'
  spec.add_dependency 'mruby-pack', mgem: 'mruby-pack'
  spec.add_dependency 'mruby-print', core: 'mruby-print'
  spec.add_dependency 'mruby-mtest', mgem: 'mruby-mtest'
end

このとき最初は mrblib 以下のファイルを適当に glob で展開しようとしたんだけど、そうすると今度は undefined method __main__ とか言われてビルドに失敗する。 __main__ は mruby-cli のお約束として実行時の入口になるメソッドで tools 以下に置かれた .c のコードから呼ばれる。
ちゃんと書いてあるのに見付からないとかなんでだ!!! と思ったんだけど、どうもこれは rbfiles の最後のファイル(この例では mrblib/msgpack-inspect.rb")に無いといけないようだ。理由はちゃんと調べてなくてわからない……。ということで glob を使わずに明示的にファイルのリストを並べることにした。

mrubyでの文字コードの扱い

mrubyはM17N(多言語文字コード)をサポートしていない。ので、Stringオブジェクトはかならずどれかの文字コードということになって、これはmrubyのビルド時に決定される。具体的には MRB_UTF8_STRING が定義されていれば utf8 に、そうでなければバイナリになる。あと、これに伴ってエンコーディング操作関連のメソッドも String クラスに存在しない。

これはなかなか困ったことで、エンコーディング操作は普通にCRubyのコードだとあちこちに埋まってるので、そのたびごとに実装ごとの呼び出し切り替えみたいなのをするのもちょっと面倒くさい。
……ので、えいやっとそのへんを誤魔化すコードを mruby 向けのエントリポイントになるファイルに追加してしまった。

class String
  # It's only for mruby... Encoding of String are defined by MRB_UTF8_STRING (or undef it) on build time.
  # Default is disabled, and this tool is built under that configuration.
  def force_encoding(encoding)
    self
  end
  def b
    self
  end
end

Rubyだからこういうこともやってしまえる。べんり(?)。

まとめ

ちょいちょいハマる部分があったが、なんとかやっつけられた。

CRubyとmrubyの両方で動かせる状態になると、とりあえず試すならバイナリをダウンロードして実行、複雑な機能を使いたい場合にはrubygem版を入れて他のスクリプトと連携、みたいなことを選択的にできるようになるのでだいぶ便利だと思う。みんなもぜひ真似して他のハマりポイントを潰して回ろう。

(なお上述のことをだいたいやってくれる ruby-cli というツールを作ろうかと思ったのだが、この名前は既にrubygems.orgで取られていたのでやる気を失った。)

*1:CRubyのYAMLサポートはたとえば libyaml に依存する

*2:自分は tools/ 以下を持っていきわすれて一度盛大にハマった

*3:ように自分は理解している、が、理解が不十分な気はしている。後述。

*4:この場合は lib/msgpack/inspect/command.rb

*5:オブジェクトを生成するはしから、終端されていなくてもとにかく出力していけるフォーマッタ