この記事はmrubyファミリー Advent Calendar 2023の22日目の記事です。 昨日は@kurod1492(黒谷 明大)さんのPicoRubyでMatz葉がにロボコンに挑戦でした。
私はrfというRubyで書けるCLIのテキストフィルタツールを作っています。 詳しくは昨日の記事を参照してください。
このrfはほぼmrubyで作っています。 今日はrfを作っているときに得られたmrubyでCLIツールを作るときのTipsについて書こうと思います。
なぜmrubyでCLIツールを作るか?
1番のモチベーションはRubyでかけることです。 しかし、Rubyを使いだけならRubyのスクリプトファイルでも良いと思うかもしれません。 Rubyスクリプトを実行するためにはRubyのコードを実行する環境(CRubyなど)や、そのスクリプトが利用しているgemをインストールする必要があります。 mrubyではバイナリファイルとして生成することができるので、このバイナリファイルを配置すれば実行できるというポータビリティの高さがあります。 バイナリファイルだとクロスプラットフォーム対応が大変に思うかもしれませんが、zigを使うことで解決できます。 これについて以下の記事に詳しく書かれているので参照してください。
Zigを使ってWindows向けのクロスビルドはできない?
これについては以前記事を書いたのでそちらを参照のこと
mruby-buildを使ったクロスビルド
Zigを使えばクロスビルドが簡単にできます。 しかし、MacOS向けにライブラリを入れたりしないといけないので準備が大変です。 そこで、それらの準備を簡単にするためにmruby-buildというツールを作・・・るつもりだったのですがまだDockerイメージしかないです。
このDockerイメージを使うとLinux向け、MacOS向け、Windows向けにクロスビルドできます。 やり方は、まず以下のようなbuild_config.rbを作成します。
def gem_config(conf) conf.gembox 'default' # be sure to include this gem (the cli app) conf.gem File.expand_path(File.dirname(__FILE__)) end MRuby::Build.new do |conf| conf.toolchain conf.enable_bintest conf.enable_debug conf.enable_test gem_config(conf) end MRuby::CrossBuild.new('linux-armhf') do |conf| [conf.cc, conf.linker].each do |cc| cc.command = 'zig cc -target arm-linux-musleabihf' end conf.archiver.command = 'zig ar' conf.host_target = 'arm-linux-musleabihf' gem_config(conf) end require 'shellwords' MRuby::CrossBuild.new('darwin-x86_64') do |conf| macos_sdk = ENV.fetch('MACOSX_SDK_PATH') conf.cc.command = "zig cc -target x86_64-macos -mmacosx-version-min=10.14 -isysroot #{macos_sdk.shellescape} -iwithsysroot /usr/include -iframeworkwithsysroot /System/Library/Frameworks" conf.linker.command = "zig cc -target x86_64-macos -mmacosx-version-min=10.4 --sysroot #{macos_sdk.shellescape} -F/System/Library/Frameworks -L/usr/lib" conf.archiver.command = 'zig ar' ENV['RANLIB'] ||= 'zig ranlib' conf.host_target = 'x86_64-darwin' gem_config(conf) end MRuby::CrossBuild.new('darwin-aarch64') do |conf| macos_sdk = ENV.fetch('MACOSX_SDK_PATH') conf.cc.command = "zig cc -target aarch64-macos -mmacosx-version-min=11.1 -isysroot #{macos_sdk.shellescape} -iwithsysroot /usr/include -iframeworkwithsysroot /System/Library/Frameworks" conf.linker.command = "zig cc -target aarch64-macos -mmacosx-version-min=11.1 --sysroot #{macos_sdk.shellescape} -F/System/Library/Frameworks -L/usr/lib" conf.archiver.command = 'zig ar' ENV['RANLIB'] ||= 'zig ranlib' conf.host_target = 'aarch64-darwin' gem_config(conf) end # Windowsはgccでビルドする MRuby::CrossBuild.new('windows-amd64') do |conf| toolchain :gcc conf.build_target = 'x86_64-pc-linux-gnu' conf.host_target = 'x86_64-w64-mingw32' [conf.cc, conf.linker].each do |cc| cc.command = "#{conf.host_target}-gcc-posix" cc.flags << '-static' end conf.cxx.command = "#{conf.host_target}-g++" conf.archiver.command = "#{conf.host_target}-gcc-ar" conf.exts do |exts| exts.object = '.obj' exts.executable = '.exe' exts.library = '.lib' end gem_config(conf) end
つぎに以下のようなコマンドを実行するとビルドされます。
$ docker run --rm -v $(pwd):/src buty4649/mruby-build:master
ビルド後は build/<build_target>/binにバイナリが出力されます。
$ ls -lah build/*/bin/mrbtest .rwxr-xr-x buty4649 buty4649 5.9 MB Fri Dec 22 17:30:33 2023 build/darwin-aarch64/bin/mrbtest .rwxr-xr-x buty4649 buty4649 4.4 MB Fri Dec 22 17:30:16 2023 build/darwin-x86_64/bin/mrbtest .rwxr-xr-x buty4649 buty4649 4.8 MB Fri Dec 22 17:29:46 2023 build/host/bin/mrbtest .rwxr-xr-x buty4649 buty4649 8.8 MB Fri Dec 22 17:30:03 2023 build/linux-armhf/bin/mrbtest
テストの実行
mrubyのコードのテストはmruby-testを使ってテストすることができます。
しかし、このテストコードを実行するにはmrubyのバイナリをビルドする必要があります。 テストコードの実行にビルドが必要なのであれば、直接CLIの動作を確認しても同じだろうと思い、rfではarubaを使ってCLIツールの動作をテストしています。
arubaの使い方はまた今度…
Kernel.exitのexitステータスを正しくハンドリングする
mruby-exitを使うとKernel.exitが使えるようになります。 これによりプロセスの終了ステータスを設定することができます。 しかし、Kernel.exitはexit(3)を呼び出すわけではなく、SystemExit例外をスローするだけです。 このSystemExit例外をハンドリングしない場合は以下のようにexitステータスが1になります。
# Rubyのコード $ cat mrblib/mrbtest.rb def __main__(argv) if argv[1] == "version" puts "v#{Mrbtest::VERSION}" else puts "Hello World" end # exitステータスが10であることを期待 exit(10) end # ビルドしたバイナリを実行すると例外がスローされたことがわかる $ ./mruby/bin/mrbtest Hello World trace (most recent call last): [2] (unknown):0 [1] /home/buty4649/work/mruby-testcode/mrblib/mrbtest.rb:7:in __main__ /home/buty4649/work/mruby-testcode/mrblib/mrbtest.rb:7:in exit: SystemExit # exitステータスが1になっている $ echo $? 1
SystemExit例外をハンドリングし、正しくexitステータスを受け取るには以下のようにします。
/* mrubyのコードを実行 */ mrb_funcall(mrb, mrb_top_self(mrb), "__main__", 1, mrb_argv); return_value = EXIT_SUCCESS; if (mrb->exc) { /* 例外がSystemExitであるか? */ if (!MRB_EXC_EXIT_P(mrb->exc)) { /* SystemExit例外以外ならエラー内容を表示してEXIT_FAILURE(=1) */ mrb_print_error(mrb); return_value = EXIT_FAILURE; } else { /* SystemExitならstatusというインスタンス変数からexitステータスを取得 */ mrb_value exit_status = mrb_iv_get(mrb, mrb_obj_value(mrb->exc), mrb_intern_lit(mrb, "status")); return_value = mrb_fixnum(exit_status); } } mrb_close(mrb); return return_value;
この変更で正しくKernel.exitを扱えるようになります。
$ ./mruby/bin/mrbtest ; echo $? Hello World 10
おわりに
mrubyでCLIツールを作るときのTipsをまとめてみました。 mrubyを使ってCLIツールを作る人は少ないかもしれませんが、誰かの役に立つと嬉しいです。