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

週刊Railsウォッチ: 月刊のHotwireニュースレター、pessimize gemほか(20221206前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 Concurrent::Map.newをスレッドセーフにした

PREFIXED_PARTIAL_NAMESが見つからないと、同時に複数のスレッド上で実行された場合にキーが上書きされる場合がある。
参照: Concurrent::Hash default initialization is not fully thread-safe · Issue #970 · ruby-concurrency/concurrent-ruby
動機/背景
これと同じ: Remove not needed thread-safe primitive by mensfeld · Pull Request #46534 · rails/rails
しかしプルリクを分けるようガイドラインで指示されているため、2つ目のプルリクを作成した。
詳細
このプルリクはキャッシュミスの扱い方を変更する。これによりスレッドセーフになり、実行中に上書きされないようになる。
追加情報
参照: Remove not needed thread-safe primitive by mensfeld · Pull Request #46534 · rails/rails
参照: Concurrent::Hash default initialization is not fully thread-safe · Issue #970 · ruby-concurrency/concurrent-ruby
同PRより


つっつきボイス:「スレッドセーフでなかった書き方をスレッドセーフにしたらしい」「#46534と関連しているんですね」「このcompute_if_absentって初めて見たかも↓」

# actionview/lib/action_view/renderer/abstract_renderer.rb#L32
    module ObjectRendering # :nodoc:
      PREFIXED_PARTIAL_NAMES = Concurrent::Map.new do |h, k|
-       h[k] = Concurrent::Map.new
+       h.compute_if_absent(k) { Concurrent::Map.new }
      end

参考: Method: Concurrent::Map#compute_if_absent — Documentation for concurrent-ruby (1.1.10)

compute_if_absentは、キーが存在しない場合に処理を実行して新しい値を保存する、つまりキーがある場合は何もしないんですね: ここではConcurrent::Map.newが不要な場合には実行しないようにしている」「メモリアロケーションなどを細かく追わないとなかなか気づかなさそうですね」

🔗 before_committed!コールバックをレコードの直近のコピーに対して実行するよう修正

以下と同じだが、before_committed!コールバックが対象である点が異なる。
Should attempt committed!/rolledback! to all enrolled records in the transaction by kamipo · Pull Request #36190 · rails/rails

従来は、コールバック実行時に同じレコードの初期のコピー(オブジェクトidは異なるがデータベース上の同じレコードを指す)が使われていたが、touch_laterの情報を持っていなかった。
修正: When a parent model has before/after_commit/rollback hooks, changing child attributes with accepts_nested_attributes_for causes parent to not be touched · Issue #26726 · rails/rails
同PRより


つっつきボイス:「committed!rolledback!でも同じ修正が行われていたらしい↓」「この#36190は2019年の修正だから結構前ですね」

参考: Should attempt committed!/rolledback! to all enrolled records in the transaction by kamipo · Pull Request #36190 · rails/rails

現在のcommitted!rolledback!は、トランザクション内で最初に登録(enroll)されたレコードだけを対象に試行するため、一部の振る舞いで問題が生じる。

1つ目の問題は、トランザクションが確定していても、最初に登録されたレコード以外ではclear_transaction_record_stateが呼び出されないこと。
つまり、そのトランザクション内でde-duplicate(重複解消)されたレコードが最新のステートを参照しなくなる(例: レコードのステートがロールバックされなくなる)。

2つ目の問題は、登録された順序が必ずしも実際に実行された登録順と同じとは限らないこと。最初に登録されたレコードは、何も実行せずに成功する可能性があり(例: before_destroyで別のレコードが既にdestroyに成功している)、その場合はトランザクション内でコールバックがトリガーされなくなってしまう。

この2つの問題を回避するために、committed!rolledback!はそのトランザクション内で登録されたすべてのレコードを対象に試行するべき。
#36190より

🔗 recipientsメソッドでX-Forwarded-Toのアドレスを取れるようになった

このプルリクは、Mail::Message#recipientsを拡張してメール転送先のアドレスを含めるようにする。
Action Mailboxでよくあるパターンのひとつに、ユーザーやチケットの識別子を含むアドレスに送信されたメールを処理するというものがある。このパターンでは、メールが送信されたアドレスがアプリケーションで必要になるので、この識別子を取得してモデル内を探索することになる。

あるアドレスにメールが直接送信されると、#recipientsはそのアドレスをToヘッダーで検索する。

しかし、あるアドレスにメールが転送された場合は、#recipientsでそのアドレスを検索できない可能性がある。検索できるかどうかは、メールサーバーがFromヘッダーとToヘッダーをどう設定するかに依存する。メールサーバーはオリジナルのFromヘッダーとToヘッダーを維持することもあるが、維持しないこともある。その場合、メール転送先のアドレスは#recipientsから見えなくなってしまう。

特にGmailがメールを転送する場合は、FromヘッダーとToヘッダーを維持し、転送先アドレスをX-Forwarded-Toヘッダーに記録する。

従来は、メールサーバーがToヘッダーに転送先アドレスを設定する場合はオリジナルのToを取得するサポートが追加されていた。

このプルリクは、メールサーバーがToヘッダーにオリジナルのToを設定する場合に転送先アドレスを取得するためのサポートを追加する。
同PRより


つっつきボイス:「Action Mailbox(Railsでメール受信を取り扱う機能)の改修ですね」「X-Forwarded-Toって何だっけ」「X-で始まるから拡張系のメールヘッダーですね」「X-Forwarded-For(HTTPヘッダー)かと思ったら違った」「昔このあたりのヘッダーをいじった覚えがあるようなないような」

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

「Gmailはメール転送時のX-Forwarded-ToとTo/Fromの扱いが特殊なようで、そういったメールをAction Mailboxが処理する際、recipientsX-Fowarded-Toが含まれていないと期待した挙動にならないので修正したらしい」「なるほど」「Action Mailboxはメールサーバーを作っているようなものだから、MTAが実装すべきものを頑張って実装している感じですね」

参考: Gmail | 自分のメールアドレス宛に届いたメールを他のメールアドレスへ自動転送する
参考: メール転送エージェント - Wikipedia -- MTA

🔗 FormBuilderの#fields#fields_forでモデルをハッシュライクに渡せるようになった

fields_forはパラメータを賢く扱おうとするが、名前と値が両方提供され、かつその値がオプションハッシュ形式の場合に混乱することがあった。この曖昧さを解消するために、空の場合であってもオプションHashを明示的に渡すようにする。

<%= form_for @person do |person_form| %>
  ...
  <%= fields_for :permission, @person.permission, {} do |permission_fields| %>
    Admin?: <%= check_box_tag permission_fields.field_name(:admin), @person.permission[:admin] %>
  <% end %>
  ...
<% end %>

同APIドキュメントより


動機/背景
ActionView::Helpers::FormBuilder#fieldsにモデルを渡すとき、このモデルがHashまたはHashのサブクラスのインスタンス(extractable_options?がtrueを返す)の場合(HashWithIndifferentAccessなど)、このモデルがオプションハッシュであるかのようにコードで解釈され、指定したモデルとオプションが両方とも無視されてしまう。

問題は、パラメータを処理するActionView::Helpers::FormBuilder#fields_forにある。#1778ではこの問題が部分的に修正されている: record_objectがHashのサブクラスで、extractable_options?をオーバーロードしない場合は、Hash#extractable_options?の制約のおかげですべて問題なく動く。

しかし、record_objectがHashそのものかHashWithIndifferentAccessなどの場合は引き続き問題がある(#46154)。

この修正方法を思いつけなかったので、このプルリクには修正は含まれていない。しかし、呼び出し元コードがfields_optionsを提供しているかどうかを調べる形で、fields_forに渡されるrecord_objectを強制的にチェックする方法を提供する。これによりfieldsの問題が修正される(fieldsメソッドの定義で、少なくともfields_optionsに空ハッシュが渡されるようになるため)。

詳細
このプルリクは、ActionView::Helpers::FormBuilder#fields_forに提供されるパラメータの解釈方法を変更する。
record_objectパラメータが実際にはfields_optionsパラメータであることが決定される前の段階で、fields_optionsが明示的に渡されていないかどうかをチェックするようになった。
同PRより


つっつきボイス:「このfields_optionsオプションにHashやHashの継承クラス(HashWithIndifferentAccessなど)の形でモデルを渡すと思わぬ挙動になったのを修正したということのようですね↓」「バグ修正に近い感じですね」

# actionview/lib/action_view/helpers/form_helper.rb#2267
-     def fields_for(record_name, record_object = nil, fields_options = {}, &block)
-       fields_options, record_object = record_object, nil if record_object.is_a?(Hash) && record_object.extractable_options?
+     def fields_for(record_name, record_object = nil, fields_options = nil, &block)
+       fields_options, record_object = record_object, nil if fields_options.nil? && record_object.is_a?(Hash) && record_object.extractable_options?
+       fields_options ||= {}
        fields_options[:builder] ||= options[:builder]
        fields_options[:namespace] = options[:namespace]
        fields_options[:parent_builder] = self

参考: Rails API fields_for -- ActionView::Helpers::FormBuilder
参考: Rails API fields -- ActionView::Helpers::FormBuilder

fields_forというとaccepts_nested_attributes_forで使う印象がありますね」「そもそもfields_forをあまり使ってないかも」

参考: Rails API accepts_nested_attributes_for -- ActiveRecord::NestedAttributes::ClassMethods

🔗 buildメソッドにハッシュの配列を渡すことで複数のオブジェクトをまとめてbuildできるようになった

概要
newのラッパーを提供することで、関連付けのbuildメソッドと同様の記法を用いて、ハッシュを要素に持つ配列から複数のレコードをcreateする機能と同等の機能を提供する。

  • 関連付けは、ハッシュを要素に持つ配列から複数のオブジェクトをcreateできる
  • 関連付けは、ハッシュを要素に持つ配列から複数のオブジェクトをbuildできる
  • クラスは、ハッシュを要素に持つ配列から複数のオブジェクトをcreateできる
  • クラスは、ハッシュを要素に持つ配列から複数のオブジェクトをbuildできる(←今ここ)

こちらで議論された: Allow ActiveRecord::Inheritance#new to accept an array - rubyonrails-core - Ruby on Rails Discussions
同PRより


つっつきボイス:「テストコードを見ると、Topic.build([{ title: "first" }, { title: "second" }])みたいにbuildにハッシュを要素に持つ配列をクラスにどさっと渡せるようになっていますね↓」「お〜ありがたそう」「どれか1つが失敗するとまとめて失敗するのかな?」「これができるとテストコードで便利そう👍」

# activerecord/test/cases/persistence_test.rb#L429
  def test_build
    topic = Topic.build(title: "New Topic")
    assert_equal "New Topic", topic.title
    assert_not_predicate topic, :persisted?
  end

  def test_build_many
    topics = Topic.build([{ title: "first" }, { title: "second" }])
    assert_equal ["first", "second"], topics.map(&:title)
    topics.each { |topic| assert_not_predicate topic, :persisted? }
  end

  def test_build_through_factory_with_block
    topic = Topic.build("title" => "New Topic") do |t|
      t.author_name = "David"
    end
    assert_equal("New Topic", topic.title)
    assert_equal("David", topic.author_name)
    assert_not_predicate topic, :persisted?
  end

  def test_build_many_through_factory_with_block
    topics = Topic.build([{ "title" => "first" }, { "title" => "second" }]) do |t|
      t.author_name = "David"
    end
    assert_equal 2, topics.size
    topics.each { |topic| assert_not_predicate topic, :persisted? }
    topic1, topic2 = topics
    assert_equal "first", topic1.title
    assert_equal "David", topic1.author_name
    assert_equal "second", topic2.title
    assert_equal "David", topic2.author_name
  end
# activerecord/lib/active_record/persistence.rb#83
      def build(attributes = nil, &block)
        if attributes.is_a?(Array)
          attributes.collect { |attr| build(attr, &block) }
        else
          new(attributes, &block)
        end
      end

🔗Rails

🔗 Hotwire devニュースレター


つっつきボイス:「Hotwireの月刊ニュースレターです」「月イチぐらいだと気軽に読めそう👍」「Railsウォッチ的なノリで記事や情報を紹介している感じ」「親しみを感じちゃいました😋」

September 2022 editionを見ていたらRails Hackersonというイベントが紹介されていました↓」「Winnersが出ているということはもう終わってるのか〜」「48時間以内に作るルールなんですね」「昔はよくこういう週末に手早く作りきるみたいなハッカソンイベントがよく開催されていましたが、最近減った気がしますね: Railsは元々短時間でモノを作ってリリースするのに向いているので面白そう」「たしかに」

参考: Rails Hackathon
参考: Rails Hackathon 2022 Winners
参考: ハッカソン - Wikipedia


「ところで以下のツイートをたまたま見かけたんですが、Honoという軽量JSフレームワークを作っている方だそうです」「Honoって炎のことですか🔥」「RailsやRubyを使っていない人にもHotwireがアピールしているんですね」

参考: Hono - Ultrafast web framework for Cloudflare Workers, Deno, and Bun.

honojs/hono - GitHub

🔗 Rubyのコンカレンシーは難しい: いかにして私がRailsコントリビュータになったか(Ruby Weeklyより)


つっつきボイス:「karafkaを使っているんですね」「karafkaはApacheの分散ストーリーミング基盤であるKafkaをRubyで使えるようにするgemでしたね」

karafka/karafka - GitHub

参考: Apache Kafka
参考: Apache Kafka とは?動作確認や機能、特徴などを解説 | OSSサポートのOpenStandia™【NRI】

「記事は、Karafkaで踏んだコンカレンシーの問題を調査しているうちに、以下のような書き方だとブロックのロックが不完全になることを見つけたらしい↓」「うわ〜」

# 同記事より
semaphores = Concurrent::Hash.new { |h, k| h[k] = Queue.new }

# 以下は上と同等
semaphores = Concurrent::Hash.new do |h, k|
  queue = Queue.new
  h[k] = queue
end

「こういうスレッドのデバッグはつらそう↓」「考えたくない...」「踏みたくない...」「コンカレンシーをスレッド内で解決しようとしてミスるとこのあたりの問題を踏みがちで、しかも問題がメモリ内で起きるからデバッグが大変」「だからジョブキューを使ったソリューションにする人が多いんだろうな」「ジョブキューならログですぐ調べられますしね」


同記事より

「この問題はたとえば以下のようにして修正できるらしい↓」「Concurrent::HashConcurrent::Mapに置き換えて#compute_if_absentを使うか、Concurrent::Hashの初期化ブロックにロックを導入することで解決できると書かれていますね」「ここでもさっきの#compute_if_absentが登場している」「RubyでMutex#synchronizeを書いたことなかった」

# 同記事より
Concurrent::Map.new do |k, v|
  k.compute_if_absent(v) { [] }
end

mutex = Mutex.new

Concurrent::Hash.new do |k, v|
  mutex.synchronize do
    break k[v] if k.key?(v)
    k[v] = []
  end
end

参考: Thread::Mutex#synchronize (Ruby 3.1 リファレンスマニュアル)
参考: ミューテックス - Wikipedia

「記事では、これと同じような問題がRailsやいろんなgemにも潜んでいたことをSourcegraphで発見したんですって」「記事後半でRailsやgemにいろいろプルリクを投げていますね」「すごい」「タイトルにあったRailsへの貢献はそういうことだったのか」

参考: Sourcegraph

(問題が見つかったプロジェクト)

  • rails (activesupport and actionview)
  • i18n
  • dry-schema
  • finite_machine
  • graphql-ruby
  • rom-factory
  • apache whimsy
  • krane
  • puppet
    同記事より

「記事の著者がRailsに投げた修正プルリク↓を見たら、さっきつっついた#46536でした」「初コントリビュートがスレッドバグの修正ってなかなかなさそうですよね」「Railsのように大勢の目が光っていてもこういう問題が残っていたぐらいだから、やっぱりスレッドは難しい」「難しいです」

参考: Make sure that concurrent map usage is thread-safe by mensfeld · Pull Request #46536 · rails/rails
参考: Remove not needed thread-safe primitive by mensfeld · Pull Request #46534 · rails/rails

🔗 pessimize gemでgemを安全にアップグレードする(RubyFlowより)

joonty/pessimize - GitHub


つっつきボイス:「Hanami公式ブログの記事です」「今使われている詳細なGemバージョンを最低バージョンとして指定したpessimistic operator(~>)を現在のGemfileに自動で付けてくれるようですね」「割と前からあるgemっぽい」

# 同記事より
source "<https://rubygems.org>"

gem "puma", "~> 5.6"
gem "roda", "~> 3.61"
gem "awesome_print", "~> 1.9"
gem "pg", "~> 1.4"
gem "redis", "~> 5.0"
gem "money", "~> 3.0"
gem "shrine", "~> 3.4"
gem "cloudinary", "~> 1.23"
gem "shrine-cloudinary", "~> 1.1"
gem "faker", "~> 2.23"
gem "dry-struct", "~> 1.4"
gem "dry-validation", "~> 1.8"
gem "dry-schema", "~> 1.10"

「pessimistic operatorを指定するとbundle updateしたときに最終桁のバージョンだけ上げていくようになる(参考リンク↓)ので、一般的なバージョンの最終桁をpatch versionとしてメンテされているGemであれば、pessimizeを使って安全にbundle updateできるGemfileになるということですね」「なるほど」

参考: Ruby's Pessimistic Operator

「gemをエイヤでアップグレードするより安全にやれそう」「アップグレード時のバージョンが気になる人は使ってみてもよさそう👍」

「ところで、gemのバージョンをあまり細かく指定するのは考えものですね: 他のgemと相互依存している場合にエラーになったこともあります」「そうそう」「pessimizeの場合は基本的に~>になるようなので多少安心だけど、気をつけたい」

Ruby: GemfileとGemfile.lock究極ガイド(翻訳)

🔗 その他Rails

つっつきボイス:「Kaigi on Rails 2022のデザイナーさんによる記事です」「こういう情報も共有されるのは嬉しいですね」「ノベルティグッズのデザインも手がけているのね」「プロのデザイナーが入ると歴然とクォリティに差が出ますね👍」


前編は以上です。

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

週刊Railsウォッチ: Ruby 3.2のParser目玉機能ほか(20221130後編)

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

Rails公式ニュース

Ruby Weekly

RubyFlow

160928_1638_XvIP4h


CONTACT

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