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
- __main__ の実装
- コマンドラインオプションの解析
- メインの実行コードを呼ぶ
この「メインの実行コードを呼ぶ」以降はすべて完全に同一のファイルを使っている。
ファイルを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だからこういうこともやってしまえる。べんり(?)。