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

週刊Railsウォッチ: RailsのRuby 3.2.0対応、ActiveSupport::Durationの暗黙の変換ほか(20221220前編)

こんにちは、hachi8833です。今年最後の週刊Railsウォッチ前編をお送りいたします。

週刊Railsウォッチについて

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

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

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


つっつきボイス:「ついでにマイルストーンを見てみると、現時点ではあと5個でした↓」「でもまだv7.1.0タグができてないし、それらしい動きもまだ見られないので、Rails 7.1はまだ先でしょうね」「7.0登場後まだ1年ですしね」

🔗 has_rich_textstrict_loading:オプションが追加

has_rich_textのシグネチャを拡張してstrict_loading:値を受け取れるようにする。この値は、背後のhas_one宣言に転送される。このオプションを省略すると、strict_loading:にはstrict_loading_by_defaultクラス属性の値が設定される(デフォルトはfalse)。
同PRより


つっつきボイス:「リッチテキストなのでAction Textの改修ですね」「事情はわからないけどstrict loadingしたいことがあったのかも」

# actiontext/lib/action_text/attribute.rb#L33
-     def has_rich_text(name, encrypted: false)
+     #
+     # * <tt>:strict_loading</tt> - Pass true to force strict loading. When
+     #   omitted, <tt>strict_loading:</tt> will be set to the value of the
+     #   <tt>strict_loading_by_default</tt> class attribute (false by default).
+     def has_rich_text(name, encrypted: false, strict_loading: strict_loading_by_default)

...

        rich_text_class_name = encrypted ? "ActionText::EncryptedRichText" : "ActionText::RichText"
        has_one :"rich_text_#{name}", -> { where(name: name) },
-         class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy
+         class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy,
          strict_loading: strict_loading

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

🔗 clear_query_caches_for_current_threadがすべてのコネクションで単一のロックを使うようにする

フォローアップ: #46519
フォローアップ: #46553
修正: #45994

マルチプルデータベースを使う場合、clear_query_caches_for_current_threadはすべてのコネクションプールを通過し、1つずつロックを取得して各クエリキャッシュをクリアしなければならない。
2つのスレッドが異なる順序でこれを行うと、デッドロックする可能性がある。
この問題は、すべてのコネクションで単一のロックを使うことで完全に回避されるようになり、pumaのスレッドとメインスレッドの競合を正しく防げるようになる。
以前のプルリクのように、このロックをRackミドルウェアに持たせて、visitなどのCapybaraプリミティブが呼び出されたらロックを解放する方がすっきりするのだが、そのためにはCapybaraに手頃なチョークポイントが必要で、アップストリームでそれを探る必要がある。
@eileencodes @matthewd @kuahyeow
同PRより

参考: Rails API clear_query_caches_for_current_thread -- ActiveRecord::ConnectionHandling


つっつきボイス:「先週見た$46553のスレッド周りのロックの修正と似ているかも(ウォッチ20221213)」「たしかにこちらもThredの場合とFiberの場合で処理を分けていますね」「まさしく先週の続き」「ActiveSupport::ConcurrencyThreadLoadInterlockAwareMonitorLoadInterlockAwareMonitorを使い分けている」

# activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L175
+     THREAD_LOCK = ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new
+     private_constant :THREAD_LOCK
+
+     FIBER_LOCK = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new
+     private_constant :FIBER_LOCK
+
      def lock_thread=(lock_thread) # :nodoc:
        @lock =
        case lock_thread
        when Thread
--        ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new
+         THREAD_LOCK
        when Fiber
-         ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new
+         FIBER_LOCK
        else
          ActiveSupport::Concurrency::NullLock
        end
      end

参考: ActiveSupport::Concurrency::LoadInterlockAwareMonitor

🔗 オブジェクトのnewを避ける最適化3件


つっつきボイス:「amatsudaさんによる最適化が似たような内容だったのでまとめてみました」

