💍

RustでRubyの拡張を作る時にrb_sys gemは不要になっていた

に公開1

タイトルだけだと分からないですよね。

RustでRubyの拡張ライブラリーを作る時、

% bundle gem --ext=rust my_ext

と、--ext=rustオプションを付けてコマンドを実行してプロジェクトの雛形を作ると思います。
こうすると様々なボイラープレートを作ってくれて便利なのですが、2025年10月現在では不要になった物も作られていることに気が付きました。

環境は

% ruby -v
ruby 3.4.7 (2025-10-08 revision 7a5688e2a2) +PRISM [arm64-darwin24]
% gem -v
3.7.2

です。

このバージョンで上のコマンドを実行すると以下のファイルが作られます:

my_ext
├── bin
│   ├── console
│   └── setup
├── Cargo.toml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── ext
│   └── my_ext
│       ├── Cargo.toml
│       ├── extconf.rb
│       └── src
│           └── lib.rs
├── Gemfile
├── lib
│   ├── my_ext
│   │   └── version.rb
│   └── my_ext.rb
├── my_ext.gemspec
├── Rakefile
├── README.md
├── sig
│   └── my_ext.rbs
└── test
    ├── my_ext_test.rb
    └── test_helper.rb

my_ext.gemspecにこんな記述があります:

spec.add_dependency "rb_sys", "~> 0.9.91"

このrb_sys gemは、gemのインストール時に、いい感じにRustコードをビルドするのに使われます。言い換えると、インストールが終わってしまえば、その後は不要な依存gemなのです。
その為だけにユーザーの環境にインストールさせてしまうのは、何か嫌な感じですね。

ただ、magnusREADMEには次のように書いてあります(v0.8.2時点):

Note: The newest version of rubygems does have beta support for compiling Rust, so in the future the rb_sys gem won't be necessary.

訳:

:rubygemsの最新バージョンではRustのコンパイルがベータ版としてサポートされているので、将来rb_sysは不要になります。

「将来」とは? 実は、その未来は既に訪れていたのです。
というわけで、出来たプロジェクト雛形からrb_sys依存を削除していきましょう。

extconf.rbを削除

ユーザーが

% gem install my_ext

とした時は、(ツールとしての)RubyGems(gemコマンド)がmy_ext.gemspec内の

spec.extensions = ["ext/my_ext/extconf.rb"]

という記述に気が付いて、

このファイルを実行
-> 生成されたMakefileを実行
-> ビルドされた拡張ライブラリーをlibディレクトリーにコピー
-> 後始末

という処理を走らせます。このファイルの中身は短くて

# frozen_string_literal: true

require "mkmf"
require "rb_sys/mkmf"

create_rust_makefile("my_ext/my_ext")

だけです。require "rb_sys/mkmf"rb_sys gemへの依存で、create_rust_makefilerb_sysが提供する関数です。

これがあるために、rb_sysへの依存が必要になっていたのです。
このファイル自体を消してしまいましょう。

extconf.rbからCargo.tomlに切り替え

そうすると当然RubyGemsは宣言されたext/my_ext/extconf.rbを見付けられないわけですから、gemのインストール時にビルドができません。
代わりにCargo.tomlを使います。my_ext.gemspecを書き換えます:

my_ext.gemspec
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
   spec.bindir = "exe"
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
-  spec.extensions = ["ext/my_ext/extconf.rb"]
+  spec.extensions = ["ext/my_ext/Cargo.toml"]
 
   # Uncomment to register a new dependency of your gem
   # spec.add_dependency "example-gem", "~> 1.0"

プロジェクトルートにあるCargo.tomlではなくてext/my_extディレクトリーのCargo.tomlであることに注意してください。

こうすることによって、gemのインストール時にmakeではなくてcargoを使うようになります。

実はこれだけで基本的な対応はお終いです。試してみましょう。

my_ext.gemspecに次の変更……

  • rb_sysへの依存を削除
  • TODO: の所を削除
  • spec.metadataを削除
  • spec.homepageに適当なURIを挿入

をして、

