【Rails】ポリモーフィック関連のコントローラについて

Ruby Ruby
Ruby

はじめに

Railsを学習している中でポリモーフィック関連のモデル関係は理解できるが、コントローラのベストプラクティスがわからなかったので考えてみました。

もちろん、これが正解という意見ではないですが、参考にしてもらえると嬉しいです。

ポリモーフィック関連とは

まずはこちらをご覧ください。

いわゆるデザインパターンのインターフェースパターンのようなもので、この記事を見るとわかりやすいです。

少し考えてみましょう。

あるAと言いうモデルが他の複数のモデルB, C, D…に関連付くとき、どう言うモデルの関連付けが考えられるでしょうか?

モデルB, C, D…それぞれにモデルAのIDを持つようにカラムを追加して関連付けるのも一つの手かもしれません。

しかしこれでは、モデルAのような関係性をもつモデルが増えるたびに関連付け先のモデルB, C, D…にカラムを追加していくハメになります。

小さなシステムで関係性が簡易な場合はそれでも良いかもしれないです。

しかし、システムに機能が追加され続け、関係性が太っていくとともに一つのモデルが持つ情報も膨れ上がってしまいます

あるいは既存のモデル関係性を壊したい時に関係性が複雑になり改修が大変になることもあるでしょう。

Railsでこういった時に役に立つのがポリモーフィック関連です。

Railsではこの関連付けがとても容易で、使いこなすとモデル間の関係性が疎になり管理が非常に楽になります。

ポリモーフィック関連コントローラを定義する方法

それではポリモーフィックがどう言うものかなんとなくわかったところで本題に入ります。

ポリモーフィック関連を構築する方法はRailsガイドを参照ください。

今回題材にするモデル

今回は、意見(Opinion)モデルと報告(Report)モデルに対してコメントをつけられるようなCommentモデルを用意しました。

関係性としては下記のような関係です。

一つのOpinion、Reportがそれぞれ複数のCommentを持つことができる関係です。

モデルの書き方は下記のようになっています。

class Opinion < ApplicationRecord
  has_many :comments, as: :commentable
end


class Report < ApplicationRecord
  has_many :comments, as: :commentable
end


class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

本来であればどのユーザーとの関連付けなども考えますが、今回は内容をわかりやすくするために省きます。

ポリモーフィック関連モデルに対するコントローラ

さて、それでは本当の本題である、これらのポリモーフィック関連を扱うコントローラについてです。

方針としては大きく2つあります。

  1. コントローラを一つにまとめてOpinionモデルとReportモデルそれぞれが一つのCommentコントローラを扱うパターン
  2. コントローラを二つにし、OpinionモデルとReportモデルそれぞれでCommentコントローラを持つパターン

いづれのパターンでも前提としてルーティングはOpinion、ReportそれぞれにCommentをネストさせる構造です。

例)opinions/:opinions_id/comments

ネット上では「ポリモーフィック コントローラ Rails」などと調べると1についての記事を多く見かけます。

これらを読んでいただけると分かるように、例えば猫Railsさんのブログでは共通のCommentコントローラで最初に下記のような処理をしています。

  1. URIを文字列を”/”で分解
  2. “opinions”文字列からOpnionモデルを生成
  3. :opinions_idを使ってコメントと関連させたい親インスタンスを検索

このやり方はコントローラをうまく共通化できていて賢いなーと思いながらもかなりエラーの温床になりやすそうだなと言う所感です。

筆者としては2が分かりやすくておすすめです。

理由は以下。

  • コントローラを分けることでOpinion、Reportそれぞれのコメントに関する機能を追加しやすい(Opinion、Reportそれぞれで別の機能を持たせやすい)
  • コントローラ処理が親に対して限定的で分かりやすい

デメリットは以下。

  • コントローラファイルが増える
  • 処理が重複している

まあ、Rails勉強するのにはこっちの方がイイですよ、と言うことです。

目指す状態

今回はこんなページを目指します。

意見の詳細ページにいくとコメントができるし見れるといった感じです。

ファイル構造

app/
  ├ controllers/
  |      ├ reports/
  |      |      └ comments_controller.rb
  |      opinions/
  |      |      └ comments_controller.rb
  |       opinions_controller.rb
  |      reports_controller.rb
models/
  |      ├ reports.rb
  |      opinions.rb
  |      └ comments.rb
views/
         ├ reports/
         |       <Scaffoldで生成したviewファイル>
         |       comments/
         |            ├ _comments.html.erb
         |            ├ _form.html.erb
         |            └ edit.html.erb
opinions/
         |       <Scaffoldで生成したviewファイル>
         |       comments/
         |            ├ _comments.html.erb
         |            ├ _form.html.erb
         |            └ edit.html.erb

このような感じでコントローラ、ビューでOpinionsとReportsそれぞれでネスト構造をとっています。

理由は後のルーティングで関わってきます。

ルーティング実装

まずはルーティングの実装例です。

OpinionとReportについてのルーティングは下記のようにネスト構造をとっています。

Rails.application.routes.draw do
  resources :comments
  resources :reports do
    scope module: :reports do
      resources :comments
    end
  end
  resources :opinions do
    scope module: :opinions do
      resources :comments
    end
  end
  mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
end

この時、scope moduleをなぜ使っているかご説明します。

先ほどのファイル構造においてネストしていることが関わってきます。

実際にルーティング情報を見てみましょう。

report_comments        GET /reports/:report_id/comments(.:format)            reports/comments#index
                       POST /reports/:report_id/comments(.:format)           reports/comments#create
