Kaigi on Rails 2022 「実践 Rails アソシエーションリファクタリング」で伝えきれなかったこと

Kaigi on Rails 2022 「実践 Rails アソシエーションリファクタリング」で伝えきれなかったこと|ANDPAD Advent Calendar 2022

この記事は 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 を定義し、その中でログ出力を行うようにしました。これにより、

  1. 旧アソシエーション Message#photos が未知の箇所で呼び出される
  2. アソシエーションが返すオブジェクトが initialize される
  3. モジュールの 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 a belongs_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 はそれぞれ些細なものですが、このような細かいことにも気を遣いながら実装を進めています。

現在、アンドパッドでは一緒に働く仲間を大募集しています。
ご興味を持たれた方はカジュアル面談や情報交換のご連絡をお待ちしております。

engineer.andpad.co.jp

hrmos.co

明日は、フェローでありRubyコミッタの柴田さんが Ruby のフルタイムコミッタとしての活動についての記事を公開してくれる予定です!お楽しみに!

*1:脚注なら本題じゃないことをいくら書いても許されるメソッド(本当に?)。量子物理学の誕生をめぐり、原典となる論文や関係者同士の手紙を一つ一つ引用・解説しながら、どのように量子論が形成されていったのかを再現しようとする本。本文だけで600ページ近くて分厚いうえ、論旨を数式でしっかり説明していく。私は物理専攻とかでもなく、高校物理レベル+時々Wikipediaの記事を読む、くらいの知識しかないので数式の積分展開を全て追うのは諦めつつ読んでいる。ただ、高校物理では教えてもらわなかったことがめっちゃ面白く、例えばプランク定数を発見したプランクは量子論を組み立てようと思って発見したわけではなく、輻射の問題を説明するための理論中に現れた定数で、ある意味では公式を導くためのテクニックであり、最初は離散的なエネルギーをとること(量子化)に懐疑的だったという話や、アインシュタインが1905年に成した相対性理論、ブラウン運動の理論、光量子仮説のうち前者二つはすぐに評価を得ていたが、プランクらによる学会へのアインシュタインの推薦文に「時には彼も、たとえばその光量子の仮説のように、的を外すこともありますが」とわざわざ書かれるほどには受け入れられ難かった話とか。ボーアとアインシュタインという出自が対照的な二人をダブル主人公に据えた当時の科学者たちの量子論にまつわる群像劇、と書くとキャラ推し的に読むことも可能では。マジで長いのでまだ1/4くらいしか読めていませんがおすすめです。ちなみに著者の山本義隆さんの半生もすごい。