[:js]という配列リテラルだと配列オブジェクトが作成されるので、それを避ける書き方にしたんですね↓」

# actionview/lib/action_view/lookup_context.rb#L259
    def formats=(values)
      if values
        values = values.dup
        values.concat(default_formats) if values.delete "*/*"
        values.uniq!
        invalid_values = (values - Template::Types.symbols)
        unless invalid_values.empty?
          raise ArgumentError, "Invalid formats: #{invalid_values.map(&:inspect).join(", ")}"
        end

-       if values == [:js]
+       if (values.length == 1) && (values[0] == :js)
          values << :html
          @html_fallback_for_js = true
        end
      end
      super(values)
    end

「こちらもActionDispatch::Request.newを後回しにして、必要がなければオブジェクトを生成しないようにしている↓」「途中でreturnする可能性があるならその後でnewする方がいいですね」

# actionpack/lib/action_dispatch/http/content_security_policy.rb#L34
      def call(env)
-       request = ActionDispatch::Request.new env
        status, headers, _ = response = @app.call(env)

        # Returning CSP headers with a 304 Not Modified is harmful, since nonces in the new
        # CSP headers might not match nonces in the cached HTML.
        return response if status == 304

        return response if policy_present?(headers)

+       request = ActionDispatch::Request.new env
+
        if policy = request.content_security_policy
          nonce = request.content_security_policy_nonce
          nonce_directives = request.content_security_policy_nonce_directives
          context = request.controller_instance || request
          headers[header_name(request)] = policy.build(context, nonce, nonce_directives)
        end
        response
      end

「引数の%メソッドに配列リテラルを渡すと評価順序では先に配列オブジェクトが生成されるので、配列リテラルを使わなくて済むsprintfメソッドに変更している↓」「そういえば%はStringクラスのメソッドでしたね」「こういう地道なリファクタリングを積み重ねるのが後々効いてくる👍」

# railties/lib/rails/rack/logger.rb#L48
        def started_request_message(request) # :doc:
-         'Started %s "%s" for %s at %s' % [
+         sprintf('Started %s "%s" for %s at %s',
            request.raw_request_method,
            request.filtered_path,
            request.remote_ip,
-           Time.now.to_default_s ]
+           Time.now.to_default_s)
        end

Ruby: パーセント記号 `%` の使い方まとめ

🔗 Railsガイド: STIのプリロード方法を4とおり記述


つっつきボイス:「こちらはドキュメントへの追記です」「こういう改修も地道だけど重要ですね👍」

参考: §5 シングルテーブル継承 (STI) -- Active Record の関連付け - Railsガイド

🔗 SQLite3のdbファイルはdb/ではなくstorage/に置くことにする

db/ディレクトリはデータ用ではなくコンフィグ専用であるべき。そうすることで、test環境やdevelopment環境はもちろん、production環境でも単一のデータボリュームをコンテナにマウントしやすくなる。
同PRより


つっつきボイス:「運用中に更新されるデータファイルをdb/ディレクトリに置くのが違和感があるというのはわかる」「db/はコンフィグやseedみたいに通常は更新されないものを置くということですね」「データの置き場所をstorage/に定める方がコンテナでボリュームマウントがしやすくなりますね」

「storage/ディレクトリはこれまで標準にはなかったと思うけど追加されたんですね」「こういう変更を突然入れてくるところがいかにもDHHらしい」「通常のアプリには影響なさそうだけどRailsチュートリアルのような教材には影響ありそう」

🔗 #last#firstのORDER BYでquery_constraintsを使うようにする

#46331で最近導入されたquery_constraintsコンフィグは、既存のActiveRecord::Base#primary_keyをさらに抽象化して「仮想の主キー」として扱えるので、今後Active Recordモデルのレベルではprimary_keyquery_constraints_listに置き換える方向に進むはず。

