ぶていのログでぶログ

思い出したが吉日

rfを作っているときに得られたmrubyでCLIツール作成のTips

この記事はmrubyファミリー Advent Calendar 2023の22日目の記事です。 昨日は@kurod1492(黒谷 明大)さんのPicoRubyでMatz葉がにロボコンに挑戦でした。


私はrfというRubyで書けるCLIのテキストフィルタツールを作っています。 詳しくは昨日の記事を参照してください。

tech.buty4649.net

このrfはほぼmrubyで作っています。 今日はrfを作っているときに得られたmrubyでCLIツールを作るときのTipsについて書こうと思います。

なぜmrubyでCLIツールを作るか?

1番のモチベーションはRubyでかけることです。 しかし、Rubyを使いだけならRubyのスクリプトファイルでも良いと思うかもしれません。 Rubyスクリプトを実行するためにはRubyのコードを実行する環境(CRubyなど)や、そのスクリプトが利用しているgemをインストールする必要があります。 mrubyではバイナリファイルとして生成することができるので、このバイナリファイルを配置すれば実行できるというポータビリティの高さがあります。 バイナリファイルだとクロスプラットフォーム対応が大変に思うかもしれませんが、zigを使うことで解決できます。 これについて以下の記事に詳しく書かれているので参照してください。

k0kubun.hatenablog.com

Zigを使ってWindows向けのクロスビルドはできない?

これについては以前記事を書いたのでそちらを参照のこと

tech.buty4649.net

mruby-buildを使ったクロスビルド

Zigを使えばクロスビルドが簡単にできます。 しかし、MacOS向けにライブラリを入れたりしないといけないので準備が大変です。 そこで、それらの準備を簡単にするためにmruby-buildというツールを作・・・るつもりだったのですがまだDockerイメージしかないです。

github.com

この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を使ってテストすることができます。

github.com

しかし、このテストコードを実行するにはmrubyのバイナリをビルドする必要があります。 テストコードの実行にビルドが必要なのであれば、直接CLIの動作を確認しても同じだろうと思い、rfではarubaを使ってCLIツールの動作をテストしています。

github.com

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ツールを作る人は少ないかもしれませんが、誰かの役に立つと嬉しいです。