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

週刊Railsウォッチ: システムテストでPlaywrightをサポート、to_paramのデリミタを変更可能にほか(20230906前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Active Jobにafter_discardフックが追加

after_discardは、ジョブが破棄される直前に実行されるブロックを定義する。

このメソッドによって、ジョブが失敗したときにブロックを実行可能になるので、ジョブの作成者と、ジョブに含まれるgemやモジュールの双方にとって有用。

after_discardは、ジョブの既存のリトライ動作を尊重するが、リトライの例外がretry_ondiscard_onに渡されたブロックで処理される場合にも実行する。

動機/背景

after_discardは、既にShopifyのモノリスで長年使われている。開発者はこれを利用してクリーンアップや失敗処理のコードを定義しており、ジョブの失敗を追跡する機能もこれに依存している。

現在のActive Jobにおける例外処理システムの他の部分は、この振る舞いに近づいているが、ジョブが失敗した場合にコードブロックを明確に実行する方法があることに価値があると思う。

詳細

このプルリクはafter_discardを追加する。既存の振る舞いは変更されない。

after_discardは、ジョブが失敗により破棄される直前のあらゆる状況で呼び出される。例:

  • retry_ondiscard_onを使わないジョブが例外をraiseする場合
  • 例外がdiscard_onで処理される場合(ブロックの有無を問わない)
  • ジョブがその例外のリトライ回数を使い果たした後のretry_onで例外が処理される場合(ブロックの有無を問わない)
  • after_discardブロックを実行するときに複数の例外がraiseした場合は、最後の例外だけがraiseする。この振る舞いによって、例外発生時であってもすべてのブロックが実行されるようになる。

同PRより


「Shopify内部で既に使われているafter_discardがマージされました」「失敗して詰まったジョブは、たいていRedisなどのジョブキューから直接削除してたな〜」「ジョブが失敗したときにJavaのfinallyみたいな感じで後処理を書けるんですね: 既存の振る舞いを変えない形になっているし、あれば便利👍」

# 同Changelogより
  class AfterDiscardJob < ActiveJob::Base
    after_discard do |job, exception|
      Rails.logger.info("#{job.class} raised an exception: #{exception}")
    end

    def perform
      raise StandardError
    end
  end

参考: Active Job の基礎 - Railsガイド
参考: Rails API ActiveJob::Exceptions::ClassMethods

🔗 テストのresponse.parsed_bodyでパターンマッチング構文をサポート

JSON形式のresponse.parsed_bodyActiveSupport::HashWithIndifferentAccessでパースするようになった

response.parsed_bodyのJSONコンテンツをActiveSupport::HashWithIndifferentAccessでパースしてパターンマッチング互換にすることで、minitestの新しいassert_patternと統合される。

同Changelogより


動機/背景

NokogiriMinitestが、Rubyのパターンマッチングを統合する以下のプルリクをマージした。

詳細

このコミットは、新しいアサーションのカバレッジを追加し、response.parsed_bodyメソッドのAPIドキュメントにサンプルコードを追加する。

追加情報

このコミットは、JSONレスポンスでパターンマッチングをサポートするために、JSON.parseobject_class: ActiveSupport::HashWithIndifferentAccessを指定して呼び出すようレスポンスパーサーを変更する(理由は、HashのキーがStringインスタンスになるとRubyのパターンマッチング構文と互換性がなくなるため)。

例:

irb(main):001:0> json = {"key" => "value"}
=> {"key"=>"value"}
irb(main):002:0> json in {key: /value/}
=> false

irb(main):001:0> json = {"key" => "value"}
=> {"key"=>"value"}
irb(main):002:0> json in {"key" => /value/}
.../3.2.0/lib/ruby/gems/3.2.0/gems/irb-1.7.4/lib/irb/workspace.rb:113:in `eval': (irb):2: syntax error, unexpected terminator, expecting literal content or tSTRING_DBEG or tSTRING_DVAR or tLABEL_END (SyntaxError)
json in {"key" => /value/}
             ^

        .../ruby/3.2.0/lib/ruby/gems/3.2.0/gems/irb-1.7.4/exe/irb:9:in `<top (required)>'
        .../ruby/3.2.0/bin/irb:25:in `load'
        .../ruby/3.2.0/bin/irb:25:in `<main>'

以下のようにStringキーをSymbolキーに対応付けると、パターンマッチングが可能になる。

irb(main):005:0> json = {"key" => "value"}.with_indifferent_access
=> {"key"=>"value"}
irb(main):006:0> json in {key: /value/}
=> true

同PRより


つっつきボイス:「parsed_bodyに渡したJSONでwith_indifferent_accessが呼ばれるようになったことでin {key: /value/}のようなパターンマッチング構文が使えるようになったのか」「テストでパターンマッチング構文を使って複数のものとマッチするかどうかを調べたいことは割りとあるのでよさそう👍」

参考: Rails API parsed_body -- ActionDispatch::TestResponse
参考: § 11.8 ハッシュキーのシンボルと文字列を同様に扱う(indifferent access) -- Active Support コア拡張機能 - Railsガイド


なお、以下はプルリクしたseanpdoyleさんのメモ的なコメントです。

# actionpack/lib/action_dispatch/testing/request_encoder.rb#L55
    register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) }
