Active Record アソシエーションを安全に廃止する

こんにちは、ザックです。アンドパッドでフリーランスの Rails エンジニアとして働きながら、趣味で Ruby や Rails にコントリビュートしています。

このポストでは、Rails へ静かに加わったあまり知られていない機能を紹介します。大規模な Rails アプリケーションを扱うエンジニアには、特に役立つと思っています。

Deprecated Associations

古いアソシエーションの削除は、本来ならルーティンな作業のはずですが、実際には手探りになりがちです。

コードベースを検索し、明らかな呼び出し箇所を修正し、テストを走らせ、アソシエーションを削除します。すべて問題なさそうに見えます。しかし、バックグラウンドジョブや管理画面、めったに使われないエンドポイントがまだ古い名前を呼び出していることが本番で発覚します。もはやクリーンアップ作業ではなく、インシデントです。

Rails 8.1 は小さな機能を追加しました: アソシエーションを deprecated としてマークできるようになりました。単に警告を出せるというだけではなく、段階的に移行する道筋を示してくれます:

  1. 開発環境では :warn を使い、エンジニアが作業を妨げずに deprecated な使用箇所を確認できる。
  2. テストと CI では :raise を使い、新しい呼び出しが混入しないようにする。
  3. 本番環境では :notify を使い、残っている隠れた呼び出し元を構造化ログで見つけてからアソシエーションを削除する。

「もう使われていないと思う」を、ほぼ確実な保証に変えられます。

問題

アソシエーションを削除するときに難しいのは、リファクタリング自体ではありません。すべての呼び出し元を見つけられたかどうかを確認することです。

コード検索は役立ちますが不完全です。アソシエーションの使用箇所は、コントローラ、ジョブ、ビュー、preload クエリ、スクリプトなど各所に散らばっています。ローカルで再現しにくいパスもあれば、本番トラフィックでしか実行されないパスもあります。

だからこそ、アソシエーションを早まって削除するのは危険です。メソッドはすぐに消えますが、呼び出し元は消えません。移行後に1つでも残っていれば、そのコードパスが実行された瞬間に落ちます。

既存のアプローチ

この機能が登場する前は、各チームが独自の解決策を用意していました。

よくあるアプローチは、アソシエーションのメソッドをラップして deprecator で警告を出すことです:

class Author < ApplicationRecord
  has_many :articles, class_name: "Post"
  has_many :posts, class_name: "Post"

  def posts
    Rails.deprecator.warn("Author#posts is deprecated, use #articles instead.")
    super
  end
end

直接呼び出しはキャッチできますが、eager load やネストされた属性、フレームワーク側の間接的な使用は漏れます。廃止したいアソシエーションが十数個あれば、形の違う手書きのラッパーが十数個できあがります。

段階的なアプローチを省略して grep・置換・削除だけで済ませるチームもあります -- うまくいかなくなるまでは。

黙って壊れるのはこういう状況です:

class Author < ApplicationRecord
  has_many :articles, class_name: "Post"
end

class LegacyAuthorDigest
  def published_titles(author)
    author.posts.published.order(:id).pluck(:title)
  end
end

Author#posts がすでに削除されていれば、LegacyAuthorDigest はそのコードパスが初めて実行された瞬間に NoMethodError で落ちます。

アソシエーションを deprecated にする

class Author < ApplicationRecord
  has_many :articles, class_name: "Post"
  has_many :posts, class_name: "Post", deprecated: true
end

Rails のドキュメントでは Deprecated Associations として解説されています。

この変更だけで、Rails が posts の使用を報告し始めます。対象は直接のメソッド呼び出しだけではありません -- クエリ実行、ネストされた属性、:dependent:touch といったオプションの副作用による使用も報告されます。宣言はアソシエーション自体に付くため、呼び出し箇所ごとに警告を書く必要がありません。

まず :warn から始める

# config/environments/development.rb
config.active_record.deprecated_associations_options = {
  mode: :warn,
  backtrace: false
}

エンジニアは警告を確認できますが、作業はブロックされません。

LegacyAuthorDigest.new.published_titles(author)
# => ["Kindred"]

:warn モードでは、deprecated なアソシエーションを呼び出しても結果は返りますが、警告がログに出ます:

