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

週刊Railsウォッチ: Active Storageバリアントの事前変換、Linkヘッダープリロードのオプトアウトほか(20230802前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Active Storageのバリアントをバックグラウンドで事前変換するオプションが追加

Active Storageのバリアントは必要が生じると即座に処理されるが、それらにアクセスされることが確実な場合は事前に処理したいときがある。

バリアントを宣言するときのpreprocessedオプションが追加された。

class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
  end
end

Shouichi Kamiya
同Changelogより

参考: § 9 画像を変形する -- Active Storage の概要 - Railsガイド


つっつきボイス:「バリアント画像は参照されたときに処理されるけど、それを事前にできるオプションも欲しいので追加したということか: shrineやcarrierwaveにも同じ機能があった気がする」「preprocessed: trueを指定すると事前処理するんですね」「バックグラウンドで処理するためにActiveStorage::TransformJobでジョブを追加するようになった↓、なるほど」「これは必要な機能👍」

# activestorage/app/jobs/active_storage/transform_job.rb
# frozen_string_literal: true

class ActiveStorage::TransformJob < ActiveStorage::BaseJob
  queue_as { ActiveStorage.queues[:transform] }

  discard_on ActiveRecord::RecordNotFound
  retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer

  def perform(blob, transformations)
    blob.variant(transformations).process
  end
end

shrinerb/shrine - GitHub

参考: File Processing | Shrine

carrierwaveuploader/carrierwave - GitHub

なお、#47473には追加修正も入っていました↓

参考: Fix ActionText::ContentHelper allowed tags and attrs by flavorjones · Pull Request #48746 · rails/rails

🔗 Rails 7.1のAction TextのサニタイザでRails::HTML5::SafeListSanitizerをデフォルトで使うようになった

Rails 7.1コンフィグでRails::HTML5::SafeListSanitizerをデフォルトで使うようになった(サポートされている場合)。

Action Textのサニタイザはconfig.action_text.sanitizer_vendorで設定可能。サポートされる値はRails::HTML4::SanitizerまたはRails::HTML5::Sanitizer

Rails 7.1の設定では、これはRails::HTML5::Sanitizerに設定され(サポートされている場合)、そしてRails::HTML4::Sanitizerにフォールバックする。
以前の設定ではデフォルトでRails::HTML4::Sanitizerだった。

Mike Dalessio
同Changelogより


#48523は、Action Textの動作を変更し、利用可能な場合にはHTML5のSafeListSanitizerを使い、それ以外の場合にはHTML4のSafeListSanitizerにフォールバックするようにした。
@rafaelfrancaの提案に基づき、このプルリクではconfig.action_text.sanitizer_vendorconfig.action_view.sanitizer_vendorと非常に似ている)とRails 7.1のデフォルトを導入し、その振る舞いの変更を制御する。

このプルリクは、config.action_view.sanitizer_vendorのテストカバレッジも補完する。

詳細
このプルリクは、config.action_text.sanitizer_vendorを導入する。これは、config.action_view.sanitizer_vendorと同じように振る舞う。

追加情報

Railsのメンテナーが、Action ViewとAction Textに本当に2つの設定パラメータが必要と考えているのか、それともRails全体でconfig.sanitizer_vendorのようなものを統合すべきと考えているのかについて興味がある。
同PRより


つっつきボイス:「最近HTML5(正式名はHTML Living Standard)関連の改修が増えていますけど(ウォッチ20230621ウォッチ20230719)、これもそうなんですね」「HTML4のライブラリとHTML5のライブラリを混ぜて使うと不整合になってしまうので仕方ないですね」

参考: Action Text の概要 - Railsガイド

🔗 ActiveSupport::JSON.encodeを高速化

これにより、.to_json/ActiveSupport::JSON.encodeのパフォーマンスがほぼ倍増する(データがすでにJSON対応であり、オプションを指定しない場合に最も影響が大きいが、すべてのケースで高速化されるはず)。この変更はユーザーにとってほぼ透明である。わかりやすくするために、この変更を3つのコミットに分割した。

1: ActiveSupport::JSON.dumpのオプションを使わないことで余分な処理を回避する

従来のJSONGemEncoder.encodeは、常に2パスで実行していた。
最初に.as_json(options)を呼び出し、その後は再帰的に.as_json(今度はオプションなしで)を呼び出して、データが「JSON-ready」表現に収束するまで、パス2のjsonifyを実行していた。

オプションが指定されていない場合、パス2はパス1と同等になるはずなので、それを検出して「jsonify」ステップのみを実行する。

この変更でユーザーから唯一見える効果は、空のハッシュを渡すのではなくas_jsonにオプションを渡さないようにすることだが、既存のas_jsonの実装はそれを受け入れることが期待されている(著者編集:ただし、Active ModelとActive Recordでそれぞれ1つのテストケースが壊れた)。

これは、数年前に@tenderlove@eileencodesが行った#34633の「軽量版」と考えてもよいかもしれない(これは依然として素晴らしいアイデアであり、将来的な実装になる可能性もあるが、このプルリクよりも変更が広範囲になる)。

