この記事は ANDPAD Advent Calendar 2022 の 8日目の記事です。
リアーキテクティングチームの白土 (@kei_s) です。 最近は、ボーアとアインシュタインに量子を読む - 量子物理学の原理をめぐってという本がとても面白く、ちまちま読んでいます*1。
去る2022年10月21,22日に開催された Kaigi on Rails 2022 で、実践 Rails アソシエーションリファクタリングというタイトルで発表しました。今回の記事では、この発表の補足をしようと思います。
Kaigi on Rails に参加して
本題の前に Kaigi on Rails に参加した感想を書かせてください。
私個人として、対外的に技術コミュニティで発表を行うのがとても久しぶりでした。 オンラインのカンファレンスで発表するのも初めてで緊張しましたが、リハーサル含めて運営チームのサポートが丁寧で、スムーズに発表を行うことができました。 発表の合間には SpatialChat でお久しぶりの方々とご挨拶もでき、とても楽しかったです。運営の皆様ありがとうございました!
実践 Rails アソシエーションリファクタリング
テーマは、ANDPAD のシステムで中心となっている Rails アプリの ActiveRecord アソシエーション(関連付け)をリファクタリングしているぞ!というものです。 この Rails アプリでは「写真」や「資料」といったストックデータの関連付けにポリモーフィック関連が多用され、関連付けの全容把握が難しいという問題がありました。 また業務ドメインを跨いで利用されているため影響範囲が大きく、各ドメインごとの開発チーム単体では修正が難しくなっていました。
このような複数のドメインを横断した問題に対応するため「リアーキテクティングチーム」という名前の専任チームを組織し、手始めに上記の問題について対処をしています。 大規模 Rails アプリをドメインごとに解体していく可能性を視野に入れると、ドメインを跨いで使われるデータの扱いは必ず課題になります。全容が把握できない複雑な実装を解消するため、ポリモーフィック関連を中間テーブルによる関連付けに置き換える作業を進めています。
発表では主に、大規模アプリならではの課題と置き換えの具体的な手順についてお話ししました。 発表中に時間の都合で詳細にお話しできなかった部分についてお伝えしていきます。
抜け漏れ検知のための has_many :extend
旧アソシエーション(ポリモーフィック関連)を新アソシエーション(中間テーブル)に置き換えるにあたって、無停止で行うために新旧の同時書き込みを行いながら段階的に移行を進めていきます。
ドメインを跨いで様々な箇所から利用されているという状況のため、移行箇所を網羅しきれているか、移行漏れがないかを調査したいです。 このため、本番環境で旧アソシエーションが利用されていることを検知する仕組みを導入しました。
コード例はこのような感じです。
class Message < ApplicationRecord has_many :photos, as: :imageable, extend: LoggingLeakage end module LoggingLeakage def self.extended(obj) # 別々の obj で3回呼び出されるため、一度だけ実行されるようにする return if obj.class.to_s != 'Photo::ActiveRecord_Associations_CollectionProxy' backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/) leakage_logger.info "Replace leakage detected. #{backtrace}" end end
旧アソシエーション(上記例では Message#photos
)の has_many
に、:extend
というオプションでモジュールを渡します。これによりこのアソシエーションは、指定のモジュールで extend された状態のオブジェクトを返します。
Rails API ドキュメント has_many
の :extend
オプションの説明によると、アソシエーションにメソッドを定義するのに便利で、特に複数のアソシエーションオブジェクトで同じメソッドを利用する場合に便利、とされています。
今回このオプションを利用して、extend するモジュールに self.extended
を定義し、その中でログ出力を行うようにしました。これにより、
- 旧アソシエーション
Message#photos
が未知の箇所で呼び出される - アソシエーションが返すオブジェクトが initialize される
- モジュールの
self.extended
が実行され、ログ出力される
という流れが起き、旧アソシエーションが利用されたことを検知できるようになります。
ログメッセージに caller
によるバックトレースを出すことで、どのコードで呼び出されたのかを調査できます。バックトレースからアプリ以外の行を省く処理も入れています。
非常に細かいですが、実際に動かしてみると self.extended
が3回実行されてしまうことがわかりました。これは extend されるオブジェクトが実際には3つあるためで、アドホックですがそのうち一つのみで処理が走るよう if
文を入れています。
結構無茶な仕組みですが、抜け漏れを調査するための一時的なもので、移行が完了したら削除するコードなのでヨシ!としました。
アソシエーションに dependent: :destroy
がある場合、動作が変わる
ここからは、発表時間が足りず割愛した Tips についてお伝えします。
ポリモーフィック関連を中間テーブル形式(has_many :through
)に置き換えるにあたって非互換な挙動があり、その一つが dependent: :destroy
が指定された場合です。
class Message < ApplicationRecord # 旧アソシエーション(ポリモーフィック関連) has_many :photos, as: :imageable, dependent: :destroy # 中間テーブル has_many :message_photos # 新アソシエーション(中間テーブル形式) has_many :renewed_photos, through: :message_photos, source: :photo, dependent: :destroy end
ポリモーフィック関連で dependent: :destroy
が指定された場合、親モデルが削除されると子モデルも依存して削除されます。しかし中間テーブルの場合は、上記例のように dependent: :destroy
を指定しても中間モデルである message_photos
しか削除されません。
これは has_many :through
の仕様です。Rails API ドキュメント has_many
の :dependent
オプションに
If using with the
:through
option, the association on the join model must be abelongs_to
, and the records which get deleted are the join records, rather than the associated records.
と明記されています。
この非互換に対応するためには、少し面倒ですが中間モデルに after_destroy
の callback を用いて子モデルの削除を実装する必要があります。
旧→新と新→旧の同時書き込みが二重に発火するのを回避する
移行の流れのうち、3ステップ目で新アソシエーションに置き換えます。この際、新→旧の同時書き込みを行いますが、旧→新の同時書き込みは停止せず維持します。これは、置き換えに抜けがあったり、問題が発覚して切り戻しを行った際にデータをロストさせないようにするためです。
新→旧と旧→新の同時書き込み処理がどちらも存在するため、そのままだと新→旧→新と同時書き込み処理が走ってしまい、新アソシエーションのレコードが二重に登録されてしまいます。
これに対処するため、保存する際に skip_double_write
attribute を用いて同時書き込みをスキップするよう制御する必要があります。
class Photo < ApplicationRecord # 旧アソシエーション(ポリモーフィック関連) belongs_to :imageable, polymorphic: true # 中間テーブル has_many :message_photos # 新アソシエーション(中間テーブル形式) has_many :messages, through: :message_photos attr_accessor :skip_double_write # 旧→新の同時書き込み after_create :double_write_message_photo!, if: -> { imageable.is_a?(Message) } def double_write_message_photo! # skip_double_write が true に指定されていたら同時書き込みをしない return if skip_double_write message_photos.create!(message: imageable) end end
class MessageController < ApplicationController def create ... # 新→旧の同時書き込み。imageable: @message により旧アソシエーション(ポリモーフィック関連)でも呼べるようにする # skip_double_write を true にして旧→新の同時書き込みをスキップさせる photo = Photo.new(photo_params.merge(imageable: @message, skip_double_write: true)) # 新アソシエーションで保存する @message.renewed_photos << photo @message.save! ... end end
1リクエスト内で重複するログを uniq して出力する
上述した抜け漏れ検知のログ出力について、例えば each
でループする内側で旧アソシエーションが呼び出されていた場合、ログが何度も出力されてしまう可能性があります。
未知の呼び出し箇所を検知する目的で仕込むログのため、どのくらい出力されるかを予測・コントロールするのは難しいです。
ログ出力を少しでも抑制するため、1リクエスト内で重複するログがあったら一回だけ出力されるような機構 Middlewares::UniqLogger
を導入しました。
利用側は特に気にせず Middlewares::UniqLogger.log(message)
としてログ出力を実行します。
# 上述の抜け漏れ検知の例 module LoggingLeakage def self.extended(obj) ... backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/) # ログ出力する Middlewares::UniqLogger.log "Replace leakage detected. #{backtrace}" end end
名前の通りこの Logger は Rails のミドルウェアとして実装します。
# config/initializers/middlewares_uniq_logger.rb # Middlewares::UniqLogger をミドルウェアとして登録 require 'middlewares/uniq_logger' Rails.configuration.middleware.use Middlewares::UniqLogger
Middlewares::UniqLogger
は、リクエストごとに Set.new
を持ちます。Middlewares::UniqLogger.log(message)
が呼び出されたら message を Set に溜めておきます。同一メッセージのログ出力はここで uniq されます。call
の ensure で Set に溜まったログを実際にログに出力するようにします。
# lib/middlewares/uniq_logger.rb module Middlewares class UniqLogger class << self def log(message) if defined?(Rails::Server) logs.add(message) else # サーバ起動していない時(例: rails console)に呼ばれた場合はそのままログ出力する ::OriginalLogger.log(message) end end def logs ::Thread.current[:middlewares_uniq_logger] ||= ::Set.new end end def initialize(app) @app = app end def call(env) @app.call(env) ensure unless logs.empty? logs.each do |message| # 実際にログ出力する ::OriginalLogger.log(message) end logs.clear end end def logs self.class.logs end end end
uniq するためにはメッセージが全く同一である必要があり、メッセージに現在時刻などがある場合は機能しません。今回の抜け漏れ検知の用途ではバックトレース情報を出力したく、each
内で何度も呼び出されたとしてもバックトレースは変わらないため、必要十分でした。
おわりに
今回紹介した Tips はそれぞれ些細なものですが、このような細かいことにも気を遣いながら実装を進めています。
現在、アンドパッドでは一緒に働く仲間を大募集しています。
ご興味を持たれた方はカジュアル面談や情報交換のご連絡をお待ちしております。
明日は、フェローでありRubyコミッタの柴田さんが Ruby のフルタイムコミッタとしての活動についての記事を公開してくれる予定です!お楽しみに!
*1:脚注なら本題じゃないことをいくら書いても許されるメソッド(本当に?)。量子物理学の誕生をめぐり、原典となる論文や関係者同士の手紙を一つ一つ引用・解説しながら、どのように量子論が形成されていったのかを再現しようとする本。本文だけで600ページ近くて分厚いうえ、論旨を数式でしっかり説明していく。私は物理専攻とかでもなく、高校物理レベル+時々Wikipediaの記事を読む、くらいの知識しかないので数式の積分展開を全て追うのは諦めつつ読んでいる。ただ、高校物理では教えてもらわなかったことがめっちゃ面白く、例えばプランク定数を発見したプランクは量子論を組み立てようと思って発見したわけではなく、輻射の問題を説明するための理論中に現れた定数で、ある意味では公式を導くためのテクニックであり、最初は離散的なエネルギーをとること(量子化)に懐疑的だったという話や、アインシュタインが1905年に成した相対性理論、ブラウン運動の理論、光量子仮説のうち前者二つはすぐに評価を得ていたが、プランクらによる学会へのアインシュタインの推薦文に「時には彼も、たとえばその光量子の仮説のように、的を外すこともありますが」とわざわざ書かれるほどには受け入れられ難かった話とか。ボーアとアインシュタインという出自が対照的な二人をダブル主人公に据えた当時の科学者たちの量子論にまつわる群像劇、と書くとキャラ推し的に読むことも可能では。マジで長いのでまだ1/4くらいしか読めていませんがおすすめです。ちなみに著者の山本義隆さんの半生もすごい。