my_ext.gemspec
@@ -8,17 +8,12 @@ Gem::Specification.new do |spec|
   spec.authors = ["Kitaiti Makoto"]
   spec.email = ["KitaitiMakoto@gmail.com"]
 
-  spec.summary = "TODO: Write a short summary, because RubyGems requires one."
-  spec.description = "TODO: Write a longer description or delete this line."
-  spec.homepage = "TODO: Put your gem's website or public repo URL here."
+  spec.summary = "Write a short summary, because RubyGems requires one."
+  spec.description = "Write a longer description or delete this line."
+  spec.homepage = "https://example.net/"
   spec.required_ruby_version = ">= 3.2.0"
   spec.required_rubygems_version = ">= 3.3.11"
 
-  spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
-  spec.metadata["homepage_uri"] = spec.homepage
-  spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
-  spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
-
   # Specify which files should be added to the gem when it is released.
   # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
   gemspec = File.basename(__FILE__)
@@ -35,7 +30,6 @@ Gem::Specification.new do |spec|
 
   # Uncomment to register a new dependency of your gem
   # spec.add_dependency "example-gem", "~> 1.0"
-  spec.add_dependency "rb_sys", "~> 0.9.91"
 
   # For more information and examples about making a new gem, check out our
   # guide at: https://bundler.io/guides/creating_gem.html

ビルド&インストールです。
ビルドコマンドでパッケージファイルを作って:

% gem build my_ext.gemspec
WARNING:  licenses is empty, but is recommended. Use an license identifier from
https://spdx.org/licenses or 'Nonstandard' for a nonstandard license,
or set it to nil if you don't want to specify a license.
WARNING:  See https://guides.rubygems.org/specification-reference/ for help
  Successfully built RubyGem
  Name: my_ext
  Version: 0.1.0
  File: my_ext-0.1.0.gem

インストールして:

% gem install my_ext-0.1.0.gem
Building native extensions. This could take a while...
Successfully installed my_ext-0.1.0
Parsing documentation for my_ext-0.1.0
Installing ri documentation for my_ext-0.1.0
Installing darkfish documentation for my_ext-0.1.0
Done installing documentation for my_ext after 0 seconds
Installing YARD documentation for my_ext-0.1.0...
Done installing documentation for my_ext after 0 seconds
1 gem installed

使ってみる:

% ruby -rmy_ext -e 'pp MyExt.hello("Cargo Builder")'
"Hello from Rust, Cargo Builder!"

ね、*.gemspecextconf.rbCargo.tomlに書き換える、たったこれだけ、驚いたんではないでしょうか。

開発環境の整備

とは言え、これではRakefileを中心に作られた開発環境ではうまく動きません(bundle exec rakeしてみてね)。このまま開発するのは現実的ではないので整えていきましょう。

まずRakefileからrb_sys gemに依存しているところをごっそり削ります:

Rakefile
@@ -9,14 +9,4 @@ Rake::TestTask.new(:test) do |t|
   t.test_files = FileList["test/**/*_test.rb"]
 end
 
-require "rb_sys/extensiontask"
-
-task build: :compile
-
-GEMSPEC = Gem::Specification.load("my_ext.gemspec")
-
-RbSys::ExtensionTask.new("my_ext", GEMSPEC) do |ext|
-  ext.lib_dir = "lib/my_ext"
-end
-
-task default: %i[compile test]
+task default: :test

モチベーションは「ユーザーがインストールする時にrb_sysをインストールさせるのが何か嫌」だったので、実行時依存じゃなくて開発時依存に移せばいいだけの気がしますが、うまくいきません。
RbSys::ExtensionTask(の親クラスのRake::ExtensionTask)がextconf.rbの存在を前提としているためです。

削ったビルドタスクの代わりに、では、どうしたらいいのでしょうか。
ext/my_extに移動してcargo buildを実行したらいいような気がするものの、エラーになってしまいます(少なくとも僕の環境では)。でも、さっき試したように、gemコマンドでのインストールではうまくいったんですよね。
なので、RubyGemsと同じ方法でビルドすればいい。Gem::Ext::CargoBuilderを使いましょう。

