RubyKaigi 2026 で palkan さんが発表されていた Require Hooks が自分の中ですごく印象的でした。
speakerdeck.com
Ruby には現在 require を拡張するための機能が存在しないため、require の瞬間に何かしたいみたいな場合に、monkey patch で実装依存になってしまいます。
この課題に対して Require Hooks が require 拡張ポイントを提供することで共通のしくみとして使えるようにしたという発表内容でした。
require を拡張するしくみを Ruby のレイヤーから実現しているという点も、そういうアプローチができるのか!と強く印象に残った理由の一つになっています。
というわけで、Require Hooks に興味津々なため実際に Rails アプリに入れて試したことを簡単にまとめてみます。
準備
maimux2x.hatenablog.com
こちらの記事で触れた自分用の Rails アプリで試してみました。
まず、README にある通りに導入準備をします。
github.com
gem 'require-hooks'
bundle install を実行して、config/ 以下にファイルを作成します。
require 'require-hooks/setup'
作成したファイルを application.rb で読み込みます。
require_relative 'boot'
require 'rails/all'
Bundler.require(*Rails.groups)
require_relative 'require_hooks'
直接 application.rb で require 'require-hooks/setup' を追加しても良かったのですが、検証用にコードを追加したりするため、分かりやすいように分けました。
Require Hooks には3種類のフックがあります。
| フック |
内容 |
| around_load |
ファイルのロード前後に処理を挟む |
| source_transform |
ソースコードを書き換えてからロードする |
| hijack_load |
ISeq(命令列)を自分でコンパイルして渡す |
これをそれぞれ試していきたいと思います。
around_load
まずは around_load から。
app/ 以下のファイルがロードされるタイミングでログにパスを出力するようにします。
先ほど作成した require_hooks.rb に以下を追記します。
app_pattern = File.expand_path("../app/**/*.rb", __dir__)
RequireHooks.around_load(patterns: [app_pattern]) do |path, &block|
puts "[require-hooks] Loading: #{path}"
result = block.call
puts "[require-hooks] Loaded: #{path}"
result
end
変更を保存してサーバーを起動して localhost:3000 にアクセスすると以下の出力が得られます。
[require-hooks] Loading: .../app/controllers/homes_controller.rb
[require-hooks] Loading: .../app/controllers/application_controller.rb
[require-hooks] Loading: .../app/helpers/application_helper.rb
[require-hooks] Loaded: .../app/helpers/application_helper.rb
[require-hooks] Loading: .../app/controllers/concerns/authentication.rb
[require-hooks] Loaded: .../app/controllers/concerns/authentication.rb
[require-hooks] Loaded: .../app/controllers/application_controller.rb
[require-hooks] Loaded: .../app/controllers/homes_controller.rb
Processing by HomesController#show as HTML
[require-hooks] Loading: .../app/models/current.rb
[require-hooks] Loaded: .../app/models/current.rb
[require-hooks] Loading: .../app/models/session.rb
[require-hooks] Loading: .../app/models/application_record.rb
[require-hooks] Loaded: .../app/models/application_record.rb
[require-hooks] Loaded: .../app/models/session.rb
Session Load (0.1ms) SELECT "sessions".* FROM "sessions" WHERE "sessions"."id" = 2 LIMIT 1 /*action='show',application='Yondayo',controller='homes'*/
↳ app/controllers/concerns/authentication.rb:29:in 'Authentication#find_session_by_cookie'
[require-hooks] Loading: .../app/models/user.rb
[require-hooks] Loaded: .../app/models/user.rb
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*action='show',application='Yondayo',controller='homes'*/
↳ app/models/current.rb:3:in 'Current#user'
[require-hooks] Loading: .../app/models/reading.rb
[require-hooks] Loaded: .../app/models/reading.rb
development 環境で最初のリクエスト時に Zeitwerk によってオートロードされた app/ 以下のファイルが確認できます。
ここで面白かったのはログのフェーズが2段階に分かれていることで、Processing by HomesController#show as HTML
を境に異なるファイルがロードされていることが分かります。
HomesController#show アクションの実行前は homes_controller.rb のロード中に application_controller.rb がロードされ、さらにその中で application_helper.rb → authentication.rb と連鎖しています。
アクション実行中は current.rb ロード実行と完了、session.rb のロード中に application_record.rb を内部でロードして session.rb ロード完了、user.rb と reading.rb のロード実行とロード完了という流れが読み取れます。
パスをログに出すことができたのでファイルのロード時間をログに出力してみます。
先程のコードを以下のように修正します。
RequireHooks.around_load(patterns: [app_pattern]) do |path, &block|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = block.call
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
Rails.logger.debug format("[require-hooks] %.4fs %s", elapsed, path)
result
end
サーバーを再起動してブラウザのページをリロードします。
[require-hooks] 0.0006s .../app/helpers/application_helper.rb
[require-hooks] 0.0002s .../app/controllers/concerns/authentication.rb
[require-hooks] 0.0017s .../app/controllers/application_controller.rb
[require-hooks] 0.0022s .../app/controllers/homes_controller.rb
Processing by HomesController#show as HTML
[require-hooks] 0.0008s /.../app/models/current.rb
[require-hooks] 0.0033s ...app/models/application_record.rb
[require-hooks] 0.0047s .../app/models/session.rb
Session Load (0.1ms) SELECT "sessions".* FROM "sessions" WHERE "sessions"."id" = 2 LIMIT 1 /*action='show',application='Yondayo',controller='homes'*/
↳ app/controllers/concerns/authentication.rb:29:in 'Authentication#find_session_by_cookie'
[require-hooks] 0.0076s .../app/models/user.rb
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*action='show',application='Yondayo',controller='homes'*/
↳ app/models/current.rb:3:in 'Current#user'
[require-hooks] 0.0010s .../app/models/reading.rb
ファイルのパスをログに出力した際にあるファイルをロード中に別のファイルがロードされていたと思いますが、この計測時間はそのネストしたロードを含む累積時間を指しています。
そのため、session.rb 自体のコードの評価にかかった時間は application_record.rb の時間を差し引いた 0.0047 - 0.0033 = 0.0014s ほどということが分かります。
user.rb はネストしていないため、単体ファイルとして一番ロードに時間がかかったファイルということが読み取れます。関連やバリデーションの定義が多いからかな。
ここまでで around_load でできることのイメージが具体的になったのではないでしょうか?試したことはシンプルですが、ロードのタイミングを可視化できて面白かったです。
次は source_transform です。
RubyKaigi 2026 の発表でも触れられていた frozen_string_literal を自動挿入してみようと思います。
モデルに定数を定義して source_transform を使用する前後の結果を比較してみます。
class Reading < ApplicationRecord
TEST_STR = "hello"
end
rails console で Reading::TEST_STR.frozen? の結果を確認します。
> Reading::TEST_STR.frozen?
=> false
続いて、require_hooks.rbを以下のように修正してみます。
RequireHooks.source_transform(
patterns: [app_pattern],
exclude_patterns: [File.expand_path("../app/views/**/*", __dir__)]
) do |path, source|
source ||= File.read(path)
next source if source.start_with?("# frozen_string_literal: true")
"# frozen_string_literal: true\n#{source}"
end
もう一度 rails console を起動して結果を確認すると
> Reading::TEST_STR.frozen?
=> true
true になりました!!
別の使い方として、test.book というファイルに本の名前を列挙して保存しておき、それを source_transform を使って Ruby コードに変換することを試してみようと思います。
# test.book
BOOK1
BOOK2
BOOK3
require_hooks.rb を以下のように修正します。
book_pattern = File.expand_path("../*.book", __dir__)
RequireHooks.source_transform(patterns: [book_pattern]) do |path, source|
source ||= File.read(path)
books = source.lines.map(&:chomp).reject(&:empty?)
class_name = File.basename(path, ".book").capitalize
books_literal = books.map { |b| " \"#{b}\"" }.join(",\n")
ruby_code = <<~RUBY
class #{class_name}
BOOKS = [
#{books_literal}
]
end
RUBY
ruby_code
end
load File.expand_path("../test.book", __dir__)
source_transform はロードのタイミングで発火するフックのため、変換処理を登録するだけでは駄目で load で実際にファイルを読み込む必要があります。
検証用のコードとして一時的に置いています。
保存して rails console で Test::BOOKS の結果を見てみます。
> Test::BOOKS
=> ["BOOK1", "BOOK2", "BOOK3"]
test.book ファイルの内容が BOOKS という定数に配列として格納されています!
ここで面白いのは test.book は Ruby として無効なファイルなのに load ができることです。
RequireHooks.source_transform のブロックを消して load の行だけ残して rails console を起動しようとするとエラーになります。
uninitialized constant BOOK1 (NameError)
BOOK1
^^^^^
source_transform が test.book が Ruby コードとして評価される前にソースを差し替えていることが分かる例で、ロードの前後にフックする around_load との違いも理解できます。
hijack_load
最後は hijack_load です。
hijack_load は MRI や JRuby などの実装に応じてコンパイル方法を切り替えることで、たとえば require-hooks の README では以下ような使い方が書かれています。
RequireHooks.hijack_load(patterns: ["/my_project/*.rb"]) do |path, source|
source ||= File.read(path)
if defined?(RubyVM::InstructionSequence)
RubyVM::InstructionSequence.compile(source)
elsif defined?(JRUBY_VERSION)
JRuby.compile(source)
end
end
今回は学習用に ISeq を取得してみることを試しました。
require_hooks.rb を修正します。
RequireHooks.hijack_load(patterns: [app_pattern]) do |path, source|
source ||= File.read(path)
iseq = RubyVM::InstructionSequence.compile(source, path, path, 1)
puts "[hijack] Compiled #{File.basename(path)} → #{iseq.inspect}"
iseq
end
サーバーを再起動してブラウザのページをリロードします。
[hijack] Compiled homes_controller.rb → <RubyVM::InstructionSequence:<compiled>@.../app/controllers/homes_controller.rb:1>
[hijack] Compiled application_controller.rb → <RubyVM::InstructionSequence:<compiled>@.../app/controllers/application_controller.rb:1>
[hijack] Compiled application_helper.rb → <RubyVM::InstructionSequence:<compiled>@.../app/helpers/application_helper.rb:1>
[hijack] Compiled authentication.rb → <RubyVM::InstructionSequence:<compiled>@.../app/controllers/concerns/authentication.rb:1>
Processing by HomesController#show as HTML
[hijack] Compiled current.rb → <RubyVM::InstructionSequence:<compiled>@.../app/models/current.rb:1>
[hijack] Compiled session.rb → <RubyVM::InstructionSequence:<compiled>@.../app/models/session.rb:1>
[hijack] Compiled application_record.rb → <RubyVM::InstructionSequence:<compiled>@.../app/models/application_record.rb:1>
ISeq を取得してログに出せたわけですが、正直よくわからない・・・。
Claude に質問してみたところ、逆アセンブルしてみると面白いかもしれないとのことで試してみました。
require_hooks.rb を少し修正します。
RequireHooks.hijack_load(patterns: [app_pattern]) do |path, source|
source ||= File.read(path)
iseq = RubyVM::InstructionSequence.compile(source, path, path, 1)
puts iseq.disasm if File.basename(path) == "book.rb"
iseq
end
もう一度サーバーを再起動すると、逆アセンブルした結果をログに出力できました!
== disasm: #<ISeq:<compiled>@.../app/models/book.rb:1 (1,0)-(5,3)>
0000 putspecialobject 3 ( 1)[Li]
0002 opt_getconstant_path <ic:0 ApplicationRecord>
0004 defineclass :Book, <class:Book>, 16
0008 leave
== disasm: #<ISeq:<class:Book>@.../app/models/book.rb:1 (1,0)-(5,3)>
0000 putself ( 2)[LiCl]
0001 putobject :readings
0003 putobject :destroy
0005 opt_send_without_block <calldata!mid:has_many, argc:2, kw:[dependent], FCALL|KWARG>
0007 pop
0008 putself ( 4)[Li]
0009 putobject :title
0011 putobject true
0013 opt_send_without_block <calldata!mid:validates, argc:2, kw:[presence], FCALL|KWARG>
0015 leave ( 5)[En]
内容は全然わからないため、Claude に解説してもらいました。
== disasm: #<ISeq:<compiled>@<compiled>:2 (2,0)-(6,3)>
0000 putspecialobject 3 # クラス定義に必要な特殊オブジェクトをスタックに積む
0002 opt_getconstant_path <ic:0 ApplicationRecord> # ApplicationRecord 定数を取得
0004 defineclass :Book, <class:Book>, 16 # Book クラスを定義(内側の ISeq を使う)
0008 leave # 終了
== disasm: #<ISeq:<class:Book>@<compiled>:2 (2,0)-(6,3)>
0000 putself # self(Book クラス)をスタックに積む
0001 putobject :readings # :readings をスタックに積む
0003 putobject :destroy # :destroy をスタックに積む
0005 opt_send_without_block has_many, argc:2, kw:[dependent] # has_many :readings, dependent: :destroy を呼ぶ
0007 pop # 戻り値を捨てる
0008 putself # self をスタックに積む
0009 putobject :title # :title をスタックに積む
0011 putobject true # true をスタックに積む
0013 opt_send_without_block validates, argc:2, kw:[presence] # validates :title, presence: true を呼ぶ
0015 leave # 終了
ちなみに app/models/book.rb の内容はこうなっています。
class Book < ApplicationRecord
has_many :readings, dependent: :destroy
validates :title, presence: true
end
本来の用途ではないですが、hijack_load で ISeq を逆アセンブルすると ISeq の命令列を見ることができるため、JIT の基礎を知ることに繋がったりするかな?と思ったりしました。
まとめ
Require Hooks の3つのフックについてそれぞれ試して何ができるのかを整理してみました。
試しながら結果を調べる過程で私はだいぶワクワク楽しく過ごせました。
もう少し実践的に活用できるシーンも考えてみたいと思います。