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

週刊Railsウォッチ: Rails 7.2 RC1がリリース、ストリーミングのレスポンス処理をRack 3で行うほか(20240807前編)

こんにちは、hachi8833です。間が空いてしまって恐縮です🙇。
今朝Rails 7.2 RC1がリリースされました↓。

週刊Railsウォッチについて

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

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

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

7月中はマイルストーンに2件ほど残っていましたが、8/1にクローズされていました↓。

参考: 7.2.0 Milestone

🔗 ActiveRecord::Encryption::Encryptor:compressorオプションで圧縮アルゴリズムを渡せるようになった

ActiveRecord::Encryption::Encryptor:compressorオプションがサポートされ、利用する圧縮アルゴリズムをカスタマイズ可能になった。

module ZstdCompressor
  def self.deflate(data)
    Zstd.compress(data)
  end

  def self.inflate(data)
    Zstd.decompress(data)
  end
end

class User
  encrypts :name, compressor: ZstdCompressor
end

compress: falseを渡すことで圧縮を無効化できる。

class User
  encrypts :name, compress: false
end

heka1024

同Changelogより


つっつきボイス: 「圧縮アルゴリズムをカスタマイズ可能にする機能って前にも入っていたような気がしたけど、キャッシュストアの圧縮のカスタマイズだった(ウォッチ20230823)」「Encryptorクラスは数年前にEncryptionモジュールに導入されたんですね」「暗号化と圧縮は用途も実装も本来別物として考えるべきなので、こういう機能はあっていいと思います👍」

# activerecord/lib/active_record/encryption/encryptor.rb#L14
    class Encryptor
+     # The compressor to use for compressing the payload
+     attr_reader :compressor
+
      # === Options
      #
      # * <tt>:compress</tt> - Boolean indicating whether records should be compressed before encryption.
      #   Defaults to +true+.
-     def initialize(compress: true)
+     # * <tt>:compressor</tt> - The compressor to use.
+     #   1. If compressor is provided, it will be used.
+     #   2. If not, it will use ActiveRecord::Encryption.config.compressor which default value is +Zlib+.
+     #   If you want to use a custom compressor, it must respond to +deflate+ and +inflate+.
+     def initialize(compress: true, compressor: nil)
        @compress = compress
+       @compressor = compressor || ActiveRecord::Encryption.config.compressor
      end

参考: Active Record と暗号化 - Railsガイド

🔗 Railsのストリーミングレスポンス処理をRack 3に移行

動機/背景

文脈については#52066を参照。

Rack 3には、「ストリーミング」のレスポンスを処理する方法を拡張するさまざまな機能が導入されている。

  • body#to_aryが存在する場合は、レスポンスがバッファリング可能であることを示す(つまりアプリケーションの振る舞いに影響せずに文字列インスタンスの配列に変換される)。
  • #eachはbodyを消費する(つまりチャンクエンコーディングを用いてインクリメンタルに送信可能なチャンクを以後生成する)。これはRack 2でもある程度有効。
  • #eachに応答する代わりに、bodyが#callに応答することが可能になった(これは部分的なハイジャックのように呼び出されるが、#eachと同一のセマンティクスを持つ)。

Railsは現在もtransfer-encoding: chunkedでエンコードされたレスポンスをRackのbodyとして引き続き生成しているので、以下のような問題が生じる可能性がある。

  • レスポンスのbodyがエンコードされるため、Rackの他のミドルウェアがレスポンスを適切に操作できなくなる可能性がある。

  • たとえばrack-cacheについて言及されているが、これはキャッシュストリーミングのレスポンスで絶対に可能であり、Railsの現状の実装上の制限でしかない。

  • transfer-encodingはHTTP/2ではサポートされていない(すべてのレスポンスが事実上チャンク化されているとみなされ、ワイヤフォーマットでは長さをプレフィックスしたチャンクエンコーディングではなくバイナリフレーミングが使われるため)。Railsの実装はこの点において必要以上に制限されている。

  • 実装が不必要に複雑になり、しかもHTTP/1固有になっている。しかもこの複雑さは、プロトコルに適した方法で既にサーバー側で処理されている。

この変更は、明示的なtransfer-encodingを削除して、代わりにサーバーがレスポンスを適切にエンコードするようにした。
同PRより