このプルリクは、#last#firstというfinderメソッド(どちらも内部でordered_relationメソッドを呼び出す)を変更して、ORDER BY句のビルドにquery_constraints_listを使うようにする。既存のすべてのActive Recordモデルは、query_constraints_list[primary_key]として暗黙で設定する(#46439)ので、そうしたモデルのほとんどは[id]と同じになるので、この変更はbreaking changeではない。

今後行われそうな拡張
このリファクタリング中に、implicit_order_columnimplicit_order_columnsに変更してORDER BY句で複数のカラムを定義できるようにすれば比較的簡単にやれることに気づいた。自分は今すぐこれをやる必要は感じておらず、単に簡単にやれそうだということを述べたいだけである。
同PRより


つっつきボイス:「query_constraintsは少し前に追加されたコンフィグですね(ウォッチ20221115)」「複合主キーを使う場合はORDER BYをそれぞれのキーにも効かせないと並び順が不定になってしまう: ここではfirstlastのときにもそれを行うようにしたということですね」「こんな場所にも影響してくるとは」「これがないと、firstlastのたびに値が変わってしまう可能性があるので、必要な修正ですね👍」

# activerecord/lib/active_record/relation/finder_methods.rb#L578
      def ordered_relation
-       if order_values.empty? && (implicit_order_column || primary_key)
-         if implicit_order_column && primary_key && implicit_order_column != primary_key
-           order(table[implicit_order_column].asc, table[primary_key].asc)
-         else
-           order(table[implicit_order_column || primary_key].asc)
-         end
+       if order_values.empty? && (implicit_order_column || !query_constraints_list.empty?)
+         # use query_constraints_list as the order clause if there is no implicit_order_column
+         # otherwise remove the implicit order column from the query constraints list if it's there
+         # and prepend it to the beginning of the list
+         order_columns = implicit_order_column.nil? ? query_constraints_list : ([implicit_order_column] | query_constraints_list)
+         order(*order_columns.map { |column| table[column].asc })
        else
          self
        end
      end

🔗 ActiveRecord::Calculations#idsが返すidが重複する問題を修正

  • ActiveRecord::Calculations#idsが一意のidのみを返すようにする
    eager_loadpreloadincludesを使う場合にベースモデルが一意のidリストのみを返すようにActiveRecord::Calculations#idsを更新した。
# (修正後の動作)
Post.find_by(id: 1).comments.count
# => 5
Post.includes(:comments).where(id: 1).pluck(:id)
# => [1, 1, 1, 1, 1]
Post.includes(:comments).where(id: 1).ids
# => [1]

つっつきボイス:「ActiveRecord::Calculations#idsが重複する値を返すことがあるのは仕様なのかと思ってた」「最近これを踏んだコードをレビューで指摘した覚えがある」「find_by / preload / includesしたときにidカラムが重複するのは前からありましたね」「修正後は、pluck(:id)すると重複するけど、idsなら最初から重複しない値を取れるようになったんですね、なるほど👍」

🔗 番外: ActiveSupport::Durationの暗黙の変換を非推奨化

概要
Durationにmethod_missingが存在することで、ときどき非常に混乱する振る舞いが生じる。

1.year.days # => 31556952 Days

7bd9603a2535d9でDurationを特定の単位に変換するメソッドが追加されたが、そのときに置き換えるはずだった混乱するメソッドが残っていた。

この変更ではmethod_missingを非推奨とし、残す意味のあるメソッドを再実装または明示的に委譲することで修正する。
さらに、Durationには*のような直感に反した振る舞いをするメソッドもいくつかある。そうしたメソッドにおける特定のケース(Duration * Duration))についても非推奨とした。

1.day * 2.days # => 172800 days

修正: #45433
同PRより


つっつきボイス:「これはまだオープンなんですが、1.year.days # => 31556952 Daysという挙動があることにびっくりしたので拾ってみました」「何と」「手元で動かしてみたら本当にこうなりました」「dayyearが値を秒で返しているっぽい」「その値のdaysを取るとこんなでかい値になっちゃうのね」