-   register_encoder :json, response_parser: -> body { JSON.parse(body) }
+   register_encoder :json, response_parser: -> body { JSON.parse(body, object_class: ActiveSupport::HashWithIndifferentAccess) }

これは潜在的に後方互換性のないbreaking changeになる可能性がある。

この変更はテストハーネスに対して行われたものであり、ホストアプリケーションの実装に影響しないため、どんな影響があるかが不明。

リスクがあるとすれば、response.parsed_bodyの呼び出し側で、本来の意図を超えて柔軟に使われてしまう可能性があることだが、文字列キーとシンボルキーは相互変換可能なので、「現在」パスしているテストは引き続きパスするだろう。

シンボルを使っている既存のテストスイートが今後変更されるときに、以前ならパスしなかった場合でもパスするようになると問題。

この変更の別案:

  • JSON.parseでビルドされたHashまたはArrayを新しいクラスにラップし、#deconstruct_keysを呼び出すときにのみHashwith_indifferent_accessすることで、変更の影響をパターンマッチングのみに限定する。
  • 7.1に、object_class: ActiveSupport::HashWithIndifferentAccessを使う新しいフレームワークのデフォルトを追加し、利用できない場合はobject_class: Hashを使うようにする。
  • この実装変更を取り消して、「Rubyのパターンマッチングを使うときは.with_indifferent_access.deep_symbolize_keysをチェインすること」と呼び出し側に促す。

#49003コメント(by seanpdoyle)より

🔗 システムテストでPlaywrightをサポート

動機/背景

Playwrightは、新しいブラウザ自動化ツールとして人気が高まりつつある。コミュニティによってメンテナンスされているPlaywrightのRubyクライアントcapybaraドライバもある。
自分がしばらく使ってみたところ、非常に優れたパフォーマンスを発揮しているので、Railsが対応できるとよいだろう。

詳細

このプルリクは、PlaywrightドライバのサポートをRailsに追加する。

同PRより


つっつきボイス:「以前も取り上げたPlaywrightはWebのE2Eテストのフレームワークでしたね(ウォッチ20210803)」「使ったことなかったけど、Chromeのドライバとしては結構いいらしいですね」「EdgeやFirefoxやSafariでも使える、なるほど」「Playwrightを出しているのがMicrosoftだということを今回初めて知りました」「これまでもPlaywrightを使うことはもちろんできたけど、この改修でPlaywrightが公式にRailsでサポートされることになったのはいいですね👍」

参考: マイクロソフト、Webアプリテストの自動化サービス「Microsoft Playwright Testing」プレビューを開始 - Publickey

microsoft/playwright - GitHub

