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

週刊Railsウォッチ: MessagePackがcookieシリアライザとメッセージシリアライザにも導入ほか(20230607前編)

こんにちは、hachi8833です。Rails World 2023のCFP締め切りが近づいています。

週刊Railsウォッチについて

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

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

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

🔗 config.filter_parametersに同じフィルタが再追加されないよう修正

Active Recordの暗号化属性が宣言されると、そのためのフィルタが自動的にconfig.filter_parametersに追加される。このコミット以前は、モデルが再読み込みされるたびにフィルターが再追加されていた:

class Post < ActiveRecord::Base
  encrypts :title
end
irb> Rails.application.config.filter_parameters
# => [:passw, ..., :ssn]

irb> Post

irb> Rails.application.config.filter_parameters
# => [:passw, ..., :ssn, "post.title"]

irb> reload!
irb> Post

irb> Rails.application.config.filter_parameters
# => [:passw, ..., :ssn, "post.title", "post.title"]

このコミットによって、フィルタが1回だけ追加されるようになり、config.filter_parametersが際限なく大きくならないようになる。
同PRより


つっつきボイス:「config.filter_parametersに同じフィルタが複数追加されても動くけど、たしかに動作としてはよろしくないですね」「修正はinclude?(filter)を追加したシンプルなものですね」

# activerecord/lib/active_record/encryption/configurable.rb#L51
-       def install_auto_filtered_parameters_hook(application) # :nodoc:
+       def install_auto_filtered_parameters_hook(app) # :nodoc:
          ActiveRecord::Encryption.on_encrypted_attribute_declared do |klass, encrypted_attribute_name|
-           filter_parameter = [("#{klass.model_name.element}" if klass.name), encrypted_attribute_name.to_s].compact.join(".")
-           unless excluded_from_filter_parameters?(filter_parameter)
-             application.config.filter_parameters << filter_parameter
+           filter = [("#{klass.model_name.element}" if klass.name), encrypted_attribute_name.to_s].compact.join(".")
+           unless excluded_from_filter_parameters?(filter)
+             app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
              klass.filter_attributes += [encrypted_attribute_name]
            end
          end
        end

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

🔗 匿名でないモジュールをfreezeしないよう修正

#48106で、モジュールが匿名でない場合にModule#deep_dupが(コピーではなく)モジュールそのものを返すように変更された。しかしこれによって、匿名でないモジュールがActiveModel::Type::Helpers::Mutable#immutable_valueに渡されると value.deep_dup.freezeがフリーズするため、たとえば、クラス属性をモジュールに設定できなくなってしまう。

こうした問題を防ぐために、このコミットでimmutable_valuefreezeを削除した。immutable_valueActiveRecord::PredicateBuilder#build_bind_attributeからのみ呼び出されるメソッドで、その目的は他のコードが値を改変できないように保護することだけであり、その値を実際にfreezeすることではない。
これによって、Rails #96281のテストなどの独立したテストの失敗が修正される。
同PRより


つっつきボイス:「メソッド名はimmutable_valueだけど、dupして返すものをさらにfreezeするとクラス属性とかを設定できなくなるのでdupするだけでいい」「修正はfreezeを外しただけなんですね」

# activemodel/lib/active_model/type/helpers/mutable.rb#L5
    module Helpers # :nodoc: all
      module Mutable
        def immutable_value(value)
-         value.deep_dup.freeze
+         value.deep_dup
        end

🔗 schema.table.columnのようにカラム指定でスキーマも指定可能になった

動機/背景
ActiveRecord::PredicateBuilderは現在、カラム名をピリオド1つのドット表記で指定することのみを想定している。スキーマに名前空間があるテーブルに対してドット表記でカラムを指定すると、スキーマ名がテーブル名として使われる。このプルリクでは、table.columnだけでなく、schema.table.columnというフォーマットでもカラムを指定可能になる。
修正されるissue: #48172
詳細
このプルリクは、ActiveRecord::PredicateBuilder.referencesActiveRecord::PredicateBuilder#convert_dot_notation_to_hashをの振る舞いを変更する。
CHANGELOGには記載しなかったが、この変更をレビュアーが重要視するのであれば喜んで追記する。
同PRより