コメントにこんな書き方がありますね↓」「Durationの剰余を取れるってありなのか🤔」「取りたい気持ちはわかる」

# 同コメントより
5.minutes % 120 # => 1.minute
7.seconds % 3.seconds # => 1.second

7.minutes % 3 # => 0 
# because 420 seconds is divisible by 3

🔗Rails

🔗 RailsのRuby 3.2対応


つっつきボイス:「amatsudaさんやyahondaさんがRailsのRuby 3.2対応作業をやっていたので拾いました」「たまたまamatsudaさんがRailsに変更をプッシュしたら3.2の問題をいくつか踏んでしまったのか」「rakeのバージョンが戻るのはつらそう...」

その後他にもいくつか修正が入りました。

「ところで、これに関連するやりとりに出てきた#46712を見ると↓、RubyVM::class_serialがRuby 3.2からなくなったらしい」「手元でやってみると、たしかになくなっていますね」「こちらも3.2.0-rc1をビルドしてやってみるとRubyVM::statの内容も変わっている」「Rubyで普段使わないところがこんなふうに変わっているんですね」

3.2.0rc1$ irb
>> defined?(RubyVM::class_serial)
=> nil

Rubyオブジェクトの未来をつくる「シェイプ」とは(翻訳)

このコードでは、class_serialが前回のアクセス時に記録された「期待されるclass_serial」と同じであることを確認していることがわかります。class_serialはクラスのバージョン番号で、クラスが変更されるとインクリメントされます。また、オブジェクトのサイズが同じクラスの他のインスタンスのサイズと異なる可能性もあるので、必要な数のインスタンス変数スロットがオブジェクトにあるかどうかもチェックする必要があります。
Rubyオブジェクトの未来をつくる「シェイプ」とは(翻訳)より

🔗 Pay: 複数のpayment processorを扱えるgem(Ruby Weeklyより)

pay-rails/pay - GitHub


つっつきボイス:「以下のpayment processorを扱えるgemだそうです」

  • Stripe (SCA Compatible using API version 2022-11-15)
  • Paddle (SCA Compatible & supports PayPal)
  • Braintree (supports PayPal)
  • Fake Processor (used for generic trials without cards, free subscriptions, testing, etc)

「ところで、このリストに日本の決済代行業者がリストアップされそうにないのが残念」「日本のサービスだと難しい点があるんでしょうか?」「最近はそうでもないかもしれませんが、日本に以前からある決済代行業者の多くはAPI仕様がクローズドになっていて、契約しないと読めない場合や、無料登録でAPI仕様は読めてもAPI仕様を他言できない場合もあったりするんですよ」「だから自前でAPIアクセスを実装しないといけなくなるのか...😢」「昔は日本にもオープン仕様の決済代行サービスがあった覚えがあるんですが、吸収合併か何かでなくなったんじゃなかったかな」「残念ですね」「日本のエンタープライズ方面では、仕様を公開するとセキュアでなくなるのではないかという考え方が今も根強いんですよ」

🔗 Railsの機能で認証と認可を実装する(Ruby Weeklyより)

lazaronixon/authentication-zero - GitHub


つっつきボイス:「Deviseなどを使わずにRailsの機能で認証と認可を実装する記事と、そのgemだそうです」「gemになっている時点で既にRailsだけの機能ではなくなっていると思いますけどね: 記事を見た感じではDeviseで実装するのと複雑さはあまり変わらない感じかな」「なるほど」「たしかにDeviseで書くのは美しいとは言い難いコードになってしまうので、こういうものを作りたくなる気持ちもわかる」

heartcombo/devise - GitHub


前編は以上です。

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

週刊Railsウォッチ: Ruby 3.2.0 RC1がリリース、YARVアドベント記事、ChatGPTほか(20221214後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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