55
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MVCパターン再考

Last updated at Posted at 2019-03-28

背景

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間の直接的な通知の仕組みを示している。

  1. Modelに対して変更があったときに情報を受け取りたいViewを登録する
  2. 変更されたら登録されたすべての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のことを指す

基本的な処理の流れ

  1. クライアントからのリクエスト(URL)
  2. リクエストを通知
  3. 結果を返す
  4. 結果を反映させる
  5. レスポンス

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点である。

  1. 制御の流れ
    • Push-MVC: Controller → Model → View
    • Pull-MVC: View → Model(Controllerの役割が小さい)
  2. データの受け渡し
    • Push-MVC: Controller経由で明示的にViewにデータを渡す(@tasks
    • Pull-MVC: ViewがModelから直接必要なデータを取得する
  3. 使い分けの指針
    • Push-MVC: 単一の画面で完結する場合に適している
    • Pull-MVC: 1つの画面で複数のデータを表示する場合に適している

MVCのメリット

  1. ViewとModelでの並行開発が可能となる
    • ViewとModelが分離されており、それぞれの変更の影響を受けないため
    • フロントエンドエンジニアとバックエンドエンジニアの分業が容易になる
  2. 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
    
  3. 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

上記コードから見える課題

  1. ViewとControllerの密結合
    • 配送日の選択状態によってViewの表示を変える必要がある
    • その制御がJavaScriptで行われるため、ViewとControllerの分離が難しい
    • UIの複雑さに応じて、ViewとControllerの結合が強くなり、それぞれが単独で交換可能ではなくなる
  2. ビジネスロジックとUIの依存
    • 配送予定日の計算(Model)と表示方法(View)が密接に関連している
    • UIの変更がModelの変更を必要とする可能性がある
  3. 状態管理の複雑さ
    • 配送日指定の有無という状態を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(従来のアプローチ)の課題

  1. ViewとControllerの結合が強い
  2. ビジネスロジックとUIロジックの混在
  3. 状態管理が複雑
  4. テストの難しさ

捉え方2(分離アプローチ)のメリット

  1. 責任の明確な分離
    • クライアントサイド: UIに関する責任
    • サーバーサイド: ビジネスロジックに関する責任
  2. 開発の並行性向上
    • フロントエンドチームとバックエンドチームが独立して開発可能
    • APIインターフェースさえ決めれば、実装の詳細は各チームに委ねられる
  3. テスタビリティの向上
    • クライアントサイド: UIのテストに集中
    • サーバーサイド: ビジネスロジックのテストに集中
  4. スケーラビリティの向上
    • APIを介した疎結合により、各層の独立したスケーリングが可能
    • 将来的なマイクロサービス化への対応が容易
  5. 保守性の向上
    • 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パターンだよ」とか

参考サイト

[宣伝]Udemyの自作教材

  • 2021年7月にwywy合同会社という会社を起業しました
  • Qiita記事をキッカケに知名度を少しでも上げたいので、以下自作のUdemyを宣伝させていただければです

■ Udemy

  • GoogleアカウントとWebブラウザがあれば開発準備OK!これからプログラミングを始める方も取り組みやすいように「40のスキル」を選定。受講後、Googleアプリ連携による業務効率化ができるようになります
  • 2022年6月時点で、約1500人の受講生を獲得し、UdemyBusinessに選定されてます。

55
53
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?