つっつきボイス:「なるほど、PostgreSQLとかにあるschema.table.columnのような書き方をサポートしたんですね👍」「今まではtable.columnみたいにドットを1つしか書けなかったのか」「今まではセッションで指定しているデフォルトのスキーマからしか取れなかったということですね」

# activerecord/test/cases/relation/predicate_builder_test.rb#L26
+   def test_references_with_schema
+     assert_equal %w{schema.table}, ActiveRecord::PredicateBuilder.references(%w{schema.table.column})
+   end
+
+   def test_build_from_hash_with_schema
+     assert_match %r{schema.+table.+column}i, Topic.predicate_builder.build_from_hash("schema.table.column" => "value").first.to_sql
+   end
+
+   def test_does_not_mutate
+     defaults = { topics: { title: "rails" }, "topics.approved" => true }
+     Topic.where(defaults).to_sql
+     assert_equal({ topics: { title: "rails" }, "topics.approved" => true }, defaults)
+   end

🔗 MessagePack関連

🔗 cookieシリアライザとメッセージシリアライザでも:message_pack形式をサポート

config.action_dispatch.cookies_serializer:message_pack:message_pack_allow_marshalをシリアライザとして渡せるようになった。これらのシリアライザではmsgpack gem (>= 1.7.0)が必要。

MessagePack形式は、パフォーマンスの向上とペイロードサイズの縮小を実現できる。また以下のように、JSONでサポートされていない一部のRuby型のラウンドトリップもサポートする。

cookies.encrypted[:foo] = [{ a: 1 }, { b: 2 }.with_indifferent_access, 1.to_d, Time.at(0, 123)]
# 変更前: config.action_dispatch.cookies_serializer = :json
cookies.encrypted[:foo]
# => [{"a"=>1}, {"b"=>2}, "1.0", "1969-12-31T18:00:00.000-06:00"]
cookies.encrypted[:foo].map(&:class)
# => [Hash, Hash, String, String]
# 変更後:config.action_dispatch.cookies_serializer = :message_pack
cookies.encrypted[:foo]
# => [{:a=>1}, {"b"=>2}, 0.1e1, 1969-12-31 18:00:00.000123 -0600]
cookies.encrypted[:foo].map(&:class)
# => [Hash, ActiveSupport::HashWithIndifferentAccess, BigDecimal, Time]

:message_packシリアライザは必要に応じてActiveSupport::JSONでデシリアライズする形にフォールバックし、:message_pack_allow_marshalシリアライザはActiveSupport::JSONに加えてMarshalでもデシリアライズする形にフォールバックする。
さらに、:marshal:json:json_allow_marshal:hybridとも呼ばれる)シリアライザも必要に応じてActiveSupport::MessagePackでデシリアライズする形にフォールバックする。この振る舞いによって古いcookieも引き続き読めるようになり、移行しやすくなる。
Jonathan Hefner
同Changelogより


MessageEncryptorMessageVerifierconfig.active_support.message_serializer:message_pack:message_pack_allow_marshalをシリアライザとして渡せるようになった。これらのシリアライザではmsgpack gem (>= 1.7.0)が必要。

MessagePack形式は、パフォーマンスの向上とペイロードサイズの縮小を実現できる。また以下のように、JSONでサポートされていない一部のRuby型のラウンドトリップもサポートする。

verifier = ActiveSupport::MessageVerifier.new("secret")
data = [{ a: 1 }, { b: 2 }.with_indifferent_access, 1.to_d, Time.at(0, 123)]
message = verifier.generate(data)
# 変更前: config.active_support.message_serializer = :json
verifier.verified(message)
# => [{"a"=>1}, {"b"=>2}, "1.0", "1969-12-31T18:00:00.000-06:00"]
verifier.verified(message).map(&:class)
# => [Hash, Hash, String, String]
# 変更後: config.active_support.message_serializer = :message_pack
verifier.verified(message)
# => [{:a=>1}, {"b"=>2}, 0.1e1, 1969-12-31 18:00:00.000123 -0600]
verifier.verified(message).map(&:class)
# => [Hash, ActiveSupport::HashWithIndifferentAccess, BigDecimal, Time]