new_report_comment     GET /reports/:report_id/comments/new(.:format)        reports/comments#new
edit_report_comment    GET /reports/:report_id/comments/:id/edit(.:format)   reports/comments#edit
report_comment         GET /reports/:report_id/comments/:id(.:format)        reports/comments#show
                       PATCH /reports/:report_id/comments/:id(.:format)      reports/comments#update
                       PUT /reports/:report_id/comments/:id(.:format)        reports/comments#update
                       DELETE /reports/:report_id/comments/:id(.:format)     reports/comments#destroy
opinion_comments       GET /opinions/:opinion_id/comments(.:format)          opinions/comments#index
                       POST /opinions/:opinion_id/comments(.:format)         opinions/comments#create
new_opinion_comment    GET /opinions/:opinion_id/comments/new(.:format)      opinions/comments#new
edit_opinion_comment   GET /opinions/:opinion_id/comments/:id/edit(.:format) opinions/comments#edit
opinion_comment        GET /opinions/:opinion_id/comments/:id(.:format)      opinions/comments#show
                       PATCH /opinions/:opinion_id/comments/:id(.:format)    opinions/comments#update
                       PUT /opinions/:opinion_id/comments/:id(.:format)      opinions/comments#update
                       DELETE /opinions/:opinion_id/comments/:id(.:format)   opinions/comments#destroy

scopeを使うことでコントローラの参照先がopinions/comments#index とネストされていることがわかります。

詳細はこの記事をご覧ください。

コントローラ実装例

それでは実際にコントローラをみて行きましょう。

※OpinionとReportで処理は全く同じなので今回はOpinionのみご紹介します。

class Opinions::CommentsController < ApplicationController
 before_action :set_commentable, only: %i[index create update edit]

 # GET opinions/:opinion_id/comments
 def index
  @comments = @commentable.comments
 end

 # GET opinions/:opinion_id/comments/1
 def show; end

 # GET opinions/:opinion_id/comments/1/edit
 def edit
  @comment = Comment.find(params[:id])
 end

 # POST opinions/:opinion_id/comments
 def create
  @comment = @commentable.comments.new(comment_params)
  if @comment.save
   redirect_to url_for(@commentable), notice: 'Comment was successfully created.'
  else
   render :new
  end
 end

 # PATCH/PUT opinions/:opinion_id/comments/1
 def update
  if @comment.update(comment_params)
   redirect_to url_for(@commentable), notice: 'Comment was successfully updated.'
  else
   render :edit
  end
 end

 # DELETE /comments/1
 def destroy
  @comment = Comment.find(params[:id])
  @commentable = @comment.commentable
  @comment.destroy
  redirect_to url_for(@commentable), notice: 'Comment was successfully destroyed.'
 end

 private

 def set_commentable
  commentable_id = params[:opinion_id].to_i
  @commentable = Opinion.find(commentable_id)
 end

 def comment_params
  params.require(:comment).permit(:content, :opinion_id)
 end
end

後ほどご紹介するViewsファイルのようにパス指定すると、ポリモーフィック関連付けされたOpinionsやReportsのインスタンスIDはリクエストのparamsでopinions_idやreport_idといった形で投げられます。

この時、コントローラを共通化して一つにしてしまうと、投げられるIDが関連付け先の親インスタンスタイプによって変わってきてしまいます。

そのため、冒頭にご紹介した共通コントローラではURLから複雑なことをしてクラスを作っていたのです。

共通化した方が一見便利そうですが、わかりにくく、エラーの温床になりそうで今回はコントローラを分ける方針をご紹介しました。

ビュー実装例

簡単にご紹介します。

まずはコメント表示のパーシャル。

<% if commentable.comments.length == 0 %>
 <%= t('views.common.not_comment') %>
 <br>
 <br>
<% else %>
 <% commentable.comments.each do |comment| %>
  <p>
  <%= comment.created_at.strftime(format='%y/%m/%d %H:%M') %>
  <br>
  <p>
  <%= comment.content %>
  </p>
  <%= link_to t('views.common.edit'), edit_polymorphic_path([commentable, comment]) %>
  <%= link_to t('views.common.destroy'), [commentable, comment], method: :delete %>
  </p>
 <% end %>
<% end %>

続いてコメントフォームのパーシャル。

<%= form_with(model: opinion) do |form| %>
 <% if opinion.errors.any? %>
  <div id="error_explanation">
   <h2><%= pluralize(opinion.errors.count, "error") %> prohibited this opinion from being saved:</h2>
   <ul>
    <% opinion.errors.each do |error| %>
     <li><%= error.full_message %></li>
    <% end %>
   </ul>
  </div>
 <% end %>
 <div class="field">
  <%= form.label :title %>
  <%= form.text_field :title %>
 </div>
 <div class="field">
  <%= form.label :content %>
  <%= form.text_area :content %>
 </div>
 <div class="actions">
  <%= form.submit %>
 </div>
<% end %>

コメント編集のビュー。

<h1>Editing Opnion</h1>
<%= render 'form', opinion: @opinion %>
<%= link_to 'Show', @opinion %> |
<%= link_to 'Back', opinions_path %>

肝なのがlink_toタグのedit_polymorphic_path([commentable, comment]) と[commentable, comment] 。

パスをルーティングに合わせてポリモーフィック関連ように生成してくれる書き方と、ネスト構造になっているルートを生成してくれる書き方です。

最後に

いかがだったでしょうか。

ポリモーフィック関連は関連づけたいモデルを扱うコントローラを関連づけたいモデル分用意する方針をご紹介しました。

共通化でも良いかもしれないですが、理解のためにはまずは分けて書くのもイイかもしれないですね。

疑問やお問い合わせはこちらに。

タイトルとURLをコピーしました