💾

【Rails】saved_change_to_attribute? でハマった話と学んだこと

に公開

こんにちは、st-1985 です。

Rails アプリケーションでは、モデルの属性変更を検知する際に saved_change_to_attribute? メソッドを使用できます。
ただしこのメソッドは「直前の保存によって変化したか」を返します。
そのことによって思わぬ落とし穴にはまってしまったので、今回はその経験を共有したいと思います。

問題の概要

開発しているアプリケーションには以下のようなフォロー状態の属性を持ったユーザーデータが存在しています。

  • 以前からフォロー状態になっている
  • 新しくフォロー状態になった

それぞれのフォロー状態に応じて画面の表示を変化させていました。

問題が発生したのは後述のような実装をした際でした。

追加した実装イメージ

# ユーザーからのアクセスに応じてフォロー状態を変更するコントローラー
def update
  user.last_accessed_at = Time.current  # 追加
  update_status                        
  user.save!                            # 追加

  # シリアライザを使用したレンダリング処理
end

def update_status
  user.status_followed! if new_follow? 
end

現在のアクセス日時を前回アクセスした日時としてを残すように修正しています。
update_status ではユーザーの更新は必ず行われるわけではないため、アクセス記録が保存されるようレンダリングの前での保存処理(save!)も追加しました。

発生した現象

リリース後、「新しくフォロー状態になった」ユーザーが「以前からフォロー状態になっている」と判定されてしまう不具合が発生しました。

原因の特定

問題の原因は追加した実装によってレンダリング時に使用するシリアライザでの判定が変化したことによるものでした。
シリアライザは以下のように「新しくフォロー状態になった」かどうかをsaved_change_to_attribute?を使用して判定していました。

# シリアライザ
def new_followed
  return true if object.saved_change_to_attribute?(:status) && object.followed?
end

saved_change_to_attribute? は直前の保存によって変化したかを返すため、update_statusで保存された後、save!で2回目の保存がされた事により判定が変化してしまっていました。

# ユーザーからのアクセスに応じてフォロー状態を変更するコントローラー
def update
  user.last_accessed_at = Time.current
  update_status                        
  user.save!
  # ↑ saved_change_to_attribute?(:status) が false を返すように変化

  # シリアライザを使用したレンダリング処理
end

def update_status
  user.status_followed! if new_follow?
  # ↑ この時点では saved_change_to_attribute?(:status) は true を返す
end

解決策(補足)

今回のケースでは、saved_change_to_attribute? に依存するのをやめ、別の判定方法に切り替えることで問題を回避しました。
弊社アプリケーションでの一例であり、他のケースでは別のアプローチ(明示的なフラグを持たせる、専用の履歴モデルを導入するなど)の方が適している場合もあると思いますので、本筋ではないですが補足として紹介します。

今回採用した方法(クリックで展開)

saved_change_to_attribute? は直前の保存しか見ていないため、保存処理が複数回走ると意図通りに判定できなくなってしまいます。
そこで「フォローが新しく発生したか」を、保存回数に依存しないロジックに置き換えることにしました。

具体的には、作成時間を基準に判定する方法を採用しました。以下のようなイメージで書き換えています。

# シリアライザでの判定ロジック変更
def new_followed
  # saved_change_to_attribute? の代わりに時間ベースの判定を使用
  object.followed? && object.created_at > GRACE_PERIOD.ago
end

これなら保存処理が複数回実行されても、判定結果が変わってしまうことはありません。
GRACE_PERIOD 期間内に再度アクセスがあった場合初回メッセージが表示される可能性がありますが、今回のケースではUX上問題ないと判断してこの方法を採用しました。

学んだこと

今回の件を通して、saved_change_to_attribute? の使い所について自分なりに整理をすると以下イメージです。

  • モデルのコールバック内など、保存が一度だけ実行されると分かっている場面では安全
  • 複数回保存される可能性がある(保存タイミングを完全にコントロールできない)場面では危険

今回はシリアライザで書いたため、保存タイミングをコントロールできない状態になっていました。
逆に、コールバックや単一メソッド内のように保存が一度で完結する場面なら、saved_change_to_attribute? は安心して使えそうです。

まとめ

saved_change_to_attribute? は便利なメソッドですが、「直前の保存」しか見ないという性質を理解していないと、意図しない挙動に繋がることがあります。

  • 単発保存が保証される場面 → 安全に使える
  • 複数回保存が起きる可能性がある場面 → 別の手段(時間ベースや明示的なフラグ)を検討する

今回の経験を踏まえて、「どこでなら安全か」を意識して使い分けることが大切だと感じました。

Social PLUS Tech Blog

Discussion