✏️

Railsのポリモーフィック関連を利用したコンテンツの再編集機能をリファクタリングした話

2024/04/23に公開

あらためまして gamiTa です。
今回は mybest BlogKaigi 2024の7日目を担当させていただきます。

ポリモーフィック関連について

今回はポリモーフィック関連をリファクタリングした話なので、まずはポリモーフィック関連について。
ご存知の通りRailsではポリモーフィック関連を簡単に実装できる仕組みがあります。
しかし、これまたご存知の通りポリモーフィック関連は、SQLのアンチパターンの一つとして扱われています。

主にアンチパターンとされている点

  • 複数のテーブルのidを1つのカラムで参照するため、外部キー制約が使用できず存在が保証できない
  • JOINを行う場合に動的にテーブルを選択することはできないので全てのテーブルを指定しなければいけなくなる

また、SQLだけでなく下記の記事にもある通り、

あくまでもこれは多態性を持ったものに対する関連を定義する事であって、インターフェースに対する関連の定義だということを理解して使うようにしよう。

https://qiita.com/joker1007/items/9da1e279424554df7bb8
という点を鑑みずに導入して運用すると、記事にもあるように実装コードのメンテナンス性が非常に辛くなります。

しかし、上記のお約束を守り、かつRailsの機能を上手く利用してリスクを低減することで、開発体験の向上が望めます。
そのため一概にアンチパターンだと言って切り捨てるには勿体ない仕組みがRailsのポリモーフィック関連です。

マイベストにおいてのポリモーフィック関連

マイベストでは、このポリモーフィック関連を利用した処理は複数存在しています。今回リファクタリングの対象となったのは、「公開中のコンテンツページ」(例:こちら)は公開しつつも、公開中のコンテンツページへの下書き編集・保存作業は行いたい、というニーズから生まれたコンテンツ再編集機能と呼ばれる機能の中にありました。

こちらがコンテンツ再編集機能で利用するテーブルを簡易的にまとめたER図です

各テーブルの役割

  • presses: idがそのままURLのslugに相当する(元々のコンテンツページのメインは本テーブルという歴史的経緯によりPressesのidをURLのslugとして利用し続けている)
  • contents: コンテンツページの主なデータが格納されている
  • substitute_contents: 公開中のコンテンツページのデータをコピーされたものが格納されている
  • section_choices: コンテンツページを構成するセクション「選び方」のデータが格納されている
  • section_ranking: コンテンツページを構成するセクション「ランキング」のデータが格納されている

また、共通のポリモーフィック関連の子テーブルを持つcontentsテーブルとsubstitute_contentsテーブルはそのままContentモデルとSubstituteContentモデルに対応しています。

コンテンツ再編集機能について

コンテンツ再編集機能は下記の2つの処理を持ちます。

  • 再編集コンテンツ作成処理
    • Contentと指定の子モデルを元にSubstituteContentと紐づく指定の子モデルを作成
  • 再編集コンテンツ公開処理
    • SubstituteContentと指定の子モデルを元に、編集結果内容をContentと指定の子モデルへ反映(SubstituteContentは反映後削除)

ポリモーフィック関連であることは辛くない。ポリモーフィック関連を持つモデル間でのデータのコピーにその関連を利用するのは辛い

  • ContentSubstituteContentはそれこそ共通モジュールをインターフェイス代わりに両クラスにinclude することで、多態性的に使うことができていた
  • しかし、ContentSubstituteContent)側にカラムやアソシエーションの追加・変更があると再編集コンテンツ作成処理及び公開処理にも、その変更を適用する必要が発生
    • コンテンツ再編集機能を意識しないドメインだと適用漏れが発生することになり不具合の温床に

そしてポリモーフィック関連におけるコンテンツ再編集機能をリファクタリングを決定させる判断として、他のコンテンツ種別のページ(例としてHogeContent)にもコンテンツ再編集機能と同様の機能を適用しないといけなくなったのも大きいです。

そのまま他のコンテンツ種別のページHogeContentに対するSubstituteHogeContentを作成し、Content側そのままにコンテンツ再編集機能を模倣すればすぐに実装は可能でありました。
しかし、カラムやアソシエーションの追加・変更には弱いままかつ、同じような処理が偏在することになりメンテナンスコストが高まりそうな気配はどうしても感じます。

そのため、ポリモーフィック関連ではなく、それこそ該当モデルにモジュールを include するだけで、簡単に再編集機能を導入できる方法を目指しました。

ポリモーフィック関連を持つモデル同士のコピーから、該当モデルの階層モデル群ごと再編集データとしてコピーを作成する方法にリファクタリング