:message_packシリアライザは必要に応じてActiveSupport::JSONでデシリアライズする形にフォールバックし、:message_pack_allow_marshalシリアライザはActiveSupport::JSONに加えてMarshalでもデシリアライズする形にフォールバックする。
さらに、:marshal:json:json_allow_marshal:hybridとも呼ばれる)シリアライザも必要に応じてActiveSupport::MessagePackでデシリアライズする形にフォールバックする。この振る舞いによって古いメッセージも引き続き読めるようになり、移行しやすくなる。
Jonathan Hefner
同Changelogより


つっつきボイス:「最近Active SupportとキャッシュシリアライザでMessagePackがサポートされましたけど(ウォッチ20230502)(ウォッチ20230530)、今度はcookieシリアライザとメッセージシリアライザでもMessagePackがサポートされました」「2つのプルリクメッセージは主語以外ほとんど同じ内容ですね」

「今後Rails内部の各種シリアライザでは、cookieのようにユーザー側で保持するものも含めて基本的にMessagePackを使っていこうという動きですね: MessagePackはバイナリフォーマットなので文字列を介するような変換が途中で発生したりするとオーバーヘッドが出るかもしれないけど、メモリ上でやりとりする分にはいいと思います👍」「JSONMarshalによる古いフォーマットへのフォールバックについても配慮されているので移行しやすそう」

参考: MessagePack: JSONをもっと速く、小さく。

🔗 メッセージシリアライザのデフォルトが:json_allow_marshalになった

このコミットより前は、config.load_defaults 7.1を指定すると古いメッセージ(または古いアプリのメッセージ)が読めなくなってしまうことがあり、開発者はこれを防ぐために手動でconfig.active_support.message_serializer = :json_allow_marshalを設定することが推奨されていた。

このコミットによって、config.load_defaults 7.1で設定されるデフォルトのメッセージシリアライザが:jsonから:json_allow_marshalに変更され、アップグレードしたアプリで設定を追加せずに古いメッセージを引き続き読めるようになる。

最終的な意図は、JSONでシリアライズされたメッセージの生成をアプリでしばらく続けた後、Rails 7.2でデフォルトを(Marshalにフォールバックしない):jsonに変更することである。

Rails 7.2より前のアプリは、手動でconfig.active_support.message_serializer = :jsonを設定することでJSONのみのシリアライズを選択できる。
修正: #48118
同PRより


つっつきボイス:「メッセージシリアライザがJSONMarshalフォーマットにフォールバックするようにしたうえで、従来デフォルトだった:json:json_allow_marshalに変わったのか: こうやって内部データ形式の変更にちゃんとフォールバック手段が用意されたことで段階的移行がやりやすくなるのはいい👍」「自力で同じことをやろうとするとテストとかが結構大変になりますよね」

参考: Switch ActiveSupport::MessageEncryptor Default Serializer to JSON by fresh-eggs · Pull Request #42846 · rails/rails
参考: Rails API ActiveRecord::Encryption::MessageSerializer

🔗 キャッシュキーに空リストを渡した場合に早期脱出するようになった

修正: #48145
read_multiwrite_multifetch_multiは、空リストを渡して呼び出されたら早期脱出すべき。
実際に修正すべきは、空のキーリストを正しく扱えるようにすることだと信じている。
cc @joshuay03: そちらのAction Viewテストについて共著扱いにする。
同じくcc @jonathanhefner
同PRより


つっつきボイス:「これもシンプルな修正」「キャッシュキーが空なら読み出しや書き込みなどは不要なので早期脱出する方がいいですね」

# activesupport/lib/active_support/cache.rb#L439
      def read_multi(*names)
+       return {} if names.empty?
+
        options = names.extract_options!
        options = merged_options(options)

        instrument :read_multi, names, options do |payload|
          read_multi_entries(names, **options, event: payload).tap do |results|
            payload[:hits] = results.keys
          end
        end
      end

参考: Rails API ActiveSupport::Cache::Store

🔗 Active Recordのカウンタインクリメントやデクリメントに1以外の増分量を効果的に指定できるようになった