「プルリクで言及されているplaywright-ruby-clientを書いたのが、以下のスライドを作ったYusukeIwakiさんです↓」

🔗 config/application.rbにconfig.autoload_libが追加されるようになった

このパッチによって、新たに生成されるconfig/application.rbに以下が含まれるようになる。

config.autoload_lib(ignore: %w(assets tasks))

実用上は、新しい7.1アプリケーションではlibに置かれているものをデフォルトでオートロードするようになることを意味する。

無視するサブディレクトリのリストを動的に算出する方法や、何らかの形で抽象化する方法も検討したが、このパッチのコードのシンプルさと明瞭さの方が、私の好みにおいて勝っている。

同PRより


つっつきボイス:「Zeitwerkをメンテしているfxnさんのプルリクです」「Rails 7.1からはassetsとtasksのみを明示的にオートロードから除外する、つまりlibがオートロードされるようになる」

「libディレクトリに何を置くのかという問題はありますけどね」「それそれ、rakeタスクみたいにどこに置いたらいいのか迷うものってありますけど、それをlibに置いていいのかどうか考えちゃいますよね」「バッチファイルみたいなものはそれ用のサブディレクトリを作る方がよさそう」「ユーティリティクラスのような他にちょうどいい置き場所のないものが仕方なくlibに置かれがちだけど、本当はgemにするのが理想ではあるんですよね」「そう思います」

🔗 to_paramのデリミタをparam_delimiterで変更可能になった

このコミットにより、to_keyが複数の値を返すときのデリミタ(区切り文字)をカスタマイズ可能になる。これにより、Active Recordでさまざまな種類の複合主キーをサポート可能になる。

このプルリクには以下の2つのコミットが含まれている。

  1. Active Modelへの追加: param_delimiter属性を追加して、Active Modelでデリミタを設定可能にする。
  2. Active Record: to_paramを微調整して、複合主キーをサポートし、新たに追加されたparam_delimiterとActive Recordのデフォルトのデリミタ(_)を定義する。

to_paramに複合主キーのサポートを追加するために、デリミタを抽象化せずに特定の値をハードコーディングすることは可能だが、複合主キーで可能な値をすべて考慮しつくすのは無理なので、競合を避けてデリミタを選ぶのは非常に難しい。また、アプリケーションに対してto_param全体をオーバーライドするよう求めるのも不便そう。

自分はこの2つのコミットをsquashせずに分けておく方が断然いいと思うので、これらの変更を2つのプルリクに分けてsquashしないようにしてよいならなお嬉しい。

同PRより


つっつきボイス:「複合主キーなどでデリミタをデフォルトの"_"から変更したいときに、param_delimiterオプションでカンマやセミコロンなどに変えられるようになったんですね: 複合主キーの採番ルール(任意コードを明示的に付与していて、その中に _ が含まれる可能性があるなど)によってはデリミタを変更したい場合がありそう👍」「ちなみにUUIDではハイフン"-"が区切り文字ですね」

# activemodel/test/cases/conversion_test.rb#57
  test "#to_param_delimiter allows redefining the delimiter used in #to_param" do
    old_delimiter = Contact.param_delimiter
    Contact.param_delimiter = "_"
    assert_equal("abc_xyz", Contact.new(id: ["abc", "xyz"]).to_param)
  ensure
    Contact.param_delimiter = old_delimiter
  end

  test "#to_param_delimiter is defined per class" do
    old_contact_delimiter = Contact.param_delimiter
    custom_contract = Class.new(Contact)

    Contact.param_delimiter = "_"
    custom_contract.param_delimiter = ";"

    assert_equal("abc_xyz", Contact.new(id: ["abc", "xyz"]).to_param)
    assert_equal("abc;xyz", custom_contract.new(id: ["abc", "xyz"]).to_param)
  ensure
    Contact.param_delimiter = old_contact_delimiter
  end
end

参考: UUID - Wikipedia

🔗 主キーが:idでない場合に主キーを返すread_attribute(:id)を非推奨化