Rakefile
@@ -2,6 +2,7 @@
 
 require "bundler/gem_tasks"
 require "rake/testtask"
+require "rubygems/ext"
 
 Rake::TestTask.new(:test) do |t|
   t.libs << "test"
@@ -9,4 +10,12 @@ Rake::TestTask.new(:test) do |t|
   t.test_files = FileList["test/**/*_test.rb"]
 end
 
+file "lib/my_ext/my_ext.#{RbConfig::CONFIG['DLEXT']}" do
+  results = Rake.verbose == true ? $stdout : []
+  Gem::Ext::CargoBuilder.new.build "ext/my_ext/Cargo.toml", ".", results, [], "lib", File.expand_path("ext/my_ext")
+end
+
+CLEAN.include "lib/my_ext/my_ext.#{RbConfig::CONFIG['DLEXT']}"
+
+task test: "lib/my_ext/my_ext.#{RbConfig::CONFIG['DLEXT']}"
 task default: :test

RbConfig::CONFIG['DLEXT']は共有ライブラリーの拡張子で、LinuxだったらsoとかmacOSだったらbundleとかWindowsだったら(多分)dllとかになります。lib/my_ext/my_ext.bundleみたいなファイルの作り方を設定しているということです。
results変数は実行時の出力が渡されるオブジェクトで、rake -vオプションを付けた時(Rake.verbose == true)は表示して、それ以外の時は無視するようにしました。
ファイルをCLEAN.includeに渡すことでrake cleanタスクによって消すことができます。
task test: "..."で、testタスクの依存としてファイルを登録することになるので、テスト前にファイルが存在していなかったり、古くなっていたら勝手にビルドしてくれます(存在していて最新版だったら何もしない)。

これでrakeコマンドを実行すれば、正しく(?)失敗します:

% bundle exec rake
Loaded suite /Users/kitaitimakoto/src/gitlab.com/KitaitiMakoto/my_ext/vendor/bundle/ruby/3.4.0/gems/rake-13.3.0/lib/rake/rake_test_loader
Started
F
===============================================================================
Failure: test: something useful(MyExtTest)
/Users/kitaitimakoto/src/gitlab.com/KitaitiMakoto/my_ext/test/my_ext_test.rb:13:in 'block in <class:MyExtTest>'
     10:   end
     11: 
     12:   test "something useful" do
  => 13:     assert_equal("expected", "actual")
     14:   end
     15: end
<"expected"> expected but was
<"actual">

diff:
? expected 
? a     ual
? ????     ??
===============================================================================
Finished in 0.005941 seconds.
-------------------------------------------------------------------------------
2 tests, 2 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
50% passed
-------------------------------------------------------------------------------
336.64 tests/s, 336.64 assertions/s
rake aborted!
Command failed with status (1)
/Users/kitaitimakoto/src/gitlab.com/KitaitiMakoto/my_ext/vendor/bundle/ruby/3.4.0/gems/rake-13.3.0/exe/rake:27:in '<top (required)>'
/Users/kitaitimakoto/.rubies/ruby-3.4.7/bin/bundle:25:in '<main>'
Tasks: TOP => default => test
(See full trace by running task with --trace)

これで概ね快適に開発していけるのではないでしょうか。
あとは(ext/my_extディレクトリーではなくて)プロジェクトルートのCargo.tomlを消したり[1]Gemfileからrake-compilerを消したり、Rakefileを整理したりしておきましょう。

ただ、以下のような所が気になるので、まだrb_sys gemに頼るのもいいかも知れません(結局・・・!)。

クレートの取得とコンパイル

Gem::Ext::CargoBuildertargetディレクトリーを消してしまうため、ビルド時に毎回クレートの取得とコンパイルが走ってしまいます。
現在のGem::Ext::CargoBuilder#buildでは一つのメソッド内でビルドからこの後始末まで全部行っているので如何ともし難いですね。ビルドと後始末を別メソッドに切り分けるパッチを送って、Rakefileではビルド部分だけ使うようにするのがいいかな。