ユーザーが(たとえば何かデータをインポートした後で)何らかのカウンタを1以外の増分量(amount)でインクリメントしたい場合、以下のようにする必要がある。

  • increment_counterを数回呼び出す: これは遅くなる。
  • where(id: id).update_all("counter = counter + 5")を呼び出す: これはincrement_counterよりも不便(COALESCEを使う場合や、楽観的ロックのカラムをインクリメントする場合は、それらにも気を遣うことになる)。
  • record.increment(:counter, 5)を呼び出す: カウンタをメモリ上で更新してから値をデータベースに書き込むが、未更新の値が競合状態によって上書きされる可能性がある。

利用例:

Company.increment_counter(:users_count, 5, by: 3)

この変更は、Railsの既存のインクリメントメソッドActiveRecord::Persistance#incrementおよびActiveSupport::CacheStore#incrementと同等(どちらも既に増分量を受け取れる)。


つっつきボイス:「カウンタをインクリメント・デクリメントするときに1以外の差分値を指定したいというのはよくあるテーマですね: これをやりたいのはわかる」

「ところでプルリクメッセージにあった2種類の既存incrementdecrement↓を見てみると、増分量を指定する引数名が違うのが気になりますね」「ほんとだ、Active RecordのはbyだけどActive Supportのはamountになってる」「英語的にはbyの方が自然な気はするかな」「最初amountという引数名を見たときにてっきりカウンタの上限値のことかと思っちゃいました」

参考: Rails API increment -- ActiveRecord::Persistence
参考: Rails API increment -- ActiveSupport::Cache::Store
参考: PostgreSQL 15.0ドキュメント 9.18.2. COALESCE -- 9.18. 条件式

🔗 保持するクエリキャッシュを直近50件までに変更した

実際にどの程度なのかはわからないが、実行に時間のかかるジョブを処理中にクエリキャッシュによってメモリが枯渇してしまう問題を何度も耳にしている。
総じて、これを際限なくキャッシュしないようにするのが賢明に思える。以下のフィードバックを受けてこのプルリクをオープンした。
cc @tenderlove: もう9ce0211の文脈を覚えてないだろうけど、自分は基本的にこれを取り消すつもりなのでメンションしておく。
ネットで見かけた不満:


Active RecordのクエリキャッシュでLRU(Least Recently Used)エントリを追い出すようにした。

デフォルトでは、直近用いられた50件のクエリだけを維持する。
キャッシュサイズはdatabase.ymlで設定可能。

development:
  adapter: mysql2
  query_cache: 100

クエリキャッシュを完全に無効にすることも可能。

development:
  adapter: mysql2
  query_cache: false

Jean Boussier
同Changelogより


つっつきボイス:「クエリキャッシュの枯渇ってあまり気にしたことなかったけど、あるんですね」「LRUはこういうときによく使われるアルゴリズムですね」

参考: Least Recently Used - Wikipedia

「ここではキャッシュヒットしたかどうかをActiveSupport::Notificationsを使って調べられるようにしてあるんですね」

# activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb#L118
      private
        def lookup_sql_cache(sql, name, binds)
+         key = binds.empty? ? sql : [sql, binds]
+         hit = false
+         result = nil
+
          @lock.synchronize do
-           if @query_cache[sql].key?(binds)
-             ActiveSupport::Notifications.instrument(
-               "sql.active_record",
-               cache_notification_info(sql, name, binds)
-             )
+             @query_cache[sql][binds]
+           if (result = @query_cache.delete(key))
+             hit = true
+             @query_cache[key] = result
            end
          end
+
+         if hit
+           ActiveSupport::Notifications.instrument(
+             "sql.active_record",
+             cache_notification_info(sql, name, binds)
+           )
+
+           result
+         end
        end

「週次や月次バッチのような大量の処理をまとめてやる場合にGCで片付かないものがたまっていくというのは、いわゆるmemory bloat(メモリの肥大化)に相当すると思いますが、その結果メモリ不足で落ちるのはたしかに避ける方がいいので、少々キャッシュミスで再読み込みが発生する可能性があってもキャッシュに上限を設定しようという考えなんでしょうね」「なるほど」

参考: Active Support の Instrumentation 機能 - Railsガイド

🔗 HTMLのpictureタグをサポート

HTMLのpictureタグをサポートするようになった。この機能ではString渡し、Array渡し、またはブロックがサポートされる。