主キーが:idでない場合にread_attribute(:id)が主キーを返すのを非推奨化する。

Rails 7.2以後は、read_attribute(:id)はモデルの主キーに関係なくidカラムの値を返すようになる。主キーの値を取得するには、代わりに#idを使うこと。複合主キーモデルでは、read_attribute(:id)は今後もidカラムの値を返すようになる。

Adrianna Chang

同Changelogより


動機/背景

このプルリクは、モデルの主キーがidカラムでない場合に、read_attribute(:id)が主キーを返すことを非推奨化する。Rails 7.2以後は、read_attribute(:id)はidカラムの値を返すようになる。

このコミットによって、複合主キーモデルのread_attribute(:id)も複合主キーではなくidカラムの値を返すように変更される。

read_attributeは、"id"を「主キー」という意味に翻訳すべきではない。このメソッドは、アプリケーションがモデルのid属性に容易にアクセス可能にするため、モデルの主キーであるかどうかに関係なく生の属性値を返すべき。

詳細

このプルリクは、read_attribute:idを読み取っているが、idが主キーでない場合に非推奨警告を表示する。さらに、複合主キーモデルではidカラムの値を返すように振る舞いを変更する。ただし、これによりcpk_record["id"]も複合主キーではなくid カラムを返すようになる点に注意すること。

私は代わりに#idを使うことをお勧めしたい。主キーを返す#idを廃止しようという他の議論(例: #48930コメント)もあったが、そのためには複合主キーの値を取得するゲッターメソッドを新たに導入しなければならないだろう。現状では、そういう変更は当分先の話となる😄

同PRより


つっつきボイス:「read_attribute(:id)が今まで主キー(primary key)を返していたのを、今後はidカラムの値を返すようになるのか」「これも複合主キーに関連しているんですね」「7.1でリリースされる予定の複合主キーも、この改修でread_attribute(:id)で主キーではなくidを返すようになった: 複合主キーでは主キーを取得すると配列が返されるので、たしかにread_attribute(:id)の方は常にidというカラム名の値を返すのがいいでしょうね」「主キーがidとは限らない問題、ややこしい」

参考: Rails API read_attribute -- ActiveRecord::AttributeMethods::Read

🔗 モデルのid属性をid_valueで取得できるようになった

レコードのidカラムの生の値にアクセスするためのActiveRecord::Base#id_valueエイリアスを追加する。

このエイリアスは、:idカラムを宣言するモデルでのみ提供される。

Adrianna Chang

同Changelogより


動機/背景

#48533を拡張する。

これにより、idカラムが存在するが主キーではないレコードで、生のidカラム値にアクセス可能になる(これは複合主キーを持つモデルで一般的)。

詳細

alias_attributeを用いて、uidid属性のエイリアスとして提供する。これは上記のプルリクで説明されている主要なユースケースの1つなので、Railsでデフォルトで提供することにした。

追加情報

#uidのAPIドキュメントを提供するのに、alias_attribute呼び出しの上にコメントを挿入するよりもよい方法はあるだろうか?

同PRより


つっつきボイス:「これも同じAdrianna Changさんのプルリク」「idカラムの値をid_valueという分かりやすいエイリアスメソッドで取れるようにしたんですね」「最初はエイリアスをuidにしようとしてたみたいだけど、レビューの結果id_valueにしたのか」

🔗 メーラーのプレビュー表示をアルファベット順にした

動機/背景

メーラーのプレビューページに表示されるメーラーは、descendants配列内で見つかった順に表示されるので、アルファベット順になるかどうかが不確定になる。アプリケーション内で定義されているメーラーが増えるにつれて、この直感的でない並び順のために特定のメーラーをプレビューページ上で見つけにくくなる。

このプルリクは、メーラープレビューページでメーラーのリストを常にアルファベット順(単語の冒頭を大文字にした名前)で返すようにし、リストで特定のメーラーを見つけやすくする。

なお、あるメーラーに複数のメソッドがある場合、それらのメソッドは既にemailsメソッドによってソート済みの形で返されることに注意。