ビルドのプログレスバー

cargo buildを実行すると色付きでクレートの取得とコンパイルのプログレスバーが表示されて楽しいのですが、Gem::Ext::CargoBuilder経由だと逐次ではなくまとめての表示になってしまうので少し寂しい……のと、全部終わってからの表示になるので意外と待たされる感じがしてしまいます。

おまけ:gemとクレートのバージョンを揃える

バージョンを変更する時にlib/my_ext/version.rbext/my_ext/Cargo.tomlの両方を揃えて変更するの、忘れそうじゃありません? Rubyの柔軟性を活かして、Cargo.tomlを「正」に、version.rbを「従」にしてみましょう。

クレートのバージョンはcargo metadataで取得することができます(Gem::Ext::CargoBuilderでもcargo metadataを使っているんだけどプライベートメソッドの中なので結果を共有できない……)。

lib/my_ext/version.rb
@@ -1,5 +1,8 @@
 # frozen_string_literal: true
+require "json"
 
 module MyExt
-  VERSION = "0.1.0"
+  manifest = File.join(__dir__, "..", "..", "ext", "my_ext", "Cargo.toml")
+  metadata = `cargo metadata --no-deps --format-version=1 --manifest-path=#{manifest}`
+  VERSION = JSON.parse(metadata)["packages"][0]["version"]
 end

そして、ext/my_ext/Cargo.tomlext/my_ext/Cargo.lockのバージョンが食い違うと、インストールに失敗してしまいます。これをどう防いだらいいのか、悩んでいます。
今の所は、共有ファイルのビルド時(開発用)とgemパッケージのビルド時(リリース用)に自動でcargo updateを実行するようRakefileにタスクを書いています:

Rakefile
require "rubygems/ext"
require "rubygems/tasks"
require "rake/testtask"
require "shellwords"

GEMSPEC = Gem::Specification.load("my_ext.gemspec")
MANIFEST = GEMSPEC.extensions.first

CARGO_LOCK = "ext/Cargo.lock"
file CARGO_LOCK => MANIFEST do |t|
  pkgid = `cargo pkgid --manifest-path=#{t.source.shellescape}`.chomp
  system "cargo", "update", "--manifest-path", t.source, pkgid, exception: true
end

SO_NAME = "#{GEMSPEC.name}.#{RbConfig::CONFIG["DLEXT"]}"
SO_PATH = File.join("lib", SO_NAME)
file SO_PATH => CARGO_LOCK do
  results = Rake.verbose == true ? $stdout : []
  Gem::Ext::CargoBuilder.new.build MANIFEST, ".", results, [], "lib", File.expand_path("ext")
end

Gem::Tasks.new
task build: CARGO_LOCK

Rake::TestTask.new
task test: SO_PATH
  • lib/my_ext/my_ext.bundle生成タスクの依存にCargo.lockCARGO_LOCK)を設定しているので、ビルドの度にCargo.lockをチェック
  • Cargo.lockCargo.tomlMANIFEST)に依存しているので、Cargo.tomlにバージョンなどの変更があればCargo.lockを更新
  • buildタスクも同様にCargo.lockに依存

という設定で「運用でカバー」している感じです。
testlib/my_ext/my_ext.bundleに依存しているのでテストの度にCargo.lockをチェックしているので大体気付くだろうという感じです。
またreleasebuildに依存しているのでリリース前にはチェックされるようにしています。
が、他に何かいい方法がある方は是非教えてください……。

何だか尻すぼみの記事になってしまいましたが、気を取り直して。

2025年10月現在、Rustで拡張ライブラリーを書く時に、わざわざユーザーにrb_sys gemのインストールを強要しなくてよくなっていますよ、ということでした。

脚注
  1. 単純にCargo.tomlを消すだけではうまくいきません。Cargo.lockの生成場所をプロジェクトルートからext/my_extに移す必要があります:
    % git rm Cargo.toml Cargo.lock
    % cargo update --manifest-path=ext/my_ext/Cargo.toml
    % git add ext/my_ext/Cargo.lock ↩︎

Discussion