imgタグに:imageキーで直接プロパティを渡せるようになった。
pictureタグはimgタグを必要とするため、指定した最後の要素がimgタグに使われる。
ブロックを渡すことでpictureタグを完全に制御可能で、それに応じてタグの内容が入力される。

以下のように単一ソースでも利用できる。

<%= picture_tag("picture.webp") %>

上は以下を生成する。

<picture>
    <img src="/images/picture.webp" />
</picture>

複数ソースの場合は以下のように書く。

<%= picture_tag("picture.webp", "picture.png", :class => "mt-2", :image => { alt: "Image", class: "responsive-img" }) %>

上は以下を生成する。

<picture class="mt-2">
    <source srcset="/images/picture.webp" />
    <source srcset="/images/picture.png" />
    <img alt="Image" class="responsive-img" src="/images/picture.png" />
</picture>

ブロック渡しで完全に制御する場合:

<%= picture_tag(:class => "my-class") do %>
    <%= tag(:source, :srcset => image_path("picture.webp")) %>
    <%= tag(:source, :srcset => image_path("picture.png")) %>
    <%= image_tag("picture.png", :alt => "Image") %>
<% end %>

上は以下を生成する。

<picture class="my-class">
    <source srcset="/images/picture.webp" />
    <source srcset="/images/picture.png" />
    <img alt="Image" src="/images/picture.png" />
</picture>

Juan Pablo Balarini
同Changelogより


つっつきボイス:「HTMLの<picture>タグ、そういえばありましたね」「ブラウザや端末によってはサポートしていない可能性がある画像や動画を別のものにフォールバックさせるタグのようですね↓」「ブラウザ側で選ぶためのヒントを提供するタグですか、なるほど」「こういう機能をRailsのタグヘルパーで使いたい人には嬉しいでしょうね」

参考: <picture>: 画像要素 - HTML: HyperText Markup Language | MDN

<picture> は HTML の要素で、0 個以上の <source> 要素と 1 つの <img> 要素を含み、様々な画面や端末の条件に応じた画像を提供します。
ブラウザーは複数の <source> 子要素を検討し、その中から最も適切なものを選択します。適切なものがない場合や、ブラウザーが <picture> 要素に対応してない場合、 <img> 要素の src 属性で指定された URL が選択されます。選択された画像は <img> 要素が占有する領域に表示されます。
MDNより

<!-- MDNより-->
<!--Change the browser window width to see the image change.-->

<picture>
    <source srcset="/media/cc0-images/surfer-240-200.jpg"
            media="(orientation: portrait)">
    <img src="/media/cc0-images/painted-hand-298-332.jpg" alt="">
</picture>

「そういえば以下のEvil Martiansの記事でもこの<picture>タグが使われていました↓」

保存版: Web画像フォーマットを「正しく」扱う(4)Webのアニメーション(翻訳)

<!-- 同記事より -->
<picture>
    <source
     media="(max-width: 799px)"
     srcset="cupcake.webp 1x, cupcake@2x.webp 2x"
     type="image/webp"
    >
    <source
     media="(min-width: 800px)"
     srcset="huge-cupcake.webp 1x, huge-cupcake@2x.webp 2x"
     type="image/webp"
    >
    ...
    <img src="cupcake.jpg" srcset="cupcake.jpg 1x, cupcake@2x.jpg 2x" alt="a yummy cupcake">
</picture>

🔗 assert_enqueued_email_withにマッチャーを渡せるようになった

assert_enqueued_email_withの引数やパラメータにprocを渡せるようになった。

assert_enqueued_email_with DeliveryJob, params: -> p { p[:token] =~ /\w+/ } do
  UserMailer.with(token: user.generate_token).email_verification.deliver_later
end

Max Chernyak
同Changelogより


つっつきボイス:「assert_enqueued_email_withは以前も見かけたことがありますね(ウォッチ20220829): これをさらに改良して-> p { p[:token] =~ /\w+/ }のようにマッチャーを渡せるようになったんですね、なるほど」

参考: Rails API assert_enqueued_email_with -- ActionMailer::TestHelper


前編は以上です。

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

週刊Railsウォッチ: Rubyで環境変数を扱う、Web標準に「Baseline」ステータス追加ほか(20230531後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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