つっつきボイス:「ストリーミングのレスポンス処理を従来はRails側で行っていたけど、Rack 3にその機能があるのでそれを使うようにしたということか、なるほど」「ストリーミングのコードがごっそり削除されていますね」

Rack 2-> Rack 3アップグレードガイド(翻訳)

プルリクのコメントに関連情報がみっちり書かれていた↓: レスポンスをchunkedで返すかどうかをRailsアプリケーションが気にしなくてよいように改修されたんですね👍」

このコードパスを使う既存のアプリケーションでは、レスポンスにチャンク転送エンコーディングが含まれていることが期待される。

Rackでは、特定のレスポンスをサーバー側で処理すべきかクライアント側で処理すべきかについての絶対的な要件は提供していない。Rack自身にもこれをテストする機能は存在しないし、そうすべきでもない。

Rackは、RFC3875で指定の指定に沿ったCGIモデルを大まかに元にしている。Rackサーバーは、クライアントブラウザとRackミドルウェア/アプリケーションの間のプロキシ(仲介者)と見なせるため、RFC9110で概説されている要件に従わなければならない。つまり、クライアントが 側HTTP/2で、RackアプリケーションがCGI(おおよそHTTP/1.1)の場合、HTTP/1.1レスポンスを消費して有効なHTTP/2セマンティクスにマッピングする必要がある。

RackにHTTP/2サポートの導入作業を開始したとき、このアプローチではずっと複雑になってしまい、Rackプロトコルの進化を妨げると判断した。つまり、このままでは永遠にCGIとその欠点や問題にとらわれることになる。

CGIへの依存は絶対的な要件というより利便性目的と思われるため、RackがHTTP/2をサポートする形に進化する過程で、私は共通の妥協点に向かってうまく進めるようにしてきた。言い換えれば、RackはRFC3875からRFC9110(それぞれのリンクは上記参照)へと徐々に移行中である。つまり、RackはHTTPの特定のバージョンではなく、HTTPセマンティクスに従うことになる。

この結果、可能ならRack内でHTTPバージョン固有のロジックを実行することは避け、代わりにRackが共通のセマンティクスセット(アップグレード処理用のrack.protocolなど)を提供する必要がある。これらのセマンティクスは、可能な場合は背後のプロトコル機能に明確にマッピングする必要があるが、実際の実装はサーバーに任されている。

客観的な例を1つ挙げると、Falconがtransfer-encoding: chunkedレスポンスを確認してそのレスポンスをデコードしてHTTP/2用に書き換えることは完全に可能ではあるが、このようなアプローチは非常に効率が悪く、元のエンコードに加えて以後のデコードと再エンコードのステップも発生してしまう。そのため、Rackは#to_aryおよび#eachに関連する抽象的なセマンティクスを提供し、(1)手軽にバッファリングできるレスポンスを提供する方法と(2)クライアントに段階的に送信できるレスポンスを提供する方法を指定する。

このクラスが削除されたため、レスポンスがチャンク化されなくなった。下流のWebサーバーはチャンク化されたレスポンスを使うかどうかをどのようにして知るのか?

サーバーは、特定のエンコーディングを使うかどうかを強制されるべきではない。接続しているクライアントにとって最も適切なものを決定するのはサーバー次第である。このロジックは、アプリケーション層(Railsの場合はフレームワーク層)に実装すべきではなく、実装における階層化違反のように思える。
アプリケーションは、接続しているクライアントの種類や、作成する最適なレスポンスの種類を気にする必要はない。Rackは、アプリケーションがネットワークプロトコルではなくビジネスロジックに専念できるように適切な抽象化を提供すべき。

とは言うものの、#to_ary(バッファリングできない)に応答せず、#eachに応答するレスポンスbodyは、段階的に送信されることが期待される。これについて試したことがなかったので、クライアントブラウザとRackアプリケーション間のやり取りをテストするために rack-conformを作成した。これはエンドツーエンドの統合テストであり、特定のサーバー+アプリケーションの実際のクライアント側の動作を確認できる。プルリク#20に示されているように、サーバーが意図したとおりに動作していることを確認可能。ただしバッファリング戦略が異なる場合もありうる(つまりサーバーがチャンクを独立して送信することが常に保証されるわけではない)が、これはまったく問題なく、サーバーが決定できる範囲内であると思う。

