Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: config.autoload_libとconfig.autoload_lib_onceが追加ほか(20230725前編)

こんにちは、hachi8833です。これ見てちょっと震えました。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

なお、更新情報のCVE-2023-28362については以下の記事でお知らせ済みです。

Railsセキュリティ修正7.0.5.1、6.1.7.4 がリリースされました

Rails 7.0.6がリリースされました

🔗 autoload_lib関連

🔗 config.autoload_libを追加

概要
このパッチは、libのオートロードやeager loadingを手軽に行えるAPIを導入する。

# config/application.rb
config.autoload_lib(ignore: %w(assets tasks generators))

経緯

libディレクトリは初期のRailsバージョンではオートロードパスに含まれていた。しかしlibディレクトリにはさまざまな種類のファイルが格納されており、production環境でeager loadingすると問題になることがある。一部のファイル(ジェネレーターなど)はオートロードやeager loadingの対象にならない。
このため、Rails 3ではデフォルトのオートロードパスからlibを削除していた。

app/lib

libについてこれといったソリューションがなかったため、app/libを利用するアイデアが浮上した。

新しいappのサブディレクトリは自動的にオートロードパスとeager loadパスに追加される。そして、後からそこに通常のコード(アセットやタスクなどではないコード)を配置するだけで、そのまま使えるようになる。

ただし、自分はapp/libについていくつか指摘したい点がある。

  • app/modelsapp/controllersは何らかの意味があるが、app/libは何も意味していない。これらは同じ階層にあるが、命名があまり一貫していない。
  • 単なるlibは本来アプリケーションコアから外れたものを指す。そこに置かれているものは、自分にとってはlibよりもappの下に配置する方が適切と思える。
  • いずれにせよ、Railsプログラマーがlibからオートロードできるようにすべき。
  • 自分がコンサルティングしたほとんどのプロジェクトでは、手動でオートロードパスにlibを追加していた。誰もがlibからオートロードしたいと思っており、そのことは理解できる。フレームワークでこのユースケースを基本機能としてサポートすべき。

今は選択肢が増えた

Zeitwerkはファイルやディレクトリを無視できるので、Railsプログラマーはlibのオートロードやeager loadingを以下のようにもっといい形で制御できるようになっている。

# config/application.rb
module MyApp
  class Application < Rails::Application
    lib = root.join("lib")

    config.autoload_paths << lib
    config.eager_load_paths << lib

    Rails.autoloaders.main.ignore(
      lib.join("assets"),
      lib.join("tasks"),
      lib.join("generators")
    )

    ...
  end
end

技術的にはRails 6以降でできるようになっているが、今ならもっとよいAPIにできる。

config.autoload_lib(ignore:)

APIの設計では、例外的なユースケースには例外的なサポートがあってもいい。設計は一般的なケースを対象とし、エッジケースはエッジに任せればよい。

この場合、libにはアドホックなAPIがふさわしいと考えている。これなら以下のようにユーザーが一括で設定できるようになる。

# config/application.rb
config.autoload_lib(ignore: %w(assets tasks generators))

ignoreに渡す値は、このAPIでlibの外にあるものを無視しても無意味になるので、libに対して相対的になる。おまけに呼び出しも短く簡潔になる。

引数をデフォルトにしない理由

これら3つのディレクトリをデフォルトにすべきかどうか考えたが、以下の例を見てほしい。

# config/application.rb
config.autoload_lib

これでは、無視されているディレクトリが存在することが明確にならない。ファイルを開けば"autoload lib"が見えることになるが、このAPIは誤解を招いてしまう。自分は、クライアントにとって当然の選択肢ならデフォルト値にする意味があると思うが、この場合はそうは思えない。

この場合は、APIを明示的にして、どれを無視すべきかをユーザーに考えさせる形にする方が良いと思う。ドキュメントでも既にそうした例を示してユーザーをそちらに導くようにしている。

これを新規アプリケーションで生成しない理由

最終的にこのコードを新規生成されるアプリケーションに組み込むことは可能。ただし急いで進めたくないので、まずはAPIを公開して様子を見ることにしよう。問題なく動作するようなら、おそらくRailsの将来のバージョンで実現されるだろう。

これをエンジンで利用可能にしない理由

エンジンのlibディレクトリは、一般にアプリケーションのlibディレクトリよりもむしろgemのlibディレクトリに近い。エンジンでのlib再読み込みはもっと複雑になる。現時点では、自分が何をしているかをわかっている人が手動でオートローダーを設定するのが望ましいと思う。
同PRより


つっつきボイス:「Zeitwerk作者のfxnさんによるプルリクです」「Railsのオートロードといえばこの人」「ドキュメントも更新されてますね」

参考: 定数の自動読み込みと再読み込み (Zeitwerk) - Railsガイド

fxn/zeitwerk - GitHub