同PRより


つっつきボイス:「これはわかりやすい改修」「ソートがアルファベット順じゃないとプレビューが増えたときに探すのが大変ですよね」

参考: Action Mailer の基礎 - Railsガイド


つっつき後に、Active Supportのdescendantsが返す結果の並び順は継承順に左右されると教わりました。

参考: §4.2.2 descendants -- Active Support コア拡張機能 - Railsガイド

🔗 production環境のRailsコンソールを常にsandboxモードにするコンフィグが追加

Railsコンソールをデフォルトでsandboxモードで起動するオプションを追加

デフォルトでRailsコンソールをsandboxモードで起動するために、sandbox_by_defaultオプションが追加された。このオプションを有効にすると、sandboxモードをオフにしてRailsコンソールを起動するには--no-sandboxを指定しなければならなくなる。

このオプションは、Railsのdevelopment環境とtest環境では無視されることに注意。

Shouichi Kamiya

同Changelogより


productionデータベースに誤って書き込んでしまうのを防ぐために、自分は常にRailsコンソールをsandboxモードで起動している。productionデータベースに書き込みたい場合を除いて、sandboxモードをオフにしてRailsコンソールを起動することはない。

Railsコンソールをデフォルトでsandboxモードで起動するために、sandbox_by_defaultオプションを追加した。このオプションを有効にすると、sandboxモードをオフにしてRailsコンソールを起動するには--no-sandboxを指定しなければならない。

このオプションは、Railsのdevelopment環境とtest環境では無視される。

同PRより


つっつきボイス:「config.sandbox_by_defaultオプションをtrueにすると、--no-sandboxを付けない限りproductionのRailsコンソールが常にsandboxモードになるのか」「デフォルトはfalseなんですね」「うっかりsandboxモードにし忘れて本番データを壊したりしないようにできるので、Railsコンソールをよく使うプロジェクトで便利そう👍」

🔗 X_FORWARDED_HOSTの値がヘッダーに表示される可能性があるバグを修正

HTTP_HOSTがブロックされている場合に、HostAuthorizationX_FORWARDED_HOSTの値をヘッダーで表示する可能性がある問題を修正。

Hartley McGuire, Daniel Schlosser

同Changelogより


動機/背景

従来は、認可されていないHTTP_HOSTの値がリクエストのヘッダーにある場合でも、認可済みのHTTP_X_FORWARDED_HOSTの値がヘッダーに表示されていた。しかし、この値が既にconfig.hostsに追加されている場合にユーザーが混乱する可能性がある。

詳細

このコミットは、ブロックされたホストの表示方法を微調整する形で問題を解決する。Request#hostを常に表示する(X_FORWARDED_HOSTが存在すると、ホストがブロックされているかどうかにかかわらず常にX_FORWARDED_HOSTを返してしまう)のではなく、ブロックされるホストを個別に表示するようになる。

追加情報

#40230を修正。
#46158の後継)

cc @Eusebius1920: 前のプルリクで共に作業したので共著に加えた。

同PRより


つっつきボイス:「config.hostsの設定がHostAuthorizationHTTP_X_FORWARDED_HOSTによるX-Forwarded-Hostヘッダー表示に正しく反映されていなかったのを修正した: ちなみにこのHostAuthorizationはRackミドルウェアですね」「あ、たしかに」「X-Forwarded-Hostヘッダーはログに出ることが多いんですが、これとconfig.hostsの設定および遮断の振る舞いが整合していないと混乱しそう」「X-Forwarded-Hostはただでさえプロキシが多段になると一気にややこしくなりがちですよね」

参考: §3.5.1 ActionDispatch::HostAuthorization -- Rails アプリケーションを設定する - Railsガイド

参考: X-Forwarded-Host - HTTP | MDN


前編は以上です。

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

週刊Railsウォッチ: Rubyガベージコレクション高速化、RubyコードゴルフQ&Aほか(20230831後編)

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

Rails公式ニュース


CONTACT

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