Bootsnapにカスタムコンパイラフックを追加:ファイルごとにRubyコンパイラを切り替え可能に
Bootsnap::CompileCache::ISeq.compiler_selector にProcを設定することで、Rubyファイルの読み込みごとにコンパイラを動的に選択できるフックAPIが追加されました。これにより、アプリケーションコードのみfrozen string literalを有効化するといった、ファイルパスに基づく細粒度なコンパイルオプションの制御が可能になります。
背景
これまでBootsnapのISeqコンパイルキャッシュは、全ファイルに対して単一のコンパイラを使用していました。frozen_string_literal: true を有効化したい場合、プロジェクト全体に適用するしかなく、gemの挙動に影響を与えるリスクがありました。アプリケーションコードだけに適用するような選択的な制御手段が存在しなかったのです。
このPRはその制約を解消するフックAPIを導入しています。また同時に、設定を外部ファイルに切り出す BOOTSNAP_CONFIG 環境変数とコンフィグファイル機構(デフォルト: config/bootsnap.rb)も追加されており、起動スクリプトを汚染せずにBootsnapの挙動をカスタマイズできる基盤が整えられました。
技術的な変更
コンパイラのクラス化と3種のデフォルト実装
これまでモジュール直接メソッドとして実装されていたコンパイルロジックが、Bootsnap::CompileCache::ISeq::Compiler クラスに切り出されました。このクラスは namespace と compile_options を受け取り、インスタンスメソッドとして input_to_storage を持ちます。
デフォルトのコンパイラ定数として以下の3種が提供されます:
-
Bootsnap::CompileCache::ISeq::DEFAULT— 標準コンパイラ -
Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL—frozen_string_literal: trueを有効化 -
Bootsnap::CompileCache::ISeq::MUTABLE_STRING_LITERAL—frozen_string_literal: falseを明示
これらはそれぞれ異なる compile_options を持つ Compiler インスタンスです。テストコードでも Bootsnap::CompileCache::ISeq 直接へのモックが Bootsnap::CompileCache::ISeq::DEFAULT へのモックに変更されており、責務の分離が明確になっています。
compiler_selector によるファイルごとのコンパイラ切り替え
Bootsnap::CompileCache::ISeq.compiler_selector= にProcを設定すると、Rubyファイルが読み込まれるたびにそのProcがパスを引数として呼び出され、使用するコンパイラインスタンスを返します。
gems_root = File.join(Bundler.bundle_path.cleanpath, "")
app_root = File.join(Dir.pwd, "")
Bootsnap::CompileCache::ISeq.compiler_selector = ->(path) do
if path.start_with?(app_root) && !path.start_with?(gems_root)
Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL
else
Bootsnap::CompileCache::ISeq::DEFAULT
end
end
compiler_selector が nil の場合は従来通り default_compiler が使用されるため、既存の挙動との後方互換性は保たれます。
ネイティブ層への namespace パラメータ追加
Cの拡張ライブラリ側でも変更が加えられています。bs_rb_fetch と bs_rb_precompile の関数シグネチャに namespace_v パラメータが追加され、それぞれ引数数が4→5、3→4に増加しました。
変更前:
static VALUE bs_rb_fetch(VALUE self, VALUE cachedir_v, VALUE path_v, VALUE handler, VALUE args);
static VALUE bs_rb_precompile(VALUE self, VALUE cachedir_v, VALUE path_v, VALUE handler);
変更後:
static VALUE bs_rb_fetch(VALUE self, VALUE cachedir_v, VALUE namespace_v, VALUE path_v, VALUE handler, VALUE args);
static VALUE bs_rb_precompile(VALUE self, VALUE cachedir_v, VALUE namespace_v, VALUE path_v, VALUE handler);
bs_cache_path も同様に namespace_v を受け取るよう変更されており、コンパイラごとに異なるキャッシュ名前空間を割り当てる仕組みの基盤となっています。これにより、同一ファイルに対して異なるコンパイラで生成されたISeqが別々にキャッシュされます。
input_to_output インターフェースの変更
ハンドラの input_to_output メソッドに path 引数が追加されました。YAMLの各実装クラスでは _path として受け取り(現時点では未使用)、テストヘルパーも同様に更新されています。
変更前:
def self.input_to_output(_data, _kwargs)
raise("but why tho")
end
変更後:
def self.input_to_output(_source, _path, _kwargs)
raise("but why tho")
end
この変更はカスタムコンパイラがパスを参照できるようにするための統一的なインターフェース整備です。
enable_frozen_string_literal ヘルパーメソッド
最も一般的なユースケースをカバーするため、Bootsnap.enable_frozen_string_literal が追加されました。
Bootsnap.enable_frozen_string_literal(app_only: true)
app_only: true を渡すと compiler_selector に前述のパスベースのラムダが自動設定されます。app_only: false(デフォルト)の場合は default_compiler を FROZEN_STRING_LITERAL に設定するだけのシンプルな動作になります。
コンフィグファイル機構
Bootsnap.load_config メソッドが追加され、Bootsnap.setup の末尾および bootsnap precompile CLIコマンドの実行時に自動的に呼び出されます。読み込むファイルのパスは BOOTSNAP_CONFIG 環境変数で上書き可能で、デフォルトは config/bootsnap.rb です。
設計判断
compiler_selector をモジュール属性のProcとして実装する方式が採用されました。コンパイラ自体はクラスインスタンスとして定義しつつ、選択ロジックはProcに委譲することで、カスタムコンパイラの実装にRubyの任意のオブジェクトを利用できます。
キャッシュの名前空間化はこの設計の重要な補完要素です。compiler_selector が異なるコンパイラを返す場合、キャッシュキーにコンパイラの namespace が組み込まれることで、コンパイルオプションの違いを反映したキャッシュが生成されます。同一ファイルのデフォルトコンパイル結果とfrozen string literal有効化のコンパイル結果が混在してキャッシュが汚染されるリスクを回避しています。
PR説明ではコンパイラインターフェースの安定性について「歴史的にほとんど変更されていないが、安定性は保証しない」と明示されています。これはカスタムコンパイラの実装者への適切な警告であり、拡張性と安定性保証のバランスを取った判断といえます。
まとめ
このPRは、Bootsnapのコンパイルキャッシュ基盤に「コンパイラをファイルごとに選択する」層を追加したものです。compiler_selector フックとキャッシュ名前空間の組み合わせにより、アプリケーションコードとgemコードで異なるコンパイルオプションを安全に共存させることが可能になり、frozen string literal導入の障壁を下げるという実用的な恩恵をもたらします。