さもないと、このコードパスは現代のWebサーバーでは機能しないだろう(ただしこれを確認したわけではない)。

はっきり言って、確認に必要な作業は完了していると信じているので、必要なら自分でも確認できる。さまざまな(あらゆる主流の)サーバーで動作することを確認したい場合は、上記のテストスイートに追加のテストを自由に導入して欲しい。このあたりを詳しく見てもらえると助かる。

さらに、リクエストがHTTP/1.0の場合に特別な処理を行っているように見えるが、そのためのテストが削除されているっぽい。

上述のように、これはアプリケーションコードの問題ではなくサーバーの問題であり、ストリーミングテストで考慮すべき事項ではないため、テストを削除した。実際、HTTP/1.0でレスポンスをストリーミングすることはまったく問題ない。RailsがHTTP/1.0でストリーミングをスキップしていたのは、パフォーマンスに対する人為的な制限だった(が、現在の実状には関係ない)。

それとは別に、Railsによって生成されたチャンクを確認する追加のテストを作成するとよいだろう。Rack::Testはストリーミングであるかどうかに関係なくレスポンスを正しく正規化するので、ミドルウェア/サーバーが何を決定したかに関係なく、テストは同じように動く。これを変更する必要はないと思われる。むしろ、特定のコントローラーを呼び出すと特定のチャンクのリストが生成されることを確認する単体テストを用意する方がよさそう。

この変更が下位互換である理由がわからない。既存のアプリが壊れない理由がよくわかる説明が欲しい。

ご存知のとおり、変更が下位互換性があるかどうかを確実に知ることは不可能だが、Railsでテストが壊れたもののストリーミングレスポンスは正常に動いた。問題はテストスイートが特殊であることで、ストリーミングが実際に行われていることを確認するテストの動作レベルは誤っていると強く思っている。コントローラーが複数のチャンクを含むbodyを返していることを確認するのがベスト。このテストは、そのような利用を想定して設計されていないメカニズムを用いて、レスポンスのワイヤ形式を確認しようとしている。

互換性の喪失への対抗策として、このプルリクによって、実際にはより幅広いストリーミング方法が可能になる。たとえば、 HTTP/1.0とHTTP/2以降が正しくサポートされるようになった。Rackが提供する正しい「抽象セマンティクス」を使うことで、サーバーにレスポンスをストリーミングする機会を提供できる。これはアプリケーション層で実行できるベストであり、そのためのテストが必要。
同PRコメントより

🔗 ETagLast-Modifiedが両方存在する場合はETagを優先するよう修正

config.action_dispatch.strict_freshnessを追加。

このコンフィグをtrueに設定すると、RFC 7232, Section 6での指定に沿って、ETagLast-Modifiedが両方存在する場合はETagを優先するようになる。

従来のRailsバージョンとの互換性を維持するため、デフォルトではfalseに設定されているが、Rails 8.0からはデフォルトでtrueになる。

heka1024
同Changelogより

修正: #52191

概要

このプルリクは、RailsのHTTPキャッシュ処理の振る舞いをHTTP仕様にさらに適合するように変更する。具体的には、この変更により、リクエストが最新かどうかを判断する際に、ETagヘッダーが他の条件よりも優先されるようにする。

詳細

従来のロジックでは、リクエストが新規とみなされるには、ETagヘッダーとLast-Modifiedヘッダーの両方が一致する必要があった。修正後の新しいロジックでは、ETagヘッダーが優先される。これは、RFC 7232 Section 6で提供されているガイダンスに準拠している。

変更内容

HTTPのキャッシュハンドリングのコードをリファクタリングし、ETagヘッダーをLast-Modifiedヘッダーよりも優先するようにし、テストを更新して新しい振る舞いを反映した。

参照

同PRより


つっつきボイス:「ETagLast-Modifiedが両方あった場合の振る舞いがRFCに厳密に沿っていなかったので修正したのね」「ドキュメントにも明記されていますね」「ETagLast-Modifiedが両方指定された場合には、ETagの方がよりアプリケーション側で明示的に設定する使い方をするものなので、ETagを優先するというのは理解できる」

