Rails初心者のためのMVCアーキテクチャ完全理解ガイド
はじめに
MVCアーキテクチャは、Railsフレームワークの根幹を成す設計パターンです。Model、View、Controllerの3つの層に責任を分離することで、保守性が高く拡張しやすいWebアプリケーションを構築できます。本記事では、MVCの基本概念から実践的な実装まで、Rails初心者でも理解できるよう詳しく解説します。
学習目標
- MVCアーキテクチャの基本概念を理解する
- 各層の責任と相互作用を把握する
- 実際のRailsアプリケーションでMVCを適切に実装する
- よくあるアンチパターンを避ける方法を学ぶ
MVCアーキテクチャの基本概念
1. MVCとは何か
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Model │ │ Controller │ │ View │
│ │ │ │ │ │
│ ・データ管理 │◄──►│ ・ユーザー入力│◄──►│ ・表示処理 │
│ ・ビジネス │ │ ・データ変換 │ │ ・UI描画 │
│ ロジック │ │ ・制御処理 │ │ ・ユーザー │
│ │ │ │ │ インタラクション │
└─────────────┘ └─────────────┘ └─────────────┘
Model(モデル): データの管理とビジネスロジック
View(ビュー): ユーザーインターフェースの表示
Controller(コントローラー): ユーザー入力の処理と制御
2. 責任の分離
ruby
# 悪い例:全ての処理をControllerに書く
class PostsController < ApplicationController
def index
# データベースから直接データを取得(Modelの責任)
@posts = Post.where("created_at > ?", 1.week.ago)
.where(status: 'published')
.order(created_at: :desc)
# ビジネスロジックをControllerに記述(Modelの責任)
@posts = @posts.select do |post|
post.comments.count > 5 &&
post.author.premium? &&
post.content.length > 500
end
# HTML生成処理をControllerに記述(Viewの責任)
@html = "<div class='posts'>"
@posts.each do |post|
@html += "<div class='post'>#{post.title}</div>"
end
@html += "</div>"
end
end
ruby
# 良い例:責任を適切に分離
class PostsController < ApplicationController
def index
# Controllerは制御処理のみ
@posts = Post.popular_posts
end
end
# Model: データとビジネスロジック
class Post < ApplicationRecord
scope :published, -> { where(status: 'published') }
scope :recent, -> { where("created_at > ?", 1.week.ago) }
scope :popular_posts, -> {
published.recent
.joins(:comments, :author)
.where(authors: { premium: true })
.where("LENGTH(content) > 500")
.group("posts.id")
.having("COUNT(comments.id) > 5")
.order(created_at: :desc)
}
end
erb
<!-- View: 表示処理のみ -->
<div class="posts">
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= truncate(post.content, length: 200) %></p>
</div>
<% end %>
</div>
Model層の詳細実装
1. Active Recordモデルの基本
ruby
# app/models/post.rb
class Post < ApplicationRecord
# 関連付け
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
has_many :tags, through: :post_tags
has_one_attached :featured_image
# バリデーション
validates :title, presence: true, length: { maximum: 100 }
validates :content, presence: true, length: { minimum: 100 }
validates :status, inclusion: { in: %w[draft published archived] }
# スコープ(クエリの再利用)
scope :published, -> { where(status: 'published') }
scope :by_author, ->(author) { where(author: author) }
scope :recent, ->(days = 7) { where("created_at > ?", days.days.ago) }
# コールバック
before_save :generate_slug
after_create :notify_followers
# インスタンスメソッド(ビジネスロジック)
def published?
status == 'published'
end
def reading_time
words_per_minute = 200
word_count = content.split.size
(word_count / words_per_minute.to_f).ceil
end
def can_be_edited_by?(user)
author == user || user.admin?
end
# クラスメソッド
def self.trending
joins(:comments)
.where(comments: { created_at: 1.week.ago.. })
.group('posts.id')
.order('COUNT(comments.id) DESC')
.limit(10)
end
private
def generate_slug
self.slug = title.parameterize if title.present?
end
def notify_followers
NotifyFollowersJob.perform_later(self) if published?
end
end
2. モデルの設計パターン
ruby
# Concern を使った共通機能の抽出
# app/models/concerns/commentable.rb
module Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable, dependent: :destroy
end
def comments_count
comments.count
end
def latest_comments(limit = 5)
comments.recent.limit(limit)
end
end
# app/models/concerns/publishable.rb
module Publishable
extend ActiveSupport::Concern
included do
validates :status, inclusion: { in: %w[draft published archived] }
scope :published, -> { where(status: 'published') }
scope :draft, -> { where(status: 'draft') }
end
def published?
status == 'published'
end
def publish!
update!(status: 'published', published_at: Time.current)
end
end
# モデルでConcernを使用
class Post < ApplicationRecord
include Commentable
include Publishable
# その他の実装...
end
class Article < ApplicationRecord
include Commentable
include Publishable
# その他の実装...
end
3. サービスオブジェクトパターン
ruby
# app/services/post_publishing_service.rb
class PostPublishingService
def initialize(post, user)
@post = post
@user = user
end
def call
return failure('権限がありません') unless can_publish?
return failure('既に公開済みです') if @post.published?
ActiveRecord::Base.transaction do
@post.publish!
create_activity
notify_subscribers
update_search_index
end
success('投稿を公開しました')
rescue => e
failure("公開に失敗しました: #{e.message}")
end
private
def can_publish?
@post.can_be_edited_by?(@user)
end
def create_activity
Activity.create!(
user: @user,
action: 'publish',
target: @post
)
end
def notify_subscribers
@user.followers.find_each do |follower|
PostNotificationJob.perform_later(@post, follower)
end
end
def update_search_index
@post.reindex if defined?(Searchkick)
end
def success(message)
OpenStruct.new(success?: true, message: message)
end
def failure(message)
OpenStruct.new(success?: false, message: message)
end
end
# Controllerでの使用
class PostsController < ApplicationController
def publish
@post = current_user.posts.find(params[:id])
result = PostPublishingService.new(@post, current_user).call
if result.success?
redirect_to @post, notice: result.message
else
redirect_to @post, alert: result.message
end
end
end
View層の詳細実装
1. ERBテンプレートの基本
erb
<!-- app/views/posts/show.html.erb -->
<article class="post">
<header class="post-header">
<h1 class="post-title"><%= @post.title %></h1>
<div class="post-meta">
<span class="author">
著者: <%= link_to @post.author.name, @post.author %>
</span>
<time class="published-date" datetime="<%= @post.published_at&.iso8601 %>">
<%= @post.published_at&.strftime("%Y年%m月%d日") %>
</time>
<span class="reading-time">
読了時間: 約<%= @post.reading_time %>分
</span>
</div>
<% if @post.featured_image.attached? %>
<div class="featured-image">
<%= image_tag @post.featured_image, alt: @post.title, class: "img-fluid" %>
</div>
<% end %>
</header>
<div class="post-content">
<%= simple_format(@post.content) %>
</div>
<footer class="post-footer">
<div class="tags">
<% @post.tags.each do |tag| %>
<%= link_to tag.name, posts_path(tag: tag.name), class: "tag" %>
<% end %>
</div>
<div class="actions">
<% if @post.can_be_edited_by?(current_user) %>
<%= link_to "編集", edit_post_path(@post), class: "btn btn-primary" %>
<%= link_to "削除", @post, method: :delete,
confirm: "本当に削除しますか?",
class: "btn btn-danger" %>
<% end %>
</div>
</footer>
</article>
<!-- コメント機能 -->
<section class="comments-section">
<h3>コメント (<%= @post.comments.count %>)</h3>
<% if user_signed_in? %>
<%= render 'comments/form', post: @post, comment: Comment.new %>
<% else %>
<p><%= link_to "ログイン", new_user_session_path %>してコメントを投稿</p>
<% end %>
<div class="comments">
<%= render @post.comments.includes(:user).order(created_at: :desc) %>
</div>
</section>
2. パーシャル(部分テンプレート)の活用
erb
<!-- app/views/posts/_post.html.erb -->
<article class="post-card">
<% if post.featured_image.attached? %>
<div class="post-image">
<%= link_to post do %>
<%= image_tag post.featured_image.variant(resize_to_limit: [300, 200]),
alt: post.title %>
<% end %>
</div>
<% end %>
<div class="post-content">
<h2 class="post-title">
<%= link_to post.title, post %>
</h2>
<p class="post-excerpt">
<%= truncate(strip_tags(post.content), length: 150) %>
</p>
<div class="post-meta">
<span class="author"><%= post.author.name %></span>
<span class="date"><%= post.created_at.strftime("%Y/%m/%d") %></span>
<span class="comments-count">
<%= pluralize(post.comments.count, 'コメント') %>
</span>
</div>
</div>
</article>
<!-- app/views/posts/index.html.erb -->
<div class="posts-grid">
<%= render @posts %>
</div>
<!-- または明示的にパーシャルを指定 -->
<div class="posts-list">
<%= render partial: 'post', collection: @posts, as: :post %>
</div>
3. ヘルパーメソッドの活用
ruby
# app/helpers/posts_helper.rb
module PostsHelper
def post_status_badge(post)
case post.status
when 'published'
content_tag :span, '公開', class: 'badge badge-success'
when 'draft'
content_tag :span, '下書き', class: 'badge badge-warning'
when 'archived'
content_tag :span, 'アーカイブ', class: 'badge badge-secondary'
end
end
def post_reading_time(post)
minutes = post.reading_time
if minutes < 1
'1分未満'
else
"約#{minutes}分"
end
end
def formatted_post_date(post)
return '未公開' unless post.published_at
if post.published_at > 1.week.ago
time_ago_in_words(post.published_at) + '前'
else
post.published_at.strftime('%Y年%m月%d日')
end
end
def post_share_url(post, platform)
case platform
when :twitter
"https://twitter.com/intent/tweet?text=#{CGI.escape(post.title)}&url=#{CGI.escape(post_url(post))}"
when :facebook
"https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(post_url(post))}"
when :line
"https://social-plugins.line.me/lineit/share?url=#{CGI.escape(post_url(post))}"
end
end
end
# app/helpers/application_helper.rb
module ApplicationHelper
def page_title(title = nil)
if title.present?
"#{title} | My Blog"
else
"My Blog"
end
end
def current_page_class(path)
'active' if current_page?(path)
end
def flash_message_class(type)
case type.to_sym
when :notice then 'alert-success'
when :alert then 'alert-danger'
when :warning then 'alert-warning'
else 'alert-info'
end
end
end
Controller層の詳細実装
1. RESTfulコントローラーの実装
ruby
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: [:show, :edit, :update, :destroy, :publish]
before_action :ensure_owner, only: [:edit, :update, :destroy, :publish]
# GET /posts
def index
@posts = Post.published
.includes(:author, :tags, featured_image_attachment: :blob)
.page(params[:page])
.per(10)
# 検索機能
if params[:search].present?
@posts = @posts.where("title ILIKE ? OR content ILIKE ?",
"%#{params[:search]}%", "%#{params[:search]}%")
end
# タグフィルタ
if params[:tag].present?
@posts = @posts.joins(:tags).where(tags: { name: params[:tag] })
end
# 作者フィルタ
if params[:author_id].present?
@posts = @posts.where(author_id: params[:author_id])
end
end
# GET /posts/:id
def show
# 閲覧数のカウントアップ(非同期で実行)
IncrementViewCountJob.perform_later(@post)
# 関連記事の取得
@related_posts = @post.related_posts.limit(3)
end
# GET /posts/new
def new
@post = current_user.posts.build
end
# POST /posts
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: '投稿を作成しました。'
else
render :new, status: :unprocessable_entity
end
end
# GET /posts/:id/edit
def edit
end
# PATCH/PUT /posts/:id
def update
if @post.update(post_params)
redirect_to @post, notice: '投稿を更新しました。'
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /posts/:id
def destroy
@post.destroy
redirect_to posts_path, notice: '投稿を削除しました。'
end
# PATCH /posts/:id/publish
def publish
result = PostPublishingService.new(@post, current_user).call
if result.success?
redirect_to @post, notice: result.message
else
redirect_to @post, alert: result.message
end
end
private
def set_post
@post = Post.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to posts_path, alert: '投稿が見つかりません。'
end
def ensure_owner
unless @post.can_be_edited_by?(current_user)
redirect_to posts_path, alert: '権限がありません。'
end
end
def post_params
params.require(:post).permit(:title, :content, :status, :featured_image, tag_ids: [])
end
end
2. 名前空間とネストしたリソース
ruby
# config/routes.rb
Rails.application.routes.draw do
root 'posts#index'
resources :posts do
member do
patch :publish
end
resources :comments, except: [:index, :show]
end
namespace :admin do
resources :posts do
member do
patch :approve
patch :reject
end
end
resources :users
root 'dashboard#index'
end
end
# app/controllers/admin/posts_controller.rb
class Admin::PostsController < Admin::BaseController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
case params[:status]
when 'pending'
@posts = @posts.where(status: 'pending')
when 'published'
@posts = @posts.published
end
end
def approve
@post = Post.find(params[:id])
@post.update!(status: 'published', published_at: Time.current)
redirect_to admin_posts_path, notice: '投稿を承認しました。'
end
end
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_post
before_action :set_comment, only: [:edit, :update, :destroy]
def create
@comment = @post.comments.build(comment_params)
@comment.user = current_user
if @comment.save
redirect_to @post, notice: 'コメントを投稿しました。'
else
redirect_to @post, alert: 'コメントの投稿に失敗しました。'
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def set_comment
@comment = @post.comments.find(params[:id])
end
def comment_params
params.require(:comment).permit(:content)
end
end
実践的なMVCパターン
1. フォーム処理の完全な実装
ruby
# app/models/post.rb
class Post < ApplicationRecord
attr_accessor :tag_names
after_save :update_tags, if: :tag_names_changed?
private
def tag_names_changed?
@tag_names.present?
end
def update_tags
self.tags = @tag_names.split(',').map(&:strip).map do |name|
Tag.find_or_create_by(name: name.downcase)
end
end
end
erb
<!-- app/views/posts/_form.html.erb -->
<%= form_with model: post, local: true, class: "post-form" do |form| %>
<% if post.errors.any? %>
<div class="alert alert-danger">
<h4><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h4>
<ul>
<% post.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= form.label :title, class: "form-label" %>
<%= form.text_field :title, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :content, class: "form-label" %>
<%= form.text_area :content, rows: 10, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :status, class: "form-label" %>
<%= form.select :status,
options_for_select([
['下書き', 'draft'],
['公開', 'published'],
['アーカイブ', 'archived']
], post.status),
{},
{ class: "form-control" } %>
</div>
<div class="form-group">
<%= form.label :featured_image, "アイキャッチ画像", class: "form-label" %>
<%= form.file_field :featured_image, class: "form-control", accept: "image/*" %>
</div>
<div class="form-group">
<%= form.label :tag_names, "タグ(カンマ区切り)", class: "form-label" %>
<%= form.text_field :tag_names,
value: post.tags.pluck(:name).join(', '),
class: "form-control",
placeholder: "rails, programming, tutorial" %>
</div>
<div class="form-actions">
<%= form.submit class: "btn btn-primary" %>
<%= link_to "キャンセル", post_path(post), class: "btn btn-secondary" %>
</div>
<% end %>
2. バリデーションエラーのハンドリング
ruby
# app/controllers/posts_controller.rb
def create
@post = current_user.posts.build(post_params)
@post.tag_names = params[:post][:tag_names]
if @post.save
redirect_to @post, notice: '投稿を作成しました。'
else
# エラー時は新規作成フォームを再表示
render :new, status: :unprocessable_entity
end
end
def update
@post.assign_attributes(post_params)
@post.tag_names = params[:post][:tag_names]
if @post.save
redirect_to @post, notice: '投稿を更新しました。'
else
# エラー時は編集フォームを再表示
render :edit, status: :unprocessable_entity
end
end
よくあるアンチパターンと解決方法
1. Fat Controller(太ったコントローラー)
ruby
# 悪い例:ControllerにビジネスロジックやView処理を記述
class PostsController < ApplicationController
def index
@posts = Post.all
# ビジネスロジックをControllerに書いている
@posts = @posts.select do |post|
post.comments.count > 5 &&
post.author.premium_user? &&
post.published_at > 1.month.ago
end
# View処理をControllerに書いている
@posts_html = ""
@posts.each do |post|
@posts_html += "<div class='post'>"
@posts_html += "<h2>#{post.title}</h2>"
@posts_html += "<p>#{post.content[0, 100]}...</p>"
@posts_html += "</div>"
end
end
end
# 良い例:責任を適切に分離
class PostsController < ApplicationController
def index
@posts = Post.popular_posts
end
end
class Post < ApplicationRecord
scope :popular_posts, -> {
joins(:comments, :author)
.where(authors: { premium: true })
.where('posts.published_at > ?', 1.month.ago)
.group('posts.id')
.having('COUNT(comments.id) > 5')
}
end
2. Fat Model(太ったモデル)
ruby
# 悪い例:Modelに様々な責任を詰め込む
class User < ApplicationRecord
def send_welcome_email
UserMailer.welcome_email(self).deliver_now
end
def generate_report
# 複雑なレポート生成処理
end
def sync_with_external_service
# 外部サービスとの同期処理
end
def process_payment
# 決済処理
end
end
# 良い例:サービスオブジェクトで責任を分離
class User < ApplicationRecord
# ユーザーのデータとコアなビジネスロジックのみ
def full_name
"#{first_name} #{last_name}"
end
def premium?
subscription&.active?
end
end
class UserWelcomeService
def self.call(user)
UserMailer.welcome_email(user).deliver_now
end
end
class UserReportService
def initialize(user)
@user = user
end
def generate
# レポート生成処理
end
end
3. View に複雑なロジックを記述
erb
<!-- 悪い例:Viewに複雑な条件分岐やループ処理 -->
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<% if post.author.premium? && post.comments.count > 5 %>
<span class="badge popular">人気投稿</span>
<% elsif post.created_at > 1.week.ago %>
<span class="badge new">新着</span>
<% end %>
<% if post.featured_image.attached? %>
<% if post.featured_image.blob.content_type.start_with?('image/') %>
<%= image_tag post.featured_image.variant(resize_to_limit: [300, 200]) %>
<% end %>
<% end %>
<!-- さらに複雑な条件分岐... -->
</div>
<% end %>
ruby
# 良い例:ヘルパーメソッドで処理を切り出し
module PostsHelper
def post_badge(post)
if post.popular?
content_tag :span, '人気投稿', class: 'badge popular'
elsif post.recent?
content_tag :span, '新着', class: 'badge new'
end
end
def post_featured_image(post)
return unless post.featured_image.attached?
return unless post.featured_image.blob.content_type.start_with?('image/')
image_tag post.featured_image.variant(resize_to_limit: [300, 200]),
alt: post.title
end
end
class Post < ApplicationRecord
def popular?
author.premium? && comments.count > 5
end
def recent?
created_at > 1.week.ago
end
end
erb
<!-- 良い例:シンプルなView -->
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<%= post_badge(post) %>
<%= post_featured_image(post) %>
</div>
<% end %>
まとめ
MVCアーキテクチャは、Railsアプリケーションの基礎となる重要な設計パターンです。適切な責任分離を行うことで、以下のメリットが得られます:
主要なポイント:
- Model: データの管理とビジネスロジック
- View: ユーザーインターフェースの表示
- Controller: ユーザー入力の処理と制御
ベストプラクティス:
- 各層の責任を明確に分離する
- Fat ControllerやFat Modelを避ける
- サービスオブジェクトで複雑なビジネスロジックを分離
- ヘルパーメソッドでView処理を整理
- 適切なバリデーションとエラーハンドリング
MVCアーキテクチャを正しく理解し実装することで、保守性が高く拡張しやすいRailsアプリケーションを構築できます。