The association Author#posts is deprecated, the method posts was invoked
(app/services/legacy_author_digest.rb:3:in 'LegacyAuthorDigest#published_titles')
["Kindred"]

コードは動き続けました。警告は移行の初期段階で有効で、作業の邪魔にならないからこそ意味があります -- 最初からエンジニアへ負担をかけると、回避策を探し始めて移行への協力が得られなくなります。

テストと CI では :raise を使う

# config/environments/test.rb
config.active_record.deprecated_associations_options = {
  mode: :raise,
  backtrace: false
}

:raise に設定すると、古い呼び出し箇所はすぐ失敗します:

ActiveRecord::DeprecatedAssociationError:
The association Author#posts is deprecated, the method posts was invoked (app/services/legacy_author_digest.rb:3:in 'LegacyAuthorDigest#published_titles')
app/services/legacy_author_digest.rb:3:in 'LegacyAuthorDigest#published_titles'

フィーチャーブランチで新たな呼び出し箇所が追加されても、テストを走らせた時点で気づけます。移行が「なるべく使わないでください」から「誤って新しい使用箇所をマージできない」に変わります。

置き換え後のパスはそのまま動きます:

class AuthorDigest
  def published_titles(author)
    author.articles.published.order(:id).pluck(:title)
  end
end

本番環境のツール: :notify

# config/environments/production.rb
config.active_record.deprecated_associations_options = {
  mode: :notify,
  backtrace: false
}

このモードでは、Rails はログ出力や例外の raise の代わりに deprecated_association.active_record 通知を publish します。

本番環境こそ、隠れた呼び出し元が見つかる場所です。忘れられたジョブ、古い管理画面、低頻度のリクエストパスは、ローカルのテストでは一切現れないことがあります。:notify を使えば、本番トラフィックを失敗させずにそれらの呼び出し元を観測できます。

deprecated_association.active_record 通知のペイロードには以下が含まれます:

  • アソシエーションのリフレクション
  • 人が読めるメッセージ
  • deprecated な使用が発生したアプリケーション側の場所
  • 任意のバックトレース

通知を受け取る

Active Support InstrumentationActiveSupport::Notifications を使います。イニシャライザでイベントを購読し、構造化ログを出力します:

# config/initializers/deprecated_association_subscriber.rb
ActiveSupport::Notifications.subscribe("deprecated_association.active_record") do |_name, _start, _finish, _id, payload|
  reflection = payload.fetch(:reflection)

  Rails.logger.warn(
    {
      event: "deprecated_association_usage",
      association: "#{reflection.active_record.name}##{reflection.name}",
      model: reflection.active_record.name,
      association_name: reflection.name.to_s,
      location: payload[:location]&.to_s
    }.to_json
  )
end

出力されるログはこのようになります:

{
  "event": "deprecated_association_usage",
  "association": "Author#posts",
  "model": "Author",
  "association_name": "posts",
  "location": "app/services/legacy_author_digest.rb:3:in 'LegacyAuthorDigest#published_titles'"
}

監視ツールで event:deprecated_association_usage を検索し、associationlocation でグループ化して、カウントが下がっていくのを確認します。本番のシグナルが静まったらアソシエーションを削除します。

実際の移行手順

  1. 古いアソシエーションに deprecated: true をつける。
  2. 開発環境は :warn のままにして、エンジニアが deprecated な使用箇所を確認しながら作業を続けられるようにする。
  3. テストと CI では :raise を使い、新しい呼び出し箇所を早期に検出する。
  4. 本番環境は :notify にして、イベントを構造化ログに流す。
  5. 本番からの通知が止まったらアソシエーションを削除する。

可視化から始め、次に強制、最後に本番で確認します。

おわりに

アソシエーションの削除はこれまで、古い名前を永遠に残し続けるか、コード検索を信じて削除するかの二択でした。Deprecated associations は第三の選択肢を与えてくれます: 呼び出し元を観測し、本番が安全だと確認してから削除します。

warn -> raise -> notify で移行のライフサイクル全体をカバーできます -- 開発では可視化、テストでは強制、本番では観測。

大規模な Rails アプリケーションでは、API そのものよりもこのワークフローの方が重要です。難しいのは宣言を書くことではありません。静的検索だけでは全体を把握しきれないシステムで、古いインターフェースを安全に廃止することです。