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

週刊Railsウォッチ: Rails+Ruby 3.3でYJITがデフォルトでオンにほか(20231122前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

お知らせ: 週刊Railsウォッチ後編は都合により来週公開します。

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

🔗 Ruby 3.3以上でRailsアプリを実行するとYJITがデフォルトで有効になる

Ruby 3.3以上で実行される新規アプリケーションでYJITがデフォルトで有効になる。

追加されるconfig/initializers/enable_yjit.rbイニシャライザは、Ruby 3.3以上で実行される場合にYJITを有効にする。

Jean Boussier
同CHANGELOGより


つっつきボイス:「この間7.1.2がリリースされたせいか今回のRails改修は比較的小粒な感じでしたが、その中でも目玉となる機能追加です」「お、config/initializers/enable_yjit.rbが新たに作られたんですね↓: RubyVM::YJIT.enableな環境であればYJITが有効になる」「これで環境変数にRUBY_YJIT_ENABLE=1を設定しなくてもデフォルトでYJITを有効にできますね」「DHHもBasecampやHEY.comでYJITを有効にしたことで高速化したとツイートしてる」

# railties/lib/rails/generators/rails/app/templates/config/initializers/enable_yjit.rb.tt
# Automatically enable YJIT as of Ruby 3.3, as it bring very
# sizeable performance improvements.

# If you are deploying to a memory constrained environment
# you may want to delete this file, but otherwise it's free
# performance.
if defined? RubyVM::YJIT.enable
  Rails.application.config.after_initialize do
    RubyVM::YJIT.enable
  end
end

🔗 Active Record関連

🔗 ActiveRecord::Core#inspectの出力をカスタマイズ可能になった

デフォルトでは、あるレコードでinspectを呼び出すと、idのみを含むフォーマット済み文字列が生成される。

Post.first.inspect #=> "#<Post id: 1>"

inspectの出力に含めたい属性があれば、ActiveRecord::Core#attributes_for_inspectで設定できる。

Post.attributes_for_inspect = [:id, :title]
Post.first.inspect #=> "#<Post id: 1, title: "Hello, World!">"

attributes_for_inspect:allに設定すると、inspectでレコードの全属性が出力される。

Post.attributes_for_inspect = :all
Post.first.inspect #=> "#<Post id: 1, title: "Hello, World!", published_at: "2023-10-23 14:28:11 +0000">"

attributes_for_inspectは、development環境とtest環境ではデフォルトで:allに設定される。
full_inspectを呼び出すことで全属性をinspectすることも可能。
attribute_for_inspectで設定した属性はpretty_printでも使われる。

Andrew Novoselac
同CHANGELOGより


つっつきボイス:「今のRailsもconfig/initializers/filter_parameter_logging.rbでログに出したくないパターンをフィルタで除外できるけど、inspectそのものをカスタマイズできるようにした、なるほど」「pretty_printにも効くのか」「inspectをカスタマイズ可能にした代わりに、従来のinspectと同様に全属性を表示できるfull_inspectも新たに追加されたんですね」「"ここだけは全部出したい"みたいなときに使うのかも」

inspectをカスタマイズするとしたら、inspectで出したくないものがあるときとかでしょうか?」「production環境のRailsコンソールでinspectしたものにめちゃくちゃ長い文字列が入っていたりするとコンソールが壊れたりすることがあるので、そういうのを除外したくなるのはわかる」「そうそう、inspectで壊れたときにデバッガーのページャーがオンになっていたりすると出力がいつまで経っても終わらないとかありますよね」「inspectがカスタマイズ可能になったところに価値がある感じ👍」

🔗 変更されていないFloat::INFINITY値をchangedとして保存しないよう修正

動機/背景

floatの無限値を含むレコードをsaveする場合はchangedとしてマークすべきではない。 レコードを変更せずに保存すると、そのたびに新しいバージョンが作成されるため、papertrailなどの監査システムで使うと面倒になる可能性がある。

詳細

再現方法:

record = MyRecord.create!(float_field: Float::INFINITY)
record.reload
record.float_field = Float::INFINITY
record.changed? # returns true when it should be false

変更の詳細:

  • 問題は、数値を文字列に変換するときにInifinityが数値でないため正規表現/\A\s*[+-]?\d/にマッチしなかったこと。Inifinityという文字列を正規表現に追加するのはfalse positiveになるのでよくないと思う。もっといいアイデアがあれば知らせて欲しい。

同CHANGELOGより


つっつきボイス:「このプルリク見たことあるような気がする」「Inifinityという文字列が正規表現の数値判定にかからなかったので、is_a?(::Numeric)で数値かどうかを先にチェックすることで修正したのね、なるほど」

# activemodel/lib/active_model/type/helpers/numeric.rb#L44
          def number_to_non_number?(old_value, new_value_before_type_cast)
-           old_value != nil && non_numeric_string?(new_value_before_type_cast.to_s)
+           old_value != nil && !new_value_before_type_cast.is_a?(::Numeric) &&
              non_numeric_string?(new_value_before_type_cast.to_s)
          end
...
          def non_numeric_string?(value)
            # 'wibble'.to_i will give zero, we want to make sure
            # that we aren't marking int zero to string zero as
            # changed.
            !NUMERIC_REGEX.match?(value)
          end

          NUMERIC_REGEX = /\A\s*[+-]?\d/
          private_constant :NUMERIC_REGEX

「そういえばRubyのInfinityにはプラスのものとマイナスのものがあったはず」「たしか数学的にはInfinityとInfinityはイコールにならなかった気がする」「そもそもInfinityは特定の数値ではないんですよね」

参考: 無限 - Wikipedia
参考: Float::INFINITY (Ruby 3.2 リファレンスマニュアル)

🔗 Arelのproc_for_bindsの振る舞いを修正

where(field: values)クエリで、fieldがシリアライズド属性の場合(fieldActiveRecord::Base.serializeを使っている場合や、fieldがJSONカラムの場合など)の振る舞いを修正した。

João Alves
同CHANGELOGより

動機/背景

このプルリクは、issue #48535#48072を修正する。

プルリク#41068の導入によって意図しない振る舞いが顕在化し、proc_for_bindsが既にキャスト済みの属性にキャストを適用する原因となった。 その結果、上で参照しているissueで指摘されているようにクエリの誤動作が発生した。

このコミットでは、型をActiveModel::Type.default_valueに置き換えて、2番目のシリアライズで事実上何も行わないようにすることで問題を修正する。この修正により、#41068以降壊れるようになった決定論的クエリを修正するなどの変更が不要になった(詳しくはこのコミット37361bfを参照(注: リンク切れ))。これらの変更により、本来HomogeneousIn内のproc_for_bindsを対象とするはずのテストが、誤ってInWithAdditionalValues内のproc_for_bindsを評価していたため、HomogeneousIn内のproc_for_bindsを調整するたびに誤検知が発生していた。
同PRより


つっつきボイス:「お、Arelの修正だ」「カラムがシリアライズされている場合にエラーになっていたのを修正したらしい」「踏まないとなかなか気づけないバグ」

# activerecord/lib/arel/nodes/homogeneous_in.rb#L50
      def proc_for_binds
-       -> value { ActiveModel::Attribute.with_cast_value(attribute.name, value, attribute.type_caster) }
+       -> value { ActiveModel::Attribute.with_cast_value(attribute.name, value, ActiveModel::Type.default_value) }
      end

「こういうJSONクエリを渡すと壊れたのね↓」

# activerecord/test/cases/serialized_attribute_test.rb#L244
  def test_where_by_serialized_attribute_with_hash_in_array
    settings = { "color" => "green" }
    Topic.serialize(:content, type: Hash)
    topic = Topic.create!(content: settings)
-   assert_equal topic, Topic.where(content: [settings]).take
+   assert_equal topic, Topic.where(content: [settings, { "herring" => "red" }]).take
end

HomogeneousInって何だろうと思ったらArelの内部クラスなんですね」「InWithAdditionalValuesもArelのモジュールだけど、このプルリクで削除されてますね」

参考: rails/activerecord/lib/arel/nodes/homogeneous_in.rb at main · rails/rails

🔗 Active Model関連

🔗 Active Recordのtype_for_attributeをActive Modelに移動

Active Recordのtype_for_attributeメソッドをActive Modelに移動した。ActiveModel::Attributesincludeしたクラスでこのメソッドが使えるようになる。振る舞いはActive Recordのときと同じ。

class MyModel
  include ActiveModel::Attributes

  attribute :my_attribute, :integer
end

MyModel.type_for_attribute(:my_attribute) # => #<ActiveModel::Type::Integer ...>

Jonathan Hefner
同CHANGELOGより


つっつきボイス:「データベースに関係ない機能はActive RecordでもActive Modelでも同じように使えるようにしようという考えでtype_for_attributeを移動したのかも」

🔗 BeforeTypeCastモジュールをActive Modelに移動

ActiveModel::Attributesincludeしたクラスでは*_before_type_cast*_for_databaseなどの自動定義メソッドが使えるようになる。振る舞いはActive Recordのときと同じ。

class MyModel
  include ActiveModel::Attributes

  attribute :my_attribute, :integer
end

m = MyModel.new
m.my_attribute = "123"
m.my_attribute                                   # => 123
m.my_attribute_before_type_cast                  # => "123"
m.read_attribute_before_type_cast(:my_attribute) # => "123"

Jonathan Hefner
同CHANGELOGより


つっつきボイス:「これもActive Modelへの引っ越しです」「*_before_type_cast*_for_databaseをActive Modelだけでも使えるようにするのはわかる: 特に型キャスト周りの機能はActive Modelで欲しいときもあるので」「Active Recordだとできるのに、Active Modelだとできそうでできなかったりするとちょっと悲しいですよね」

「Active Modelの属性周りがだんだんリッチになってきた感じですね」「どちらかというとActive Modelのインターフェイスや振る舞いをActive Recordに近づけている感じかな: RailsでPORO(Pure Old Ruby Object)的なものが欲しいときは基本的にActive Modelを使えばいいと思いますが、Rails以外のRubyアプリで似たようなことをしたいならdry-rbのdry-structとかを使う選択肢もありますね(ウォッチ20200226)」

参考: dry-rb - Home

「Active Modelはsaveで永続化しないときに使うのかと思っていました」「Active Modelでもデータを永続化したければActive Recordに合わせてsave系メソッドを自分で実装すればActive Record透過な使い方ができますし、Active Modelの#persisted?なども元々そういう使い方のために用意されているメソッドですね(Active Modelの#persisted?はデフォルトでは常にfalseを返す)」「あ、そうなんですね」「Active Modelは本来データベースと密結合する機能はなくて良いはずですが、今回のようにActive Recordとインターフェースを合わせるために*_for_database系のtype casting機能を統合する過程で、Active Modelでもデータベースを前提とした機能になりつつあるというのは興味深いですね」

「Active Modelのインターフェイスは以前からActive Recordに近いものになっていますけど、こういう改修を見ていると両者の機能やインターフェイスをもっと近づけて相互運用可能にする方向に進んでいるのかなという気がしますね」「なるほど、そうなると今後もこの種の仕様変更が増えるかもしれませんね」「Active ModelとActive Recordのインターフェイス統一が進むと、たとえば最初はActive Modelで書いたシンプルなモデルを後からActive Record化するみたいな作業が多少やりやすくなるんじゃないかな(それほど簡単ではないと思いますが)」

参考: Active Model の基礎 - Railsガイド

🔗 Action Mailer関連

🔗 Action Mailerのプレビューでインライン添付ファイルを別表示にした

Action Mailerのプレビューで、インライン添付ファイルを通常の添付ファイルと分けて表示するようになった。
例:

Attachments: logo.png file1.pdf file2.pdf

上は以下のように表示される。

Attachments: file1.pdf file2.pdf (Inline: logo.png)

Christian Schmidt and Jonathan Hefner
同CHANGELOGより


つっつきボイス:「インライン添付ファイルをデフォルトで他の添付ファイルと分けて表示する: 小さいけど便利な修正ですね👍」

参考: §2.3.2 ファイルをインラインで添付する -- Action Mailer の基礎 - Railsガイド

🔗 メールにDateヘッダーが存在する場合はAction Mailerのプレビューで日付を表示

Action Mailerのプレビューで、メッセージのDateヘッダーが存在する場合は日付を表示するようになった。

Sampat Badhe
同CHANGELOGより


つっつきボイス:「これもAction Mailerの改修ですね」「修正前のTime.currentを表示する方法よりはDateヘッダーを使う方がいいでしょうね👍」

# railties/lib/rails/templates/rails/mailers/email.html.erb#L95
    <dt>Date:</dt>
-   <dd id="date"><%= Time.current.rfc2822 %></dd>
+   <dd id="date"><%= @email.header['date'] || Time.current.rfc2822 %>

参考: § 2.6 メールのプレビュー -- Action Mailer の基礎 - Railsガイド

🔗 SMTP-To:を必要な場合にのみ表示するよう修正

Action Mailerのプレビューで、ToCcBccの和集合と一致しない場合にのみSMTP-To:を表示するようになった。

Christian Schmidt

同CHANGELOGより


つっつきボイス:「メールの送信先リストがSMTP-To:と一致しない場合にのみSMTP-To:を表示、なるほど」「個人的にはメールのヘッダーを加工せずに全部表示してくれる方がトラブルシューティングがやりやすいかなと思いますけどね」


同PRより

🔗 Action TextのJavaScriptがSprocketsでうまく動かない問題を修正

  • actiontext.esm.jsとしてブラウザで直接利用される可能性があるESMパッケージをコンパイルするよう修正。

Matias Grunberg

  • Sprocketsでactiontext.jsが正常に動作しない問題を修正。

Matias Grunberg
同CHANGELOGより


つっつきボイス:「先週も似たような修正がありましたね」「TrixのJavaScriptのESMがSprocketsでうまく動かない問題が修正されたやつですね(ウォッチ20231114)」「他にもJavaScript周りで修正があるかも?」

🔗 システムテストをパラレル実行したときの競合状態を修正

パラレル実行されるシステムテストでText file busy - chromedriverエラーが発生する可能性のある競合状態を修正。

Matt Brictson

同CHANGELOGより


つっつきボイス:「chromedriverのパスのプリロードがパラレルテストで失敗することがあったのね」「マルチプロセスでパスをプリロードするのは、ワーカーがforkする前にOSの共有メモリにパスを読み込んでおくことで効率を高めるためなんでしょうね」「修正はシンプルだけどバグの原因究明は大変そう」

# actionpack/lib/action_dispatch/system_testing/browser.rb#L31
      def preload
        case type
        when :chrome
-         ::Selenium::WebDriver::Chrome::Service.driver_path.try(:call)
+         resolve_driver_path(::Selenium::WebDriver::Chrome)
        when :firefox
-         ::Selenium::WebDriver::Firefox::Service.driver_path.try(:call)
+         resolve_driver_path(::Selenium::WebDriver::Firefox)
        end
      end
...
+       def resolve_driver_path(namespace)
+         namespace::Service.driver_path = ::Selenium::WebDriver::DriverFinder.path(
+           options || namespace::Options.new,
+           namespace::Service
+         )
+       end

動機/背景

webdriversが存在しない場合(これはRails 7.1以降ではデフォルトのシナリオ)、Seleniumのdriver_pathは最初nilになる。つまり、ドライバのパスはlazyに検索され、システムテストが実行されるまで先送りされることになる。

これによって、パラレルテストで各ワーカープロセスが同時にドライバを解決しようとして競合状態が発生する。その結果、#49906のエラーが発生する。

詳細

このコミットでは、Browser#preloadの実装を変更することで競合状態を修正する。変更前の実装ではdriver_pathwebdrivers gemによってProcに設定されていたが、webdrivers gemが使われなくなってdriver_pathnilになるとうまくいかなくなる。

修正後のBrowser#preloadでは、selenium-webdriverが提供するDriverFinderを用いてdriver_pathnilの場合にドライバのパスをeagerに解決するようになった。これにより、パラレルテストのワーカーがforkする前にdriver_pathが設定されるようになる。

追加情報

このソリューションは、Selenium内でドライバの実行ファイルを必要に応じて検出するコードを真似ている。

service.executable_path ||= WebDriver::DriverFinder.path(options, service.class)

同PRより


前編は以上です。

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

週刊Railsウォッチ: RBS 3.3.0とSteep 1.6.0リリース、Ruby Conf Taiwan 2023ほか(20231116後編)

Rails公式ニュース


CONTACT

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