「今までも以下のようにRails.autoloaders.main.ignoreでディレクトリを除外できたのか↓: config.autoload_libignore:オプションを渡すことで同じことをできるようになったんですね」「assets/tasks/generators/などは普通はRails実行時に必ずしも読み込む必要のないディレクトリで、その方がメモリも起動時間も節約できますね」

    lib = root.join("lib")

    config.autoload_paths << lib
    config.eager_load_paths << lib

    Rails.autoloaders.main.ignore(
      lib.join("assets"),
      lib.join("tasks"),
      lib.join("generators")
    )

参考: §6.5 オートロードパスにappを追加する -- Classic から Zeitwerk への移行 - Railsガイド

🔗 config.autoload_lib_onceを追加

このconfig.autoload_lib_onceメソッドはconfig.autoload_libと似ているが、libconfig.autoload_once_pathsに追加する点が異なる。

config.autoload_lib_onceを呼び出せば、lib内のクラスやモジュールがオートロードされる。これはアプリケーションの初期化時でも呼び出せるが、再読み込みは行われない。
同PRより


つっつきボイス:「これもfxnさんのプルリクです」「今度はconfig.autoload_lib_once: これは名前の通り一度だけ読み込まれて再読み込みされないんですね」

#railties/lib/rails/application/configuration.rb#467
      def autoload_lib_once(ignore:)
        lib = root.join("lib")

        # Set as a string to have the same type as default autoload paths, for
        # consistency.
        autoload_once_paths << lib.to_s
        eager_load_paths << lib.to_s

        ignored_abspaths = Array.wrap(ignore).map { lib.join(_1) }
        Rails.autoloaders.once.ignore(ignored_abspaths)
      end

🔗 Action Mailboxでバウンスメールをbounce_now_withで送信可能になった

動機/背景

このプルリクを作成した理由は、Active Jobとキューを経由せずにバウンスメールを(ActionMailbox.bounce_withで)送信しなければならないユースケースに最近遭遇したため。このユースケースに対応してもよさそうなので、バウンスメールの即時配信を可能にする代替メソッドbounce_now_withの追加を提案する。

詳細

このプルリクは、キューを経由しないバウンスメール送信をトリガーするActionMailbox::Base#bounce_now_withを追加する。

 # バウンスメールをエンキューする
MyMailbox.bounce_with MyMailer.my_method(args)

# メールを即時配信する
MyMailbox.bounce_now_with MyMailer.my_method(args)

同PRより


つっつきボイス:「これはAction Mailboxの改修」「Action Mailerの処理中にバウンスを検知した場合に行う処理のようですね」

参考: Action Mailbox の基礎 - Railsガイド
参考: バウンスメール - Wikipedia

「ところでAction Mailboxの機能を見ていると、Railsの中になつかしのqmailが入っているような気持ちになりますね」「そうそう、Action Mailboxは実質的にメール受信サーバーを作っているようなものですね」「今qmailを使っているところはさすがに減ったかな」

参考: qmail - Wikipedia

🔗 railties:install:migrationsDATABASEオプションを追加

これにより、rails railties:install:migrationsを実行する際に、マイグレーションをどのデータベースにコピーするかを指定できるようになる。

$ rails railties:install:migrations DATABASE=animals

Matthew Hirst
同Changelogより


つっつきボイス:「railtiesでマイグレーションをインストールしたことないかも」「使い道がよくわからないけどマルチプルデータベースかRailsエンジンあたりに関係していそうですね🤔」

マイグレーションをコピーする必要のあるエンジンが複数ある場合は、代わりにrailties:install:migrationsを使います。

$ bin/rails railties:install:migrations

§ 4.2 エンジンの設定 -- Rails エンジン入門 - Railsガイドより


なお、rail tieは線路の枕木のことです。その名もRailtieというおもちゃがあるようです↓。

🔗 SHA1ハッシュダイジェストで非決定論的に暗号化された従来のデータの復号をサポート

これにより、Active Recordの新しい暗号化オプションが追加され、SHA1ハッシュダイジェストで非決定論的に暗号化されたデータを復号できるようになる。

Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = true

新しいオプションは、7.0から7.1にアップグレードする際の問題に対処する。Active Record暗号化の初期化方法にバグがあったため、非決定論的暗号化に用いるキープロバイダは、RailsがRails.application.config.active_support.key_generator_hash_digest_classでグローバルに設定したものではなく、SHA-1をダイジェストクラスとして利用していた。
Cadu Ribeiro、Jorge Manrubia
同Changelogより


このプルリクは、SHA1ハッシュダイジェストで非決定論的に暗号化された既存のデータをサポートする新しいActive Record暗号化オプションを追加する。