🔗 http_cache_foreverをデフォルトでimmutable: trueに設定

http_cache_foreverimmutable: trueになるよう修正した。

Nate Matykiewicz
同Changelogより

動機/背景
#52197expires_inimmutable: trueオプションが追加された。このプルリクはhttp_cache_foreverimmutable: trueを設定した。これはプロキシされたActive Storageファイルにも影響する。

詳細
immutable: trueは、http_cache_foreverexpires_in呼び出しで設定される。
同PRより


つっつきボイス:「キャッシュコントロールのimmutableは以前expires_inに追加されたオプションですね(ウォッチ20240709)」「それをhttp_cache_foreverでもデフォルトで効くようにしたんですね↓」「これでHTTPキャッシュは基本的に変更不可になる」「"プロキシされたActive Storageファイルにも影響する"とあるのは、Active Storageのデータ参照のアクションにも影響するということですね」

# actionpack/lib/action_controller/metal/conditional_get.rb#L323
    def http_cache_forever(public: false)
-     expires_in 100.years, public: public
+     expires_in 100.years, public: public, immutable: true

      yield if stale?(etag: request.fullpath,
                      last_modified: Time.new(2011, 1, 1).utc,
                      public: public)
    end

🔗 ファイルウォッチャーでi18nの読み込みパスを削減して最適化

  • Railtie#initialize_i18nの読み込み時間を最適化する。

I18n.load_pathをフィルタで絞り込んで、Rails.rootの下にあるファイルだけをファイルウォッチャーに渡す。修正前は、gemに含まれているロケール(変更は生じないのでウォッチャーでは不要)も含め、すべてのロケールをウォッチャーが取り込んでいた。

Nick Schwaderer
同Changelogより


つっつきボイス:「アプリケーションの実行中にgemのインストールディレクトリ内を操作することは考えにくいので
、たしかに一回読み込めば十分」「修正もシンプルですね↓」

# activesupport/lib/active_support/i18n_railtie.rb#L65
      if app.config.reloading_enabled?
        directories = watched_dirs_with_extensions(reloadable_paths)
-       reloader = app.config.file_watcher.new(I18n.load_path.dup, directories) do
-         I18n.load_path.keep_if { |p| File.exist?(p) }
+       root_load_paths = I18n.load_path.select { |path| path.start_with?(Rails.root.to_s) }
+       reloader = app.config.file_watcher.new(root_load_paths, directories) do
+         I18n.load_path.delete_if { |p| p.start_with?(Rails.root.to_s) && !File.exist?(p) }
          I18n.load_path |= reloadable_paths.flat_map(&:existent)
        end

🔗 子レコードが親レコードを自動保存するときのコールバックが重複実行されることがあったのを修正

  • 子がhas_onebelongs_toで親を自動保存する場合にコールバックが重複実行されていたのを修正

修正前は、新しい子レコードを新しい関連付けられた親レコードで永続化すると、before_validationafter_validationbefore_saveafter_saveコールバックが2回実行されていた。

修正後、これらのコールバックは期待どおりに1回だけ実行されるようになった。

Joshua Young
同Changelogより


つっつきボイス:「重複実行は明らかにバグですね: バリデーションなどは冪等に書かれることが多いと思いますが、 *_saveコールバックのように重複実行されると困る実装もあると思うので、バグ修正されるのはありがたい👍」

🔗 ジェネレータ関連2件

  • セッションジェネレータを追加

データベースでトラッキングされるセッションを用いる認証システムを作るときの出発点となる。

生成コマンド

bin/rails sessions

生成されるファイル

app/models/current.rb
app/models/user.rb
app/models/session.rb
app/controllers/sessions_controller.rb
app/views/sessions/new.html.erb
db/migrate/xxxxxxx_create_users.rb
db/migrate/xxxxxxx_create_sessions.rb

DHH
同Changelogより

基本的なセッションジェネレーターを追加して、ユーザーがこれを元に独自の認証システムを作れるようにした。
ただし、これは考えられるすべての認証の問題に対する万能の回答となることを意図したものではない。これは、あくまで基本的なパスを明らかにし、独自の認証システムを構築するのは珍しいことではないことを示すのが目的。

したがって、魔法のリンクやパスキーや2要素認証を期待しないこと。このジェネレーターはそういうものを生成しない。
同PRより


