はじめに
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つあります。
- コントローラを一つにまとめてOpinionモデルとReportモデルそれぞれが一つのCommentコントローラを扱うパターン
- コントローラを二つにし、OpinionモデルとReportモデルそれぞれでCommentコントローラを持つパターン
いづれのパターンでも前提としてルーティングはOpinion、ReportそれぞれにCommentをネストさせる構造です。
例)opinions/:opinions_id/comments
ネット上では「ポリモーフィック コントローラ Rails」などと調べると1についての記事を多く見かけます。
猫Railsブログ
https://nekorails.hatenablog.com/entry/2019/06/13/031003
Quiita
https://qiita.com/miketa_webprgr/items/f9d536f8265ca52b5092
これらを読んでいただけると分かるように、例えば猫Railsさんのブログでは共通のCommentコントローラで最初に下記のような処理をしています。
- URIを文字列を”/”で分解
- “opinions”文字列からOpnionモデルを生成
- :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]
。
パスをルーティングに合わせてポリモーフィック関連ように生成してくれる書き方と、ネスト構造になっているルートを生成してくれる書き方です。
最後に
いかがだったでしょうか。
ポリモーフィック関連は関連づけたいモデルを扱うコントローラを関連づけたいモデル分用意する方針をご紹介しました。
共通化でも良いかもしれないですが、理解のためにはまずは分けて書くのもイイかもしれないですね。
疑問やお問い合わせはこちらに。