りばーさいだー

気ままに書いてみます

Railtieに積む位置を考える

tl;dr

  • after_initialize だと eager_load = true の場合にロードが遅すぎてシンタックスエラーやバリデーションエラーとなる場合がある
  • before_eager_load だと eager_load = false の時に評価されない
  • ActiveSupportlazy_load_hooks を使うと適切なタイミングで評価させることができる

やりたいこと

「デフォルトの Rails で valid として扱われるオプションを拡張するモンキーパッチを作る」というケースを想像します。

やりたいことはこんな感じ

# ActiveRecord::Associations::Builder::Association の オプションを拡張したい
def valid_options
  super + [:hoge]
end
# モンキーパッチをあてる
class Railtie < Rails::Railtie
  config.after_initialize do  # 良い感じの位置に積みたい
    ActiveRecord::Associations::Builder::Association.prepend(Hoge::Ext)
  end
end

どこに積むべきか考える

まずはこちらをご査収ください。

1) require "config/boot.rb" to setup load paths

2) require railties and engines

3) Define Rails.application as "class MyApp::Application < Rails::Application"

4) Run config.before_configuration callbacks

5) Load config/environments/ENV.rb

6) Run config.before_initialize callbacks

7) Run Railtie#initializer defined by railties, engines and application. One by one, each engine sets up its load paths, routes and runs its config/initializers/* files.

8) Custom Railtie#initializers added by railties, engines and applications are executed

9) Build the middleware stack and run to_prepare callbacks

10) Run config.before_eager_load and eager_load! if eager_load is true

11) Run config.after_initialize callbacks

ref: railties/lib/rails/application.rb

見ていただきたいのは 10)11)production だと eager_load = true になっているはずであり、after_initialize より前に eager_load! が走ることがわかります。

この例において、after_initialize のままだと eager_load = true の場合にロードされたタイミングではまだ Hoge::Extprepend されておらず、valid なオプションが拡張される前にモデルが評価され、エラーとなってしまう。

では、before_eager_load だとどうかと言うと Run if eager_load is true ということで、開発環境などの eager_load = false の環境でダメ。

だからと言って before_initialize では早すぎる気がする…どうすればいいんだ………

コードを読んでみよう

そもそも before_initialize ってどうやって実現されてるの?

# Second configurable block to run. Called before frameworks initialize.
def before_initialize(&block)
  ActiveSupport.on_load(:before_initialize, yield: true, &block)
end

なんだか ActiveSupport#on_load ってのが本体らしいですね。こいつがどこで定義されているか調べてみると active_support/lazy_load_hooks.rb というファイルで定義されています。

ドキュメントが詳しく書かれているので見てみましょう。

Here is an example where +on_load+ method is called to register a hook.

ruby initializer 'active_record.initialize_timezone' do ActiveSupport.on_load(:active_record) do self.time_zone_aware_attributes = true self.default_timezone = :utc end end

When the entirety of +activerecord/lib/active_record/base.rb+ has been

evaluated then +run_load_hooks+ is invoked. The very last line of

+activerecord/lib/active_record/base.rb+ is:

ruby ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base)

これこれ! これがやりたかったんですよ~。

#on_load の中身は「既にロード済みであればすぐに実行、そうじゃないならロードされたタイミングで実行」するように定義されています。 hook を仕掛けられる側のモジュール(仮に HookTarget とする)にて、ActiveSupport.run_load_hooks(:hook_name, HookTarget) とすることで、 :hook_name という名前に対して積まれた hooks が一斉に実行されていきます。

ActiveRecord::Base のコードを見てみると、様々なモジュールを extend, include した後、最後に ActiveSupport.run_load_hooks(:active_record, Base) と書かれています。 つまり、 ActiveSupport.on_load(:active_record) とすることで ActiveRecord::Base が読み込まれた直後に実施する処理を定義することができます。

これを踏まえて再定義

# モンキーパッチをあてる
class Railtie < Rails::Railtie
  initializer 'active_record.valid_option_ext' do
    ActiveSupport.on_load(:active_record) do  # 良い感じの位置に積めた
      ActiveRecord::Associations::Builder::Association.prepend(Hoge::Ext)
    end
  end
end

おわりに

かなりスッキリ解決できて満足です。

しかしなかなかやりたいこと(Rails にモンキーパッチを当てる)の内容から lazy_load_hooks という名前にたどり着けませんね。

この記事で今後モンキーパッチを当てる人の役に立てば嬉しいです。