何故SubstituteContentが必要かというと、公開中のContentのデータを編集したくないために公開中のContentの代替データで編集・保存したいからです。
それならそのまま新しいContentに該当のContentのデータを子モデル階層ごとコピーしたものを作成するだけでも要件は成立します。(頑張ってコピー処理を実装すれば)
そしてContent同士は自己結合で紐づけることで公開中のContentと、そこから作成された代替データっとしてのContentを参照することができるようになります。

上記を表現した簡易的なER図が下記のものです。

各テーブルの役割(変更点)

  • presses: contentsテーブルがhas_manyの関連で紐づくようになったので、rails側でcontent_idnull(=オリジナル側)のものをcontentメソッドとして新たに定義している
  • contents: 新たにカラムcontent_idを作成。再編集用として作成された場合にはオリジナル側のcontentsテーブルのidが格納される。
  • section_choices: 外部キーとしてcontent_idを参照するように変更
  • section_ranking: 外部キーとしてcontent_idを参照するように変更

コンテンツ再編集機能について

コンテンツ再編集機能はリファクタリング前と同様に下記の2つの処理を持ちます。

  • 再編集コンテンツ作成処理

    • content_idにはコピー元のidが入る
    • 該当の公開中のContentに紐づく全ての子モデルの階層を末端まで含めてコピーしたContentを作成する
      • そのために該当モデル紐づく子モデルから再帰的に子モデルを探索して再編集コンテンツとして保存するロジックを作成
        • 子モデルの持ち方的に独自にロジックを作成することになったため、モデルの has_manyhas_one 関連を辿り再帰的に子モデルを収集するロジックを作成
      def clone_with_association(original)
        substitute = original.deep_dup
      
        # NOTE: 画像ファイルはこのタイミングで複製し、レコードも一旦更新する
        if original.has_attribute?(:image_data) && original.image.present?
          attacher = substitute.image_attacher
          attacher.set attacher.upload(attacher.file)
        end
      
        # clone_has_one(many)の引数に渡したモデルに子モデルがあれば本メソッド(clone_has_one_association)を呼び出し
        # 引数にその子モデルを格納することでさらにその子モデルの末端まで探索しながら、substituteにオリジナル側の子モデルのdeep_dupを保存していく
        clone_has_one_association(original, substitute)
        clone_has_many_association(original, substitute)
      
        substitute
      end
    
      def clone_has_one_association(original, substitute)
        has_one_model_names(original).each do |child_model_name|
          next if substitute_exclude_models.include?(child_model_name)
    
          original_child = original.send(child_model_name)
          next if original_child.nil?
    
          substitute_child = clone_with_association(original_child)
          substitute.send("#{child_model_name}=", substitute_child)
        end
      end
    
      def clone_has_many_association(original, substitute)
        has_many_model_names(original).each do |child_model_name|
          next if substitute_exclude_models.include?(child_model_name)
    
          original_children = original.send(child_model_name)
          next if original_children.empty?
    
          substitute_children = original_children.map { |original_child| clone_with_association(original_child) }
          substitute.send("#{child_model_name}=", substitute_children)
        end
      end
    
      def has_one_model_names(object)
        object.class.reflect_on_all_associations(:has_one).reject do |assoc|
          assoc.class.to_s.match? '::ThroughReflection'
        end.map(&:name)
      end
    
      def has_many_model_names(object)
        object.class.reflect_on_all_associations(:has_many).reject do |assoc|
          assoc.class.to_s.match? '::ThroughReflection'
        end.map(&:name)
      end
    
    
  • 再編集コンテンツ公開処理

    • 再編集として作成されたContentcontent_idをnullにして、公開中のContentを削除することで、まるっとPressに紐づくContent*をすげ替える

上記のコンテンツ再編集機能はSubstitutableモジュールとして作成しており、下記のように動的にアソシエーションを設定するため、クラス名_idのカラムさえ作成して include したらどのモデルでも利用できるようにしています。

    has_one :substitute, class_name: self.to_s
    belongs_to :original, class_name: self.to_s, optional: true,
        foreign_key: "#{self.to_s.underscore}_id", inverse_of: :substitute

上記のモジュールを作成、導入することでカラムやアソシエーションの追加・変更の影響を受けることなく、またSubstitute〜関連の定義が不要になることで本来なら実装されたはずのコードを大幅に削減することができました。

おわりに

本案件は mybest BlogKaigi 2024の4日目である イネーブリングチームの考え方と実践例 — 組織の価値提供能力をいかに高めるかの流れの一環として進めさせて頂きました。この考えがあるため、またこのような考えがエンジニア以外のビジネスメンバーにも浸透しているため、マイベストは改善活動がとても実施しやすい環境となっています。
なので今後もこのような活動を通してエンドツーエンドへの価値提供能力を高めていければと思います。

Discussion