つっつきボイス:「DHHがジェネレータ周りにいろいろ手を加えていました」「Deviseのセッションを思わせるコードですね: このrate_limitみたいなコードはたしかにセッション系のコードでよく書くので、ひな形があってもよさそう👍」「テンプレで名前がばらつきにくくなるだけでもありがたいですね」

# railties/lib/rails/generators/rails/sessions/templates/controllers/sessions_controller.rb#L1
+class SessionsController < ApplicationController
+  allow_unauthenticated_access
+  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
  ...
  • scriptフォルダとジェネレータを追加

1回だけ実行するスクリプトや汎用スクリプト(例: データのマイグレーション用スクリプトやクリーンアップ用スクリプト)を保存するscript/フォルダをデフォルトで追加する。

以下のようにスクリプト生成用の新しいジェネレータを利用可能になる。

rails generate script my_script

生成したスクリプトは以下のように実行できる。

ruby script/my_script.rb

Jerome Dalbert, Haroon Ahmed
同Changelogより

#39552がクローズされた後、この議論に対処するための新しい試み。

動機/背景

このプルリクを作成した理由は、1回しか実行しないスクリプトの置き場所が明確でないため、DHHがdiscussでサポートを表明し、script/をデフォルトのディレクトリとする話が復活したことによる。

詳細

このプルリクは、データのマイグレーション用スクリプトやクリーンアップ用スクリプト、ベンチマーク用スクリプトなどを置ける、スクリプト用のデフォルトディレクトリを新たに作成する。スクリプトを作成する基本的なジェネレータも追加する。

追加情報

このプルリクにスクリプトのジェネレータを追加した理由:

  • それ用のサポートもある程度あるらしい
  • 新しいscript/ディレクトリとジェネレータは密接に関連しそう。
  • script/ディレクトリを追加することを入門ガイドで少し触れるだけでは少々物足りない感じがする。(ガイドで他にドキュメントを書ける場所が明確ではないので)ここから始めてもいいが、新しいジェネレータを追加すれば、この新しいscript/ディレクトリの使い方を間接的にもドキュメント化できるだろう。

ジェネレータが無用ならこのプルリクから削除しても構わない。逆に、ジェネレーターのみが必要なのであれば、ジェネレータは残してデフォルトの空のスクリプトフォルダを削除すればよい。

  • Rubyスクリプトにしか関心がない人がほとんどだと思うので、bashスクリプトを生成するオプションは追加しなかったが、必要なら追加可能。

  • 39552は受け入れられなかったのと、@hahmedが他の誰かに引き継ぐことに興味を示していたので、このプルリクが受け入れられるかどうか見ていきたい。
    同PRより


つっつきボイス:「新しいRailsアプリではRails.rootの下にscript/フォルダがデフォルトで作られるそうです」「スクリプトファイルの置き場所が公式に決まったんですね: この種のファイルはrakeタスクで書くのが伝統だったけど、rakeタスクはいろいろ不便なのでRailsランナーのスクリプトをここに置いて実行するのでいいという気持ちはある」

🔗 app/channels/フォルダがデフォルトから削除される

RailsではHotwireがデフォルトになったので、カスタムチャネルは多くのアプリで不要になる。カスタムチャネルが必要なアプリでも、ジェネレータでファイルを復元すれば済む。
同PRより


つっつきボイス:「scripts/フォルダが追加されたと思ったら今度はchannels/フォルダがデフォルトから削除されたのね: Hotwireがあるからデフォルトではchannels/を作らなくてもドキュメントに作り方があればいいということですね」

参考: [skip-ci][docs] Remove 'channels' from getting_started.md by hachi8833 · Pull Request #52475 · rails/rails

これに関連するドキュメントの更新プルリク↑を投げたところ、すぐにマージされました。
個人的には以下↓もマージして欲しいのですが...

参考: [ci-skip][Docs][retry] Add description for CTE to querying guide by hachi8833 · Pull Request #52423 · rails/rails -- 現在オープン


前編は以上です。

バックナンバー(2024年度第2四半期)

週刊Railsウォッチ: シャーディング用メソッドを追加、localsマジックコメント修正ほか(20240709)

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

Rails公式ニュース


CONTACT

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