現在、7.0から7.1にアップデートするユーザーにはActive Recordの暗号化に関する問題が存在する。#44873以前は、非決定論的な暗号化でデータを暗号化する場合、常にSHA-1が使われていた。その理由は、ActiveSupport::KeyGenerator.hash_digest_classがrailtieの設定内のafter_initializeブロックで設定されていたが、暗号化設定はそれよりも前の段階で実行されるようになっていたため、結果的に従来のデフォルト値であるSHA-1が使われていたため。つまり、既存のユーザーは非決定論的な暗号化にSHA256を使い、決定論的な暗号化にSHA1を使っていることになる。

このプルリクで追加される新しいオプションsupport_sha1_for_non_deterministic_encryptionは、ユーザーが既存データの復号でSHA1とSHA256の両方をサポートする目的で有効にできる。

新しいオプションは、7.1より前のバージョンではデフォルトで有効になる。考え方は以下のとおり。

  • 既存のユーザーは、7.0からのアップデート時にこのオプションを設定する必要はない(デフォルトで有効になるため)。
  • 7.1以降のユーザーは、このオプションが無効になる。

これにより、Active Record暗号化の初期化システムがさらに堅牢になる。この初期化はすべてのイニシャライザが実行された後に実行されるようになり、フィルタリングする属性を宣言するシステムも異なる読み込み順で動作する。

徹底的な調査とデバッグを行ってくれた@duduribeiroにひたすら感謝したい。彼はこの問題を突き止めて修正する代替手段を模索してくれた(#48520参照)。私はそのプルリクから移植されたコミットの共同著者として彼を追加した。
同PRより

参考: Active Record と暗号化 - Railsガイド
参考: SHA-1 - Wikipedia
参考: SHA-2 - Wikipedia


つっつきボイス:「以前#44873でActive Recordの暗号化でダイジェストを変更可能になったことがあったけど(ウォッチ20230322)、非決定論的な暗号化ではkey_generator_hash_digest_classで指定したものが効かずにSHA-1が使われていたのか」「Rails 7.0で非決定論的暗号化の場合に設定が食い違っていたんですね」「この問題を調査して対応方法をプルリクにまとめるのは大変そう...」

後で調べると、#48530に他にも追加修正が入っていました↓。

参考: Fix Active Record encryption not picking up encryption settings with eager-loading by jorgemanrubia · Pull Request #48577 · rails/rails
参考: Fix queries for deterministically encrypted attributed for data migrated from 7.0 by jorgemanrubia · Pull Request #48676 · rails/rails

🔗 Deprecation::Behavior:reportを追加

config.active_support.deprecation = :reportを設定すると、エラーレポーターがActiveSupport::ErrorReporterに非推奨警告を報告するようになった。
非推奨警告は、重要度:warningの処理済みエラーとして報告される。
production環境で発生する非推奨事項をバグトラッカーに報告するのに有用。
Étienne Barrié
同Changelogより


この振る舞いは、非推奨警告を重大度:warningで処理されたエラーとしてErrorReporterで報告する。

動機/背景
エラーレポーターは、production環境で発生するエラーを報告する方法として優れていて、"error"を処理済みとして宣言できる。アプリケーションのライフサイクルでは、新しいマイナーバージョンにアップグレードした後、非推奨機能は徐々に解決され、再導入を防ぐために完全に禁止されることもある。その後、再び使われたり警告が無視されたりしないよう、非推奨警告がdevelopment環境やtest環境で発生するように設定される。しかし通常のproduction環境では、非推奨警告が完全に抑制されている(config.active_support.report_deprecationsオプションで設定される)。

次のマイナーバージョンにアップグレードする直前の最後のステップで、すべてのコードパスをカバーするテストの信頼性が万全ではない場合は、production環境で発生する非推奨事項をアプリケーションのバグトラッカーに報告できると便利だろう。

詳細

バックトレースなどをトラッキングして重複レポートを削除すべきかどうか悩んだ。最終的に複数のワーカープロセスが同じ非推奨事項を繰り返し報告するので、そこまでやってもさほど有用ではなく、実装が複雑になるだけだと思えた。

デフォルトでは、報告されたエラーは処理済みとマークされ、重大度:warningになる。自分は懸念が非常に低いことを示すために:infoにすることも考えたが、「deprecation warning」という用語に沿って、処理済みエラーのデフォルト値である:warningにした。
同PRより

参考: ActiveSupport::Deprecation::Behavior


つっつきボイス:「これまで非推奨警告の振る舞いで指定可能だった:raise:stderr:log:notify:silence:reportも追加されたんですね: production環境のアプリケーションログにたくさん出てきそうだけど、バグトラッカーで問題を見つけたいという気持ちもわかるので、そういう場合にはよさそう👍」

参考: §3.14.10 config.active_support.deprecation -- Rails アプリケーションを設定する - Railsガイド


前編は以上です。

バックナンバー(2023年度第3四半期)

週刊Railsウォッチ: Kaigi on Rails 2023プロポーザル募集、rubocop-magic_numbersほか(20230721後編)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。