こんにちは、hachi8833です。
🔗Rails: 最近の改修(Rails公式ニュースより)
- 公式更新情報: Ruby on Rails — Refactoring Active Record Signed ID verifiers
- 公式更新情報: Ruby on Rails — Improved leap year counting performance and more!
🔗 allocate
でアロケーションされたActive Recordオブジェクトで関連付けを探索可能にした
従来は、アロケーションされたレコードオブジェクトに関連付けキャッシュが設定されていなかったため、関連付けを探索するとクラッシュしていた。mochaなどのテストフレームワークでは、stub可能なインスタンスメソッドに対して
allocate
を使っている。Gannon McGibbon
同Changelogより
従来は、
allocate
でアロケーションされたActive Recordオブジェクトに関連付けキャッシュが設定されていなかったため、関連付けを探索するとクラッシュしていた。mochaなどのテストフレームワークでは、stub可能なインスタンスメソッドに対してallocate
を使っているが、これは関連付けの探索をトリガーする可能性がある(Railsの場合の例)。動機/背景
このプルリクを作成した理由は、
allocate
でアロケーションされたActive Recordオブジェクトが関連付けを探索できなかったため。これは正しくないと思う。詳細
このプルリクは、関連付けのキャッシュをlazyに初期化することで探索を行えるようにする。
追加情報
もしかすると、
allocate
でアロケーションされたオブジェクトではinit_internals
を使う方がよいのかもしれないが、その場合はsend
を使うかメソッドをpublicにする必要がある。他のインスタンス変数もそこに存在すべき理由がなければ、この方が理にかなっていると思われる。
同PRより
つっつきボイス:「ここで言うallocate
とは?」「RubyにあるClass#allocate
は"自身のインスタンスを生成して返す"とあるので↓、クラスのインスタンスは生成するけどnew
の場合と異なりinitialize
を実行しないということらしい」「あ、そういうことなのか!」「初期化が何も行われていないインスタンスで関連付けキャッシュにいきなりアクセスするとクラッシュするので、||= {}
を挟んで修正したということなのね↓: allocate
は使ったことないけど、stub化するような状況では使われることがあるらしい」「これはニッチな修正ですね」「この問題を踏んだら原因を特定するのが大変そう」
# activerecord/lib/active_record/associations.rb#L81
it exists, +nil+ otherwise.
def association_instance_get(name)
- @association_cache[name]
+ (@association_cache ||= {})[name]
end
参考: Class#allocate
(Ruby 3.4 リファレンスマニュアル)
「プルリクで言及されているRailsのinit_internals
はprivateメソッドなので、いつものRails APIでは出てこなくて、APIdockの方にありました↓」「privateメソッドに無理やりsend
でアクセスしたりするのは避けたいというのはわかる」
参考: init_internals
(ActiveRecord::Core
) - APIdock
🔗 署名済みIDのベリファイアをRails.application.message_verifiers
で設定可能になった
この変更以前は、署名済みID検証を設定する主な方法は、以下のように個別のモデルクラスで
signed_id_verifier
を設定する方法だった。Post.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) Comment.signed_id_verifier = ActiveSupport::MessageVerifier.new(...)
開発者が
signed_id_verifier
を設定していなかった場合、ベリファイアはsecret_key_base
から派生したsecretと以下のオプションを用いてインスタンス化されることになる。{ digest: "SHA256", serializer: JSON, url_safe: true }
このため、すべてのベリファイアでコンフィグをローテーションするのが面倒だった。
この変更では、Railsに
config.active_record.use_legacy_signed_id_verifier
という新しいコンフィグを定義する。デフォルト値である
:generate_and_verify
は、従来の振る舞いを維持する。
ただし値を:verify
に設定すると、署名済みID検証はRails.application.message_verifiers
(具体的にはRails.application.message_verifiers["active_record/signed_id"]
)のコンフィグを用いて署名済みIDを生成・検証するが、古い設定を用いた署名済みIDの検証も行う。話を複雑にしないため、新しい振る舞いは
signed_id_verifier_secret
がモデルクラス(またはその先祖クラス)で設定されて「いない」場合にのみ適用される。
また、従来のsigned_id_verifier_secret
は非推奨化される。現在モデルクラスでsigned_id_verifier_secret
を設定している場合は、以下のようにsigned_id_verifier
を代わりに設定できる。# 改修前 Post.signed_id_verifier_secret = "my secret" # 改修後 Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("my secret", digest: "SHA256", serializer: JSON, url_safe: true)
移行を容易にするため、
signed_id_verifier
もclass_attribute
として振る舞う(つまり継承可能)に変更されたが、ただしこれはsigned_id_verifier_secret
が設定されて「いない」場合に限られる。# 改修前 ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false # 改修後 ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => true Post.signed_id_verifier_secret = "my secret" # => 非推奨警告 Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false
ただし、最終的にはモデル固有のベリファイアを、
Rails.application.message_verifiers
で管理される統一コンフィグに移行することが推奨される。
以下のようにActiveSupport::MessageVerifier#rotate
を使うことで移行しやすくなる。# 移行前 # 署名済みのPost IDの生成と検証をPostモデル固有のコンフィグで行っている Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("post secret", ...) # A移行後 # 署名済みのPost IDの生成と検証を統一コンフィグで行っている Post.signed_id_verifier = Post.signed_id_verifier.dup # 署名済みIDを検証する場合にPost固有のコンフィグにフォールバックする Post.signed_id_verifier.rotate("post secret", ...)
Ali Sepehri, Jonathan Hefner
同Changelogより
つっつきボイス:「今までだと署名済みIDをモデル単位で設定していたので、ベリファイア設定を差し替えようとするとモデルの数だけ変更が必要で面倒だったので、コンフィグで一元的にやれるようにした↓」「デフォルトの振る舞いは変わらないのでリファクタリングなんですね」
# 署名済みID用のベリファイアをローテートできる
Rails.application.message_verifiers.rotate do |salt|
next nil if salt != 'active_record/signed_id'
secret_generator = ->(_) { 'old_secret' }
{ secret_generator: secret_generator, digest: "SHA256", serializer: JSON, url_safe: true }
end
# ローリングデプロイ用に`transitional`モードを有効にする
Rails.application.message_verifiers.transitional = true
# 古いベリファイアの利用状況をログ出力するための`on_rotation`コールバック
Rails.application.message_verifiers.on_rotation do
puts "Old verifier used for verifying! Still in use!"
end
「この署名済みIDを、たとえばActive Storageの添付ファイルの一般アクセス可能なURLに使えば、IDが改ざんされた場合に検出できますね↓」「ユーザーがURLをコピーしたり、推測して改ざんしたりしてもアクセスできないようにするヤツですね」
参考: 3.4 添付ファイルの置き換えvs追加 -- Active Storage の概要 - Railsガイド
<!-- Active Storage の概要より -->
<% @message.images.each do |image| %>
<%= form.hidden_field :images, multiple: true, value: image.signed_id %>
<% end %>
<%= form.file_field :images, multiple: true %>
🔗 ネストしたトランザクション内のcreate_or_find_by
をロールバック可能になるよう修正
動機/背景
#54830を修正するため(このissueは間接的に修正される)。
問題
create_or_find_by
メソッドはトランザクションのロールバックが許されていないが、この振る舞いはcreate
とは異なっている。その理由は、これが二重トランザクションのエッジケースであり、外側のトランザクションがロールバックを認識しないため。詳細
class User < ApplicationRecord after_save do raise ActiveRecord::Rollback end end User.create(name: "John") #=> 正常にロールバックする User.find_or_create_by(name: "John") #=> Rails 7.0までは正常にロールバックしていたが、コミット023a3eb3c046091a5d52027393a6d29d0576da01からロールバックしなくなった User.create_or_find_by(name: "John") #=> ロールバックしない
解決方法
内側のトランザクションが成功したかどうかを知る必要があるが、
.create
メソッドは(トランザクションのステータスではなく)常にレコードを返すため、これは不可能。# https://github.com/rails/rails/blob/1eac7f27748ffa43c5ce91c036c928fc22d4c597/activerecord/lib/active_record/persistence.rb#L39 object
new
に続けてsave
を実行することはcreate
と同等だが、save
を行うことでトランザクションのreturnステータスを取得できるようになり、外側のトランザクションが正常にロールバック可能になる。
同PRより
参考: Rails API create_or_find_by
-- ActiveRecord::Relation
参考: Rails API find_or_create_by
-- ActiveRecord::Relation
つっつきボイス:「create
の動作をnew
とsave
に分けて、save
がバリデーションエラーなどで失敗したら明示的にロールバックするように修正したんですね↓」
# activerecord/lib/active_record/relation.rb#L273
def create_or_find_by(attributes, &block)
with_connection do |connection|
- transaction(requires_new: true) { create(attributes, &block) }
- record = nil
+ transaction(requires_new: true) do
+ record = new(attributes, &block)
+ record.save || raise(ActiveRecord::Rollback)
+ end
+ record
rescue ActiveRecord::RecordNotUnique
if connection.transaction_open?
where(attributes).lock.find_by!(attributes)
else
find_by!(attributes)
end
end
end
「今さらですが、Active Recordにはcreate_or_find_by
とfind_or_create_by
が両方あるんですね」「ちょうどこの記事にあるんですが↓、create_or_find_by
はまずcreate
を投機的に実行してからfind_by
で取り出すことで、同じ処理が同時に行われていても確実にidを生成するメソッドですね(トランザクション分離レベルにもよると思いますが)」「お〜、なるほど!」「データベースを勉強すると更新はこのような投機的な方法で説明されていることが多いですね」
参考: create_or_find_by
はユニーク制約が定義されたテーブルで効いてくる #Rails - Qiita
「逆にfind_or_create_by
はfind_by
で行ロックしてからcreate
するメソッドですが、存在しない行はロックできないので、Qiita記事にもあるように、同じ処理が同時に行われているとタイミングによっては同じidが2つ生成されてしまう可能性がある(データベース側でunique制約が設定されていれば回避できます)」
🔗 /rails/info/notes
に表示される情報をアノテーションでフィルタできるようになった
動機/背景
/rails/info/notes
で表示されるnotesをタグでフィルタできるようにする。詳細
FIXMEやTODOやOPTIMIZEなどのアノテーションをドロップダウンボックスで選択することでフィルタする。
同PRより
つっつきボイス:「ローカルのRailsで/rails/info/notes
を開いたときの情報表示を改善したそうです(動画の右上隅)」「CLIでbin/rails notes
で表示されるのと同じ情報ですね: 自分ならIDEで検索すると思いますが、GUIでTODOやFIXMEみたいなnotesをフィルタしたい人にはよいと思います👍」
参考: 2.10 bin/rails notes
-- コマンドラインツール - Railsガイド
🔗 コレクションが無限の場合にEnumerable#sole
がハングする問題を修正
最近、Active Supportの
Enumerator#sole
メソッドを使っているうちに、実装に関して残念な点があることに気付いた。def sole case count when 1 then return first # rubocop:disable Style/RedundantReturn when 0 then raise SoleItemExpectedError, "no item found" when 2.. then raise SoleItemExpectedError, "multiple items found" end end
count
を呼び出すと、特定のオブジェクトにもっと効率的な実装がない限りコレクション全体がenum
されてしまう。アイテムが1つしかない場合は、first
を呼び出したときに再びenum
される。コレクションがメモリ上にある場合は、ほとんど大した問題にはならない。ただし、ものすごく馬鹿げたコードを書くと影響が顕著になる。
puts Benchmark.measure { [].to_enum.sole rescue nil }.utime # => 5.800000000277805e-05 a = Array.new(1_000_000) # 非常にバカバカしい puts Benchmark.measure { a.to_enum.sole rescue nil }.utime # => 0.02890700000000379
しかし真の問題は、APIエンドポイントから動的にリソースを取得するコレクションでこれを使った場合に発生したこと。これにはいくつかの理由がある。
- リクエストが1件ではなく2件発生する。
- 2つ目のenumの結果セットが異なる可能性がある。
背後で
count
で使った場合のもう1つの残念な副作用は、無限シーケンス(例:[1].cycle.sole
)を渡すとsole
が永久にハングしてしまうこと。この点を徹底的に検討したわけではないが、現在の実装と内容的に同等である以下の実装は、コレクションから最大2つの項目しか取得しない。
def sole result = nil found = false self.each_entry do |entry| if found raise SoleItemExpectedError, "multiple items found" end result = entry found = true end return result if found raise SoleItemExpectedError, "no item found" end
#54335より
つっつきボイス:「sole
は、該当するオブジェクトが対象セットの中に1個ある場合にだけそれを返して、それ以外の場合はraiseするメソッドでしたね」「要素100万個の配列をto_enum.sole
するとraise
しないでハングする、なるほど」
参考: Rails API Enumerable#sole
「見た感じ、個数が多いenumerableをcount
した場合に問題になるということか↓」「修正後は個数にかかわらずすぐ結果を返すようになっているみたいですね」「考えてみれば、enumerableをcount
するときは愚直に全部カウントするしかないはずなので、1..
みたいなendレスのenumerableなどをcount
で数えるのがよくないという話か」「sole
をcount
で実装していたことでこんなことが問題になるとは...」「Enumerableをinclude
しているクラス側で個別にオーバーライドしている場合はそちらのより効率的なメソッドが呼ばれますが、個別実装が無い場合はEnumerableの実装が使われて、Enumerableの中身を1個ずつ数え上げる方式になってしまうようですね」
# activesupport/lib/active_support/core_ext/enumerable.rb#L211
def sole
- case count
- when 1 then return first # rubocop:disable Style/RedundantReturn
- when 0 then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "no item found"
- when 2.. then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "multiple items found"
+ result = nil
+ found = false
+
+ each do |element|
+ if found
+ raise SoleItemExpectedError, "multiple items found"
+ end
+
+ result = element
+ found = true
+ end
+
+ if found
+ result
+ else
+ raise SoleItemExpectedError, "no item found"
+ end
end
参考: Enumerable#count
(Ruby 3.4 リファレンスマニュアル)
参考: 後者関数 - Wikipedia
「それにしても[1].cycle.sole
が無限ループになるとは面白い」「Railsコンソールで[1].cycle.sole
を実行すると本当にハングしましたけど、このcycle
は?」「Rubyのcycle
は指定の回数だけenumerateするメソッドですね: これはEnumerable
のインスタンスを返すメソッドなので、cycle
を通すことでRailsのEnumerable#sole
が呼ばれる」「なるほど、(1..).to_enum.sole
と同じ結果になるんですね」
参考: Enumerable#cycle
(Ruby 3.4 リファレンスマニュアル)
🔗 distance_of_time_in_words
にDoS攻撃の可能性があったのを修正
動機/背景
distance_of_time_in_words
関数は、from_time
引数とto_time
引数の差が大きすぎる場合にDoS攻撃を引き起こす可能性がある。問題のコードを定数時間に収まる計算に置き換えた。詳細
distance_of_time_in_words
関数は、Rubyのrangeとcount
関数を使ってうるう年をカウントする。def distance_of_time_in_words(from_time, to_time = 0, options = {}) # ... leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) } # ...
このコードは、
from_year
とto_year
が大きく離れているとDoS攻撃を引き起こす可能性がある(2つの引数の差から生成されたrangeが大量にイテレーションされると実質的に実行をブロックする)。このコードを、うるう年を定数時間内で計算する以下のコードに置き換えた。leap_years_up_to = ->(year) { (year / 4) - (year / 100) + (year / 400) } leap_years = (from_year > to_year) ? 0 : leap_years_up_to.call(to_year) - leap_years_up_to.call(from_year - 1)
このケースは一見DoS攻撃とは無縁に思えるかもしれないが、私は自分のプロジェクトでこの問題を発見した。タイムスタンプを設定できるユーザーは、どこかにある
distance_of_time_in_words
関数にタイムスタンプが渡されると、簡単にこの問題を引き起こせる可能性がある。
同PRより
つっつきボイス:「うるう年の処理パフォーマンスを改善したそうで、さっきのEnumerable#count
の問題に続いてDoS攻撃になりそうな問題ですね」「from_year
とto_year
はいかにもユーザーが入力しそうな値なので、悪意がなかったとしてもうっかり極端な値が入って処理がブロックされないようにするのは大事👍」
参考: Rails API distance_of_time_in_words
-- ActionView::Helpers::DateHelper
🔗 キャッシュキーの最大サイズをmax_key_size
オプションで設定可能になった
キーが設定の上限を超えると(デフォルトでは250バイト)、キーは切り捨てられ、残りのキーのダイジェストが追加される。
なお、従来の
ActiveSupport::Cache::RedisCacheStore
では、切り捨て前のキャッシュキーが最大1KBまで許可されていたが、現在は250バイトに削減されている。
同Changelogより
プロジェクトのキャッシュストア(Redisを利用)の使用状況を分析したところ、キャッシュ全体の30%がキーのみで占められていることに気づいた。計算したところ、64バイトの制限を強制すると、キャッシュ全体のサイズが15%小さくなる。中には数百バイト単位のかなり大きなキーもある。すべての場所でキーを修正するようユーザーに依頼するのは面倒なので、このプルリクのようなモンキーパッチを導入するとよさそう。
ここで採用したアプローチは、外部キーの命名スキームに似ている(キーを途中で切り捨てて、残りをダイジェストで埋める)。
幸い、私たちのコードベースでは、すべてのキーに一意の識別子がプレフィックスとして付与されているので、コードベース内でキーが設定されている場所を見つけるのに使える。そのため、このアプローチは問題なく機能する
利用例:
# config/application.rb config.cache_store = :redis_cache_store, { max_key_size: 64 }
同PRより
つっつきボイス:「Active Supportキャッシュの改修だそうです」「 Redisのキャッシュキーとかでキーの最大サイズを指定したいというのは普通にありえますね: ところでキーを途中からダイジェストで埋める実装になっているのが面白い↓」
# activesupport/lib/active_support/cache.rb#L980
+ def truncate_key(key)
+ if key && @max_key_size && key.bytesize > @max_key_size
+ suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
+ truncate_at = @max_key_size - suffix.bytesize
+ key = key.byteslice(0, truncate_at)
+ key.scrub!("")
+ "#{key}#{suffix}"
+ else
+ key
+ end
+ end
🔗Rails
🔗 Rails World 2025で「Rails at Scale」イベントも併設(Rails公式ニュースより)
つっつきボイス:「9/4にアムステルダムで開催されるRails World 2025はもうチケット売り切れか」「前日の9/3にRails at Scaleというイベントも併設されるそうです」「会場の写真が教会とか大学の講堂っぽいのがヨーロッパらしいですね↓」
同サイトより
🔗 書籍『Rails Scales!』
Brand New in Beta: Rails Scales!
Rails doesn’t scale, say the naysayers. They’re wrong. Ruby on Rails runs some of the biggest sites in the world. This book reveals how they do it, and how you can apply the same techniques to your applications.https://t.co/k1675jaFR2@crplanas… pic.twitter.com/QCfvhLSbgJ— PragmaticProgrammers (@pragprog) July 17, 2024
つっつきボイス:「上のRails at Scaleに続いて、Rails Scales!という書籍のβ版が公開されました: ZenDeskの中の人がZenDeskでのスケーリングの実例を詳しく書いているそうです」「お、ZenDeskは相当規模が大きいはずなのでスケーリングの参考になりそう👍」
「ZenDeskではRails 8.0へのアップグレードを一晩で完了させたという発表も行っていたそうです↓」
参考: From Legacy to Latest: How Zendesk Upgraded a Monolith to Rails 8.0
🔗 Kaigi on Rails 2025の公式サイトがオープン
💎Kaigi on Rails 2025 公式サイト オープン!💎
オンラインとオフラインのハイブリッド開催です!
日程:2025.09.26 (Fri.) - 27 (Sat.)
JP TOWER Hall & Conferencehttps://t.co/PkUF6rAVCE#kaigionrails— Kaigi on Rails (@kaigionrails) April 30, 2025
つっつきボイス:「Kaigi on Rails 2025のサイトがオープンされましたね🎉」「こちらは9/26〜27の開催ですね」「JP TOWER Hallってどこかなと思ったら東京駅前か」「旧東京中央郵便局の跡地にできたあのビルですね」
🔗Ruby
🔗 Rubyのインラインコメントに関する議論
つっつきボイス:「ruby-jp Slackで議論が盛り上がっていたので取り上げてみました」「一部の言語にはコードの途中にコメントが書けるので、やってみたい気持ちもわかる」「言われてみれば、途中コメントできる言語ってぱっと思いつかないかも」「C言語は/* */
で途中コメント書けますよ」「そうでした😅」
# #20405より抜粋
(| This is a comment (| and nested one |) /:|) (:|) #=> :|
(: This is comment :)
#= This is a comment #= and nested one =# =# :| # => :|
(= ^..^ =)
参考: インラインコメントの使い方 | MattyLogs -- C言語
「Rubyの%w
にコメントを書きたい気持ちもわかる↓」
%w(
foo bar #{= comment =}
baz #{= comment =}
)
🔗 Ruby PlaygroundでNamespaceを試せるようになった
GitHub へのログインが必要です
— _ko1 (@_ko1) May 6, 2025
つっつきボイス:「お〜、ブラウザでNamespaceを試せるようになったんですね👍」「tagomorisさんが昨年ぐらいからNamespaceの実装を進めていてRubyKaigiでも経過が発表されていますが、Namespaceの話がまだよくわかってなくて😅」「大まかには、以下のNamespace.new
のようにNamespaceを定義して、そこでライブラリを読み込むことで、同じライブラリの異なるバージョンをNamespaceに閉じ込める形で共存させられるようにする機能ということでよかったと思います」「そういうことなんですね」
CONST = 1
def foo = :main
ns1 = Namespace.new
ns1.require './a'
#--- a.rb
p CONST
つっつき後に、RubyKaigi 2024〜2025でも発表されたNamespaceがmasterにマージされたことを以下の記事の追記で知りました(#13226)🎉。
参考: RubyKaigi 2025 行ってきた&しゃべってきた、その後 - たごもりすメモ
また、tagomorisさんがNamespaceを実装する動機について書いていた記事を見つけました↓。
参考: ご意見募集: Rubyに名前空間サポート的なものが欲しいという話 - たごもりすメモ
🔗DB
🔗 書籍『SQLアンチパターン』の第2版
原著改訂に伴い、12年の時を経て、第2版を監訳し出版する運びとなりました。第2版は内容が大きく改訂され、新規書き下ろしの章と15のミニ・アンチパターンが加わりました。読みやすい章構成は変わりません。第2版も何卒よろしくお願いします! / 『SQLアンチパターン 第2版』 https://t.co/IegMpfIG7x
— Takuto Wada (@t_wada) April 27, 2025
つっつきボイス:「t_wadaさん監訳の『SQLアンチパターン』が2025/7/11発売で予約受付中だそうです🎉「紹介文に"第2版では内容を大幅に改訂し、新規書き下ろしの章と15のミニ・アンチパターンが加わりました"とある: 旧版の頃からデータベース周りもそれなりに移り変わっているので、そろそろ改定が必要になってくるのもわかる👍」
今回は以上です。
バックナンバー(2025年度第1四半期)
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)