背景
Java, Groovy, C++, C#などいろいろな言語を使ってアプリケーションを開発するなかで、MVCパターンを常に意識して開発してきた。
自分的な解釈だと、以下のような大雑把の分類である。
-
Model
- ビジネスロジック、データベース処理、データオブジェクトなど
-
View
- 画面出力
-
Controller
- ViewとModelの仲介役、ユーザーの入力をModelに渡すなど
しかし、あるセミナーで「ネイティブアプリでは、MVCは合わない」という話を聞き、改めて、MVCがどこまで適用できるものなのか、そもそもMVCってなんだろう?と再考したくなった。というわけで、MVCパターンを再考してみる。
MVCと一言で言っても
改めて調べて分かったのだが、一口にMVCといっても、以下の分類であることがわかった。
- MVC1(Smalltalk MVC)
- MVC2
- Pull-MVC
- Push-MVC
MVC1(Smalltalk MVCとも呼ぶ)
- 1979年 SmalltalkのGUI設計で用いられた概念
- デザインパターンのObserverパターンが具体例として有名
MVC1の実装例(JavaScript)
以下のコード例は、MVC1の特徴である Observer パターンを使用したModel-View間の直接的な通知の仕組みを示している。
- Modelに対して変更があったときに情報を受け取りたいViewを登録する
- 変更されたら登録されたすべてのViewに対して通知を行う
// MVC1の実装例
// Model
class TodoModel {
constructor() {
this.tasks = []
this.observers = [] // Observerパターンの実装
}
// オブザーバーの登録
addObserver(observer) {
this.observers.push(observer)
}
// タスクの追加とオブザーバーへの通知
addTask(task) {
this.tasks.push(task)
this.notifyObservers() // 変更を通知
}
// オブザーバーへの通知
notifyObservers() {
this.observers.forEach(observer => observer.update(this.tasks))
}
}
// View
class TodoView {
constructor(model) {
this.model = model
this.model.addObserver(this) // ViewをModelに登録
}
// Modelからの更新通知を受け取るメソッド
update(tasks) {
// DOMの更新
const todoList = document.getElementById('todo-list')
todoList.innerHTML = tasks.map(task => `
<li>${task}</li>
`).join('')
}
// HTML作成
render() {
return `
<div>
<input type="text" id="new-task">
<button onclick="controller.addTask()">タスク追加</button>
<ul id="todo-list"></ul>
</div>
`
}
}
// Controller
class TodoController {
constructor(model) {
this.model = model
}
addTask() {
const input = document.getElementById('new-task')
this.model.addTask(input.value)
input.value = ''
}
}
MVC2
- 1998年 JSP仕様のドラフトで提唱された
- MVC1をWebサービスに適応させたものである
- サーバー側からいきなりModelの状態変更が通知されることはHTTPがステートレスなのでできない
- Modelの状態変更は、Controllerを経由してViewへの通知するように修正された
- WebアプリにおいてのMVCとはMVC2のことを指す
基本的な処理の流れ
- クライアントからのリクエスト(URL)
- リクエストを通知
- 結果を返す
- 結果を反映させる
- レスポンス
Push-MVC(MVC2)
- ほとんどのMVCフレームワークは、Push-MVCのアーキテクチャにしたがっている
- このようなフレームワークは、処理を要求するアクションを実行し、次に結果を出力するためにデータを表示のレイヤにプッシュする
- 代表的なフレームワーク:Struts、Django、Ruby on Rails、Spring MVCなど
Push-MVCの実装例(Rails)
- 以下、Railsによる実装例
- Push-MVCでは、Controllerが主導的にModelのデータをViewに渡す
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
# Controllerが能動的にModelからデータを取得し、Viewに渡す
@tasks = Task.all
# View(HTML)にデータをPushして描画
render 'index'
end
end
# app/models/task.rb
class Task < ApplicationRecord
# ビジネスロジックをカプセル化
def self.completed
where(status: 'completed')
end
end
<!-- app/views/tasks/index.html.erb -->
<!-- Controllerから渡された(Pushされた)データを表示 -->
<h1>タスク一覧</h1>
<ul>
<% @tasks.each do |task| %>
<li><%= task.title %></li>
<% end %>
</ul>
Pull-MVC(MVC2)
- Push-MVCに対するアーキテクチャで、「コンポーネント型」とも呼ばれている
- 「コンポーネント型」は表示レイヤから処理を開始し、必要に応じて複数のコントローラからの処理の結果を「プル」する
- 「コンポーネント型」では、複数のControllerが一つのViewに関連付けられる
- 代表的なフレームワーク:Tapestry、Velocityなどがプル型アーキテクチャの例である
Pull-MVC(MVC2)の実装例(Rails)
- 以下、Railsによる実装例
- Pull-MVCでは、Viewが必要に応じてModelからデータを取得する
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
# Controllerの役割を最小限に
def show
render 'dashboard'
end
end
# app/models/task.rb
class Task < ApplicationRecord
# 同じModelでも、Pull-MVCではViewから直接アクセスされる
def self.recent
order(created_at: :desc).limit(5)
end
def self.urgent
where(priority: 'high')
end
end
<!-- app/views/dashboard/show.html.erb -->
<!-- Viewが必要なデータを必要なタイミングでPullする -->
<h1>ダッシュボード</h1>
<div class="recent-tasks">
<h2>最近のタスク</h2>
<ul>
<% Task.recent.each do |task| %>
<li><%= task.title %></li>
<% end %>
</ul>
</div>
<div class="urgent-tasks">
<h2>緊急タスク</h2>
<ul>
<% Task.urgent.each do |task| %>
<li><%= task.title %></li>
<% end %>
</ul>
</div>
Push-MVCとPull-MVCの主な違い
Push-MVCとPull-MVCの主な違いは以下の3点である。
- 制御の流れ
- Push-MVC: Controller → Model → View
- Pull-MVC: View → Model(Controllerの役割が小さい)
- データの受け渡し
- Push-MVC: Controller経由で明示的にViewにデータを渡す(
@tasks
) - Pull-MVC: ViewがModelから直接必要なデータを取得する
- Push-MVC: Controller経由で明示的にViewにデータを渡す(
- 使い分けの指針
- Push-MVC: 単一の画面で完結する場合に適している
- Pull-MVC: 1つの画面で複数のデータを表示する場合に適している
MVCのメリット
- ViewとModelでの並行開発が可能となる
- ViewとModelが分離されており、それぞれの変更の影響を受けないため
- フロントエンドエンジニアとバックエンドエンジニアの分業が容易になる
- Modelを機能別に分けることで、テストがしやすく再利用性の高いコンポーネントを作成できる
# 機能別に分かれたModelの例 class Task < ApplicationRecord # ビジネスロジックをModelに集約 belongs_to :user has_many :comments def self.due_today where(due_date: Date.today) end def overdue? due_date < Date.today end def notify_assignee # 担当者への通知ロジック end end
- ViewとModelの相互参照を避けることができるのでコードの見通しが良くなり、影響範囲も限定しやすい
MVCのメリットを踏まえた上で実務で感じた課題
View、Model、Controllerの並行開発がなかなか難しい
以下、ECサイトの配送日指定機能を実装例として、Railsのコードで具体的に説明する
Model
# app/models/delivery.rb
class Delivery < ApplicationRecord
# Modelでは配送に関するビジネスロジックを定義
def calculate_estimated_date
# 在庫状況、配送先、など様々な要因で配送予定日を計算
Date.today + 3.days
end
def available_dates
# 配送可能日の計算ロジック
(Date.today..(Date.today + 7.days)).to_a
end
end
View
<!-- app/views/deliveries/edit.html.erb -->
<div class="delivery-options">
<!-- View側でラジオボタンの状態によって表示を切り替える必要がある -->
<div>
<input type="radio" name="delivery_type" value="estimated" checked>
配送日を指定しない
</div>
<div>
<input type="radio" name="delivery_type" value="custom">
配送日を指定する
</div>
<!-- 配送予定日の表示部分 -->
<div id="estimated-date" class="delivery-date">
配送予定日: <%= @delivery.calculate_estimated_date %>
</div>
<!-- カレンダーの表示部分 -->
<div id="custom-date" class="delivery-date" style="display: none;">
<select id="delivery-calendar">
<% @delivery.available_dates.each do |date| %>
<option value="<%= date %>"><%= date %></option>
<% end %>
</select>
</div>
</div>
<!-- View側で必要な制御のJavaScript -->
<script>
document.querySelectorAll('input[name="delivery_type"]').forEach(radio => {
radio.addEventListener('change', function() {
// View側で表示制御が必要
const estimatedDateDiv = document.getElementById('estimated-date');
const customDateDiv = document.getElementById('custom-date');
if (this.value === 'estimated') {
estimatedDateDiv.style.display = 'block';
customDateDiv.style.display = 'none';
} else {
estimatedDateDiv.style.display = 'none';
customDateDiv.style.display = 'block';
}
});
});
</script>
Controller
# app/controllers/deliveries_controller.rb
class DeliveriesController < ApplicationController
def edit
@delivery = Delivery.find(params[:id])
# ViewとModelの両方に依存する処理が必要
if params[:delivery_type] == 'custom'
# カレンダー表示用のデータ取得
@available_dates = @delivery.available_dates
else
# 配送予定日表示用のデータ取得
@estimated_date = @delivery.calculate_estimated_date
end
end
end
上記コードから見える課題
-
ViewとControllerの密結合
- 配送日の選択状態によってViewの表示を変える必要がある
- その制御がJavaScriptで行われるため、ViewとControllerの分離が難しい
- UIの複雑さに応じて、ViewとControllerの結合が強くなり、それぞれが単独で交換可能ではなくなる
-
ビジネスロジックとUIの依存
- 配送予定日の計算(Model)と表示方法(View)が密接に関連している
- UIの変更がModelの変更を必要とする可能性がある
-
状態管理の複雑さ
- 配送日指定の有無という状態をView、Controller、Model間で共有する必要がある
- これにより、各層の独立性が低下する
MVCという用語の功罪
功
- 「ModelとViewを分離させる」という常識を浸透させたこと
- 知ってしまうと当たり前のように考えてしまうが、MVCを知らない人が最初からModelとViewを分離しようと考えることは難しい
- Viewの部分(html)をデザイナーに依頼、Model部分をエンジニアが開発という分業が可能になった
罪
罪というか、自分の無知が大きいのですが...。
- MVC1とMVC2というのがごっちゃになってコミュニケーションされる傾向があり、モヤっとさせる場面がある
- Javaなどで扱うデザインパターンは、
MVC = MVC1
ですが、Webアプリ入門などで取り上げられるのはMVC = MVC2
という感じで、同じMVCでも文脈によって違う意味で使われている
MVCの捉え方によって違ってくる!
捉え方1 サーバーサイドのみでMVCを考える
- Model: データ重複、ロジック重複を避けるために共通化する。共通化する単位としてビジネスロジックなど
- Controller: ユーザー入力を受け付けて、それに合ったModelを起動する
- Push型: 担当するViewにModelの情報をPushする
- Pull型: (-)ViewがModelからデータをPullする。ControllerはViewと密着する
- View: ControllerからのデータをもとにHTMLを出力する
捉え方2 クライアントサイト、サーバーサイド、それぞれでMVCを考える
クライアントサイドMVC
- Model: サーバーサイドのAPIを利用しながら、そのアプリケーションが扱う領域のデータと手続きを表現する
- View: ユーザーにModelの状態を反映したHTMLを出力する
- Controller: ユーザーの入力を受け取って判断し、Modelを起動する
サーバーサイドMVC
- Model: ビジネスロジック、データベース処理など
- View: json形式等、APIに適したDataFormatでクライアントに返す
- Controller: クライアントのリクエストを受け付けてそれに合ったModelを起動する
「配送日指定機能」のリファクタリング例
配送日指定画面のHTML(index.html)
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>配送日指定</title>
</head>
<body>
<!-- Viewのコンテナ要素 -->
<div id="delivery-container"></div>
<!-- JavaScriptファイルの読み込み -->
<script src="delivery.js"></script>
</body>
</html>
クライアントサイドMVC(delivery.js)
// delivery.js
// Model: クライアントサイドのデータとロジックを管理
class DeliveryModel {
constructor() {
this.deliveryType = 'estimated';
this.estimatedDate = null;
this.availableDates = [];
}
// APIと通信してデータを取得
async fetchDeliveryData() {
if (this.deliveryType === 'estimated') {
const response = await fetch('/api/deliveries/estimated_date');
const data = await response.json();
this.estimatedDate = data.estimated_date;
} else {
const response = await fetch('/api/deliveries/available_dates');
const data = await response.json();
this.availableDates = data.available_dates;
}
}
}
// View: 画面の表示を担当
class DeliveryView {
constructor(model, controller) {
this.model = model;
this.controller = controller;
}
// イベントの設定
setupEvents() {
document.querySelectorAll('input[name="delivery_type"]').forEach(radio => {
radio.addEventListener('change', (e) => {
this.controller.updateDeliveryType(e.target.value);
});
});
}
// 画面の更新
render() {
const container = document.querySelector('#delivery-container');
container.innerHTML = `
<div class="delivery-options">
<div>
<input type="radio" name="delivery_type" value="estimated"
${this.model.deliveryType === 'estimated' ? 'checked' : ''}>
配送日を指定しない
</div>
<div>
<input type="radio" name="delivery_type" value="custom"
${this.model.deliveryType === 'custom' ? 'checked' : ''}>
配送日を指定する
</div>
${this.model.deliveryType === 'estimated' ?
`<div>配送予定日: ${this.model.estimatedDate}</div>` :
`<div>
<select>
${this.model.availableDates.map(date =>
`<option value="${date}">${date}</option>`
).join('')}
</select>
</div>`
}
</div>
`;
this.setupEvents();
}
}
// Controller: ModelとViewの橋渡し
class DeliveryController {
constructor(model, view) {
this.model = model;
this.view = view;
}
// 初期表示用のメソッド
async initializeDelivery() {
// 初期データの取得
await this.model.fetchDeliveryData();
// 初期表示
this.view.render();
}
async updateDeliveryType(type) {
this.model.deliveryType = type;
await this.model.fetchDeliveryData();
this.view.render();
}
}
// 初期化と実行
document.addEventListener('DOMContentLoaded', () => {
// MVCの各インスタンスを作成
const model = new DeliveryModel();
const view = new DeliveryView(model);
const controller = new DeliveryController(model, view);
// ViewにControllerを設定
view.controller = controller;
// 初期表示
controller.initializeDelivery();
});
サーバーサイドMVC(Rails)
# app/controllers/api/deliveries_controller.rb
class Api::DeliveriesController < ApplicationController
def estimated_date
delivery = Delivery.find(params[:id])
render json: {
estimated_date: delivery.calculate_estimated_date
}
end
def available_dates
delivery = Delivery.find(params[:id])
render json: {
available_dates: delivery.available_dates
}
end
end
# app/models/delivery.rb
class Delivery < ApplicationRecord
def calculate_estimated_date
# 配送予定日の計算ロジック
Date.today + 3.days
end
def available_dates
# 配送可能日の計算ロジック
(Date.today..(Date.today + 7.days)).to_a
end
end
捉え方1と捉え方2の比較
捉え方1(従来のアプローチ)の課題
- ViewとControllerの結合が強い
- ビジネスロジックとUIロジックの混在
- 状態管理が複雑
- テストの難しさ
捉え方2(分離アプローチ)のメリット
-
責任の明確な分離
- クライアントサイド: UIに関する責任
- サーバーサイド: ビジネスロジックに関する責任
-
開発の並行性向上
- フロントエンドチームとバックエンドチームが独立して開発可能
- APIインターフェースさえ決めれば、実装の詳細は各チームに委ねられる
-
テスタビリティの向上
- クライアントサイド: UIのテストに集中
- サーバーサイド: ビジネスロジックのテストに集中
-
スケーラビリティの向上
- APIを介した疎結合により、各層の独立したスケーリングが可能
- 将来的なマイクロサービス化への対応が容易
-
保守性の向上
- UIの変更がバックエンドに影響を与えにくい
- ビジネスロジックの変更がUIに影響を与えにくい
まとめ
- MVCにはMVC1とMVC2がある
- MVC1では、Modelの変化を引き金として、Viewにその変化を通知する。
- MVC2では、サーバー側からModelの状態変更を通知することはHTTPがステートレスなのでできない。したがって、Modelの状態変更は、Controllerを経由してViewへの通知するように修正された
- MVCといったら、WebアプリではMVC2を指す
-
サーバーサイドとクライアントと分割してMVCを考えた方がしっくりくる
- 調査以前は、Viewはブラウザ、ModelとControllerはサーバーとして、サーバーサイドとクライアントサイドを統合して考えていたが、分割して考えた方がMVCをすっきりと受け入れられる気がした
-
Viewはブラウザと捉えずにHTML, json, xmlなど最終的にクライアントに返すフォーマットという広い捉え方にしよう
- そういう捉え方をすることで、API+ネイティブアプリという構成であっても、MVCで捉えることもできる
- 「APIサーバーはMVCパターンだけど、ネイディブ側はDocument-Viewパターンだよ」とか
参考サイト
- http://ynomura.dip.jp/archives/2012/09/mvc1.html
- http://qiita.com/tshinsay/items/5b1724baf32b8b5113c2
- http://cartman0.hatenablog.com/entry/2015/12/17/045635
- http://javazuki.com/articles/mvc-tutorial.html
- http://forza.cocolog-nifty.com/blog/2014/07/mvc2mvc-ffd9.html
[宣伝]Udemyの自作教材
- 2021年7月にwywy合同会社という会社を起業しました
- Qiita記事をキッカケに知名度を少しでも上げたいので、以下自作のUdemyを宣伝させていただければです
■ Udemy
- GoogleアカウントとWebブラウザがあれば開発準備OK!これからプログラミングを始める方も取り組みやすいように「40のスキル」を選定。受講後、Googleアプリ連携による業務効率化ができるようになります
2022年6月時点で、約1500人の受講生を獲得し、UdemyBusinessに選定されてます。