2: 文字列入力ではなくJSON出力をエスケープする

Railsは、JSON gemと比べて文字列を追加でエスケープしている(U+2028U+2029<>および &もエスケープする)。JSONでは、これらの文字が有効な場所はJSON文字列内だけなので、出力時にエスケープ処理を行っても同等であり、かつ高速になる。

このコミットは最もパフォーマンスに影響を与えるため、入力と無関係に.to_jsonの呼び出しを強化できる。このエスケープ処理はまだかなりコストが高いが、エスケープルールの調整や上流でより高速な実装を検討していく予定。

3: シンボルを"JSON-ready"とみなしてjsonifyを改善

従来のjsonifyは、Integerniltruefalseに対しても.as_jsonを呼び出していたが(これは#26933で導入されたらしい)、これらの型は「JSON-ready」と見なされている。技術的にはユーザーがこれらの型の.as_jsonをオーバーライドする可能性も一応あるが、このユースケースは想像できないし、サポートすべきではないと考えている。

Numericのジェネリックな「.as_json」呼び出しの挙動は変更しなかった(ユーザーがサブクラスを持っている場合にas_jsonが実装されている可能性があるため)。この挙動はFloatでも使われる(NaN/Infinity/-Infinitynilに変換する)。

これにより、「JSON-ready」タイプのリストにシンボルも追加されるようになって入力時のエスケープ処理が行われなくなるので、文字列への不要なキャストを回避できる。jsonifyの出力はJSON.generateを通る前にユーザーから見えることはないため、ユーザーには見えないだろう。

これにより、Hashの処理も修正されてすべてのキーに対してto_sを呼び出すようになる。これは.as_jsonの振る舞いとJSONの要件(キーはStringsでなければならない、JSON gemはシンボルを文字列に変換することを認識している)と一致する。
同PRより


つっつきボイス:「詳しくは見てないけど、危険な文字が通過しない範囲で、しなくてもいい処理をスキップしたり、エスケープのタイミングを変更したり、シンボルをJSON-readyと見なしたりすることで高速化したようですね」

🔗 Linkヘッダーのプリロードをオンオフできるようになった

stylesheet_link_tagjavascript_include_tagの呼び出しでLink preloadヘッダーのオプトイン/アウトを可能になった。

# 設定が有効であってもヘッダーを除外する
javascript_include_tag("http://example.com/all.js", preload_links_header: false)

# 設定が無効であってもヘッダーをインクルードする
stylesheet_link_tag("http://example.com/all.js", preload_links_header: true)

Alex Ghiculescu
同Changelogより

参考: §1.1.8 stylesheet_link_tag -- Action View ヘルパー - Railsガイド
参考: §1.1.5 javascript_include_tag -- Action View ヘルパー - Railsガイド


つっつきボイス:「今まではアセットの読み込みでLinkヘッダーのプリロードがデフォルトで有効になっていたけど、preload_links_header:オプションでプリロードするかどうかを指定できるようになったのか」「issue #48517によると、プリロードをオフにしないとimportmapが壊れることがあったみたいですね」「今もconfig.action_view.preload_links_headerでグローバルに変更はできるけど、stylesheet_link_tagjavascript_include_tagのオプションとして指定できるようにしたのね」

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

Linkヘッダーってどれかと思ったらこれのことか↓」「これ知りませんでした」

参考: Link - HTTP | MDN

Linkヘッダーの関連情報に103 Early Hintsステータスへのリンクもある: たとえばレスポンスのボディはレンダリングが終わってから出力する必要があるような場合に、レスポンスでLinkヘッダーを先に返すことで、レンダリング中にリソースをブラウザ側でプリロードできるようになるんですね」「なるほど」「プロキシがらみなどでこのプリロードをオプトアウトしたくなる状況はありそう」

HTTP 103 Early Hints インフォメーションレスポンスステータスコードは、主に Link ヘッダーと共に使用され、サーバーがまだレスポンスを準備している間にユーザーエージェントがリソースのプリロードを開始できるようにすることを目的としています。
103 Early Hints - HTTP | MDNより

🔗 クエリログが有効な場合はプリペアドステートメントを無効にするよう修正

修正: #48398

プリペアドステートメントとクエリログは互換性がない。クエリログはすべてのクエリを一意にするため、プリペアドステートメントとは組み合わせられない。

Marginalia gemではこの点をいい感じに説明している。

Marginaliaでプリペアドステートメントを使う際は注意すること。request_idなどのコンポーネントを使うとすべてのクエリが一意になるため、Active Recordは新しいプリペアドステートメントをクエリごとに作成するようになり、それが元でシステムリソースが枯渇する可能性がある。cardinalityが大きいコンポーネントを使いたい場合は、プリペアドステートメントを無効にすること。
同PRより


つっつきボイス:「Railsではデフォルトでプリペアドステートメントを有効にしたログが出力されるけど、クエリログを有効にするとプリペアドステートメントなしのログが出力されるということか: このあたりは知っておくというか気をつけておいてもよさそう」

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.5 プリペアドステートメント

「プリペアドステートメントを使わないとどうなるんでしょうか?」「値も含めて生のSQLクエリログがすべて出力されるようになりますね: そうなるとDB側のクエリオプティマイザのキャッシュが効きにくくなるなどのデメリットはあると思います」「なるほど」

参考: プリペアドステートメントとは - 意味をわかりやすく - IT用語辞典 e-Words

🔗 belongs_to :inverse_ofを介したhas_one :through関連付けで、レコードをビルドできるように修正

has_one throughの単一の逆関連付けを介して、レコードのビルドが可能になった(belongs_to throughの場合は外部キーと主キーモデルのリンクは不要)。
has_oneでは関連付けをミュータブルにできないため、レコードをビルドできない。
Gannon McGibbon
同Changelogより


動機/背景
このプルリクを作成した理由は、has_one through:の振る舞いが壊れていて、ビルドしたレコードにリンクしようとするとクラッシュするため。

詳細

このプルリクは、ActiveRecord::Associations::ThroughAssociationを変更して、ソースのリフレクションが単一(has_one / belongs_to)の場合は逆方向へのリンクを持つビルドされたレコードをスキップするようにした。
このコードはコレクションの関連付けのみを対象とする。

追加情報
ついでに、record.build_<関連付け名>がデフォルトでは属性ハッシュを含んでいないことにもデバッグ中に気づいた(1d4c288)。

 def build_#{name}(*args, &block) 

しかしrecord.<関連付け名>s.buildは以下のようになる(1d4c288)。

 def build(attributes = {}, &block) 

これはバグかもしれないが、単一の関連付けコードではこれを考慮しているらしいので、修正は不要だった。ActiveRecord::Associations::ThroughAssociation#build_record が唯一のコードパスらしい。ここでは attributesがハッシュであると仮定している。
同PRより


つっつきボイス:「has_one through:って使ったことなかったかも」「こういうinverseがらみの修正が前にもあった気がしますね」

参考: 2.5 has_one :through関連付け -- Active Record の関連付け - Railsガイド

# activerecord/lib/active_record/associations/through_association.rb#L117
        def build_record(attributes)
-         inverse = source_reflection.inverse_of
-         target = through_association.target-
-         if inverse && target && !target.is_a?(Array)
-           Array(target.id).zip(Array(inverse.foreign_key)).map do |primary_key_value, foreign_key_column|
-             attributes[foreign_key_column] = primary_key_value
+         if source_reflection.collection?
+           inverse = source_reflection.inverse_of
+           target = through_association.target
+
+           if inverse && target && !target.is_a?(Array)
+             Array(target.id).zip(Array(inverse.foreign_key)).map do |primary_key_value, foreign_key_column|
+               attributes[foreign_key_column] = primary_key_value
+             end
            end
          end

プルリクメッセージだけだとわかりにくいので、テストコードを部分的に引用しました。

# https://github.com/gmcgibbon/rails/blob/hmt_singular_fix/activerecord/test/models/member.rb
class Member < ActiveRecord::Base
  has_one :current_membership
  # ...
  has_one :club, through: :current_membership
# https://github.com/gmcgibbon/rails/blob/hmt_singular_fix/activerecord/test/models/membership.rb
class CurrentMembership < Membership
  belongs_to :member
  belongs_to :club, inverse_of: :membership
end
# https://github.com/gmcgibbon/rails/blob/hmt_singular_fix/activerecord/test/cases/associations/has_one_through_associations_test.rb#L103
  def test_building_works_with_has_one_through_belongs_to
    new_member = Member.create!(name: "Joe")
    new_member.create_current_membership!
    new_club = new_member.build_club

    assert_equal(new_member.club, new_club)
  end

🔗 ネストしたfield_idfield_nameの添字の値を二重エンコードしないようになった

field_idfield_nameのビューヘルパーメソッドには、デフォルトのキーワード引数としてindex: @optionsを渡すこと。
Sean Doyle
同PRより


つっつきボイス:「field_idfield_nameはAction Viewのメソッドですね」「この[0]が余分に追加されていたのか↓」

field_nameは、余分な添字パラメータ[0]parent[children_attributes][0][0][grandchildren_attributes][]のように追加しています。
#47436より

# actionview/lib/action_view/helpers/form_helper.rb#L1770
-     def field_id(method, *suffixes, namespace: @options[:namespace], index: @index)
+     def field_id(method, *suffixes, namespace: @options[:namespace], index: @options[:index])
        @template.field_id(@object_name, method, *suffixes, namespace: namespace, index: index)
      end
# actionview/lib/action_view/helpers/form_helper.rb#L1770
-     def field_name(method, *methods, multiple: false, index: @index)
+     def field_name(method, *methods, multiple: false, index: @options[:index])
        object_name = @options.fetch(:as) { @object_name }

        @template.field_name(object_name, method, *methods, index: index, multiple: multiple)
      end

前編は以上です。

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

週刊Railsウォッチ: Rubyにdefp導入の提案、IRB 1.7.3リリースほか(20230727後編)

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

Rails公式ニュース


CONTACT

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