Help us understand the problem. What is going on with this article?

2対のMVC (Double MVC)

More than 3 years have passed since last update.

2対のMVC (Double MVC)

by ogomr
1 / 44

FluxSmalltalk の MVC を実装したフレームワークと言われています。
MVC の流れでは GUI で利用される Smalltalk の MVC が原点です。
また、MVC の歴史では Web で利用される サーバーサイド の MVC(Model 2) が登場します。

この GUI-MVC(クライアントサイド) と Web-MVC(サーバーサイド) の実装を個別に説明して
さらに 2つの MVC を コラボレーション するまでを紹介します。


Agenda

  1. GUI-MVC は Smalltalk の MVC を React と Flux(簡易) で説明
  2. Web-MVC は Rails の MVC を API only apps で説明
  3. GUI-MVC と Web-MVC の接続を client-server(C/S) モデル で説明
  4. GUI-MVC と Web-MVC の依存を Action Cable で説明

(クラス図やコンポーネント図などの構造図は PlantUML を利用しています。 )


Smalltalk

Smalltalk の MVC は情報(Model), 表現方法(View), 制御(Controller)の3つに責務を分離する設計方針です。
また、Observer Pattern が認識される数十年前から Observer が実装されていました。


Observer Pattern

Observer Pattern観察対象(Subject) の状態の変化を 観察者(Observer) に通知をする仕組みです。
Observer Pattern の別名は DependentsPublish-Subscribe と呼ばれます。

observer

@startuml

class Observer {
  +update()
}

class ConcreteObserver {
  +update()
}

class Subject {
  state
  observers
  +addObserver(observer)
  +notifyObservers()
  +getState()
}

class ConcreteSubject {
  +action()
}

Observer <|.. ConcreteObserver
Subject <|-- ConcreteSubject
Observer <-o Subject

@enduml

GUI-MVC

GUI-MVC は C -> M -> V と Flow が一方向です。 Observer Pattern で実装されています。

  • Controller が Model のメッセージを実行して Model を更新します。
  • Model は 状態の変更を View に通知します。
  • View はモデルの状態を取得して再表示します。

mvc1

@startuml

package "GUI-MVC" {
  boundary View
  control Controller
  entity Model

  [Browser] ...> Controller : 1.Key Down

  Controller ...> Model : 2.Update
  View ...> Model : 4.Get Data
  View <... Model : 3.Notify

  [Browser] <... View : 5.Display
}

@enduml

CounterView

これから Redux のサンプルにもあるカウンターを作成します。
ユーザの入力で数値の増加や減少を表示するシンプルなアプリケーションです。

CounterView は Smalltalk の MVC を擬似的に JavaScript で実装しました。
Smalltalk の後継の Squeak のサンプルコードを参考にしています。


Example

  • Frontend Boilerplate を利用するか個別にライブラリをインストールをして環境を作成します。
$ mkdir react-counter-view-example
$ cd react-counter-view-example
$ npm init --force
$ npm install webpack webpack-dev-server --save-dev
$ npm install babel-core babel-loader --save-dev
$ npm install babel-preset-react babel-preset-es2015 --save-dev
$ npm install react react-hot-loader react-dom --save-dev
$ tree
.
├── app
│   ├── controllers
│   │   └── CounterKeyboardController.js
│   ├── models
│   │   ├── Counter.js
│   │   └── Subject.js
│   ├── views
│   │   └── CounterTextView.js
│   └── index.js
├── .babelrc
├── index.html
├── package.json
└── webpack.config.js

Model

Subject
  • Subject クラスには状態と Observer のリストが保持されます。
  • Observer を追加するメソッドと通知するメソッドが実装されます。
  • 状態を取得するメソッドが実装されます。

(簡易な Flux ですが Redux のコードを参考にしています。)

app/models/Subject.js
class Subject {
  constructor() {
    this.state = undefined
    this.observers = []
  }

  addObserver(observer) {
    this.observers.push(observer)
  }

  notifyObservers() {
    this.observers.forEach(observer => observer())
  }

  getState() {
    return this.state
  }
}

export default Subject

ConcreteSubject
  • Subject クラスを継承して、ConcreteSubject クラスを作成します。
  • Counter クラスに Model の状態を変更する action メソッドが実装されます。
app/models/Counter.js
import Subject from './Subject'

class Counter extends Subject {
  constructor() {
    super()
    this.state = 0
  }

  action(type) {
    switch (type) {
    case 'increase':
      this.state += 1
      break
    case 'decrease':
      this.state -= 1
      break
    }

    this.notifyObservers()
  }
}

export default Counter

Controller

  • キーの入力と Model のアクションを設定します。
  • u(up)のキーで増加して、d(down)のキーで減少する仕様です。
app/controllers/CounterKeyboardController.js
export default function CounterKeyboardController(model) {
  return {
    u: () => model.action('increase'),
    d: () => model.action('decrease')
  }
}

View

  • React の Component クラスを継承して、ConcreteObserver クラスを作成します。
  • componentDidMount メソッドで、キーの入力と Controller を設定します。
  • render メソッドで、表示をする要素の構成を設定します。
app/views/CounterTextView.js
import React, { Component } from 'react'

class CounterTextView extends Component {
  componentDidMount() {
    window.document.addEventListener(
      'keydown',
      this.handleKeyDown.bind(this)
    )
  }

  handleKeyDown(e) {
    this.props.controller[e.key]()
  }

  render() {
    return (
      <div>
        <h1>Counter</h1>
        <p>Key Downed: {this.props.count} times</p>
        <div>Increase in the key of u (up)</div>
        <div>Decrease in the key of d (down)</div>
      </div>
    )
  }
}

export default CounterTextView

Container

  • MVC を結びつけます。Model を Controller に設定します。
  • View に Controller と Model の状態の取得を設定します。
  • View を Observer に追加します。
app/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import Counter from './models/Counter'
import CounterTextView from './views/CounterTextView'
import CounterKeyboardController from './controllers/CounterKeyboardController'

const model = new Counter()
const controller = CounterKeyboardController(model)

function render() {
  ReactDOM.render(
    <CounterTextView
      controller={controller}
      count={model.getState()}
    />,
    document.getElementById('root')
  )
}

render()
model.addObserver(render)

Demo

counter_view

サンプルで分かるように MVC は Model, View, Controller が疎結合になり、責務の分離が実現できます。


Web-MVC

Web の MVC も情報(Model), 表現方法(View), 制御(Controller)の3つに責務を分離する設計方針です。
しかし、GUI と Web はアプリケーションのドメインが違うので MVC の役割は同じではありません。
もちろん Model と View に Observer Pattern は実装されていません。

Web-MVC は C -> M -> C -> V -> C と Flow に Controller が何度も登場します。

  • Controller が Request を受けて Model を操作してデータを取得します。
  • Controller が View に Model のデータをセットして、コンテンツを作成します。
  • Controller が Response を返します。

mvc2

@startuml

package "Web-MVC" {
  boundary View
  control Controller
  entity Model

  [Browser] <... Controller : 6.Response
  [Browser] ...> Controller : 1.Request

  Controller ...> Model : 2.Get Data
  Controller ...> Model : 3.Set Data

  Controller <... View : 5.Render
  Controller ...> View : 4.Set Data
}

@enduml

CounterViewApi

CounterViewApi は Ruby on Rails の API only apps を利用して実装しました。
CounterView と同じくカウンターのシンプルなアプリケーションです。

  • rails new コマンドに --api オプションで API だけの Web アプリケーションが作成できます。
  • rails generate scaffold コマンドで counter の MVC が作成できます。

Example

$ rails new counter-view-api --api
$ mv counter-view-api rails-counter-view-example
$ cd rails-counter-view-example
$ edit Gemfile
$ rails generate scaffold counter state:integer
$ tree
.
├── app
│   ├── controllers
│   │   ├── application_controller.rb
│   │   └── counters_controller.rb
│   ├── models
│   │   ├── application_record.rb
│   │   └── counter.rb
│   └── views
│       └── counters
│             └── show.json.jbuilder
├── bin
├── config
├── db
├── lib
├── public
├── config.ru
├── Gemfile
└── Rakefile

Model

  • ApplicationRecord クラスを継承して、Counter クラスを作成します。
  • カンターの状態を保持し、増加と減少のメソッドを実装します。
app/models/counter.rb
class Counter < ApplicationRecord
  def initialize
    super
    self.state = 0
  end

  def increase
    self.state = self.state + 1
  end

  def decrease
    self.state = self.state - 1
  end
end

Controller

  • ApplicationController クラスを継承して、CounterController クラスを作成します。
  • カンターの状態を表示するメソッドと増加と減少のメソッドを実装します。

(Rails は Controller のメソッドをアクションと呼びます。)

app/controllers/counters_controller.rb
class CountersController < ApplicationController
  before_action :set_counter

  def show
  end

  def create
    @counter.increase
    @counter.save!
  end

  def destroy
    @counter.decrease
    @counter.save!
  end

  private
    def set_counter
      @counter = Counter.last || Counter.new
    end
end

View

  • Model のデータを View のテンプレートに設定します。
app/views/counters/show.json.jbuilder
json.extract! @counter, :state

Router

  • URL の ルーティング を設定します。
  • コントローラーとアクションを結びつけます。
config/routes.rb
Rails.application.routes.draw do
  resource :counter, only: [:show, :create, :destroy]
end

Demo

  • カウンターを表示する curl のコマンドです。
$ curl -X GET http://localhost:3000/counter
{"state":8}
  • カウンターを増加するコマンドです。
$ curl -X POST http://localhost:3000/counter
  • カウンターを減少するコマンドです。
$ curl -X DELETE http://localhost:3000/counter

REST API なので エンドポイントは同じでも、メソッドで振る舞いが変わります。


GUI-MVCとWeb-MVCの接続

Web も Web-browser と Web-server の client-server(C/S) なのですが
GUI-MVC(client) と Web-MVC(server) を結びつけてデータを連携します。

C/S は プレゼンテーション層(ユーザインターフェース)、アプリケーション層(ビジネスロジック)、
データ層(データベース) で3階層モデルと呼ばれます。

cs

@startuml

package "GUI-MVC" {
  boundary View
  control Controller
  entity Model

  [Browser] ...> Controller : 1.Key Down

  Controller ...> Model : 2.Update
  View ...> Model : 10.Get Data
  View <... Model : 9.Notify

  [Browser] <... View : 11.Display
}

package "Web-MVC" {
  boundary View2
  control Controller2
  entity Model2

  Model <.. Controller2 : 8.Response
  Model ..> Controller2 : 3.Request

  Controller2 ...> Model2 : 5.Set Data
  Controller2 ...> Model2 : 4.Get Data

  Controller2 <... View2 : 7.Render
  Controller2 ...> View2 : 6.Set Data
}

@enduml

GUI-MVC(client)

  • action メソッドに fetch で CounterViewApi と結びつけます。

Model

app/models/Counter.js
import Subject from './Subject'
import fetch from 'isomorphic-fetch'

const endpoint = 'http://localhost:3000/counter'

class Counter extends Subject {
  constructor() {
    super()
    this.state = 0
  }

  action(type) {
    switch (type) {
    case 'show':
      fetch(endpoint, {headers: {accept: 'application/json'}})
        .then(response => response.json())
        .then(json => {
          this.state = json.state
          this.notifyObservers()
        })
      break
    case 'increase':
      fetch(endpoint, {method: 'post'})
        .then(response => this.action('show'))
      break
    case 'decrease':
      fetch(endpoint, {method: 'delete'})
        .then(response => this.action('show'))
      break
    }
  }
}

export default Counter

Container

  • View を Observer に追加した後に、表示のアクションを実施します。
app/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import Counter from './models/Counter'
import CounterTextView from './views/CounterTextView'
import CounterKeyboardController from './controllers/CounterKeyboardController'

const model = new Counter()
const controller = CounterKeyboardController(model)

function render() {
  ReactDOM.render(
    <CounterTextView
      controller={controller}
      count={model.getState()}
    />,
    document.getElementById('root')
  )
}

render()
model.addObserver(render)
model.action('show')

Web-MVC(server)

  • client が No Access-Control-Allow-Origin を表示するので CORS に対応します。
  • Rails 5 から gem 'rack-cors' が標準で設定されているので CORS の対応が簡単です。
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Demo

counter_view

ドメイン駆動設計 では レイヤアーキテクチャ で責務に応じた4つの層が説明されています。
そのプレゼンテーション層とアプリケーション層で MVC を利用することができました。


GUI-MVCとWeb-MVCの依存

client-server(C/S) にも Observer Pattern を実装することができます。
GUI-MVC(client) と Web-MVC(server) を依存させて状態変更を通知します。

pubsub

@startuml

package "GUI-MVC" {
  boundary View
  control Controller
  entity Model

  [Browser] ...> Controller : 1.Key Down

  Controller ...> Model : 2.Update
  View ...> Model : 11.Get Data
  View <... Model : 10.Notify

  [Browser] <... View : 12.Display
}

package "Web-MVC" {
  boundary View2
  control Controller2
  entity Model2

  Model <.. Controller2 : 8.Response
  Model <.. Controller2 : 9.Notify
  Model ..> Controller2 : 3.Request

  Controller2 ...> Model2 : 5.Set Data
  Controller2 ...> Model2 : 4.Get Data

  Controller2 <... View2 : 7.Render
  Controller2 ...> View2 : 6.Set Data
}

@enduml

GUI-MVC(subscribe)

  • ActionCable(WebSocket) で CounterViewApi と結びつけます。
  • Subscribe を追加するメソッドが実装されます。

Model

app/models/Counter.js
import Subject from './Subject'
import fetch from 'isomorphic-fetch'
import ActionCable from 'actioncable'

const endpoint = 'http://localhost:3000/counter'
const cable = ActionCable.createConsumer('ws://localhost:3000/cable')

class Counter extends Subject {
  constructor() {
    super()
    this.state = 0
  }

  action(type) {
    switch (type) {
    case 'show':
      fetch(endpoint, {headers: {accept: 'application/json'}})
        .then(response => response.json())
        .then(json => {
          this.state = json.state
          this.notifyObservers()
        })
      break
    case 'increase':
      fetch(endpoint, {method: 'post'})
        .then(response => this.action('show'))
      break
    case 'decrease':
      fetch(endpoint, {method: 'delete'})
        .then(response => this.action('show'))
      break
    }
  }

  addSubscribe(channel) {
    cable.subscriptions.create(
      channel,
      {
        connected() {
          this.handleShow()
        },
        received() {
          this.handleShow()
        },
        handleShow: () => this.action('show')
      }
    )
  }
}

export default Counter

Container

  • View を Observer に追加した後に Subscribe を追加します。
app/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import Counter from './models/Counter'
import CounterTextView from './views/CounterTextView'
import CounterKeyboardController from './controllers/CounterKeyboardController'

const model = new Counter()
const controller = CounterKeyboardController(model)

function render() {
  ReactDOM.render(
    <CounterTextView
      controller={controller}
      count={model.getState()}
    />,
    document.getElementById('root')
  )
}

render()
model.addObserver(render)
model.addSubscribe('CounterChannel')

Web-MVC(publish)

  • Rails Action Cable(WebSocket) で CounterView と結びつけます。
  • rails generate channel コマンドで counter の ActionCable が作成できます。

Example

$ rails generate channel counter
$ tree
.
├── app
│   ├── channels
│   │   ├── application_cable
│   │   │   ├── channel.rb
│   │   │   └── connection.rb
│   │   └── counter_channel.rb
│   ├── controllers
│   ├── models
│   └── views

Channel

  • subscribedcounter を設定します。
app/channels/counter_channel.rb
class CounterChannel < ApplicationCable::Channel
  def subscribed
    stream_from 'counter'
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

Controller

  • 状態を変更した後に ActionCable.server.broadcastcounter を設定します。
app/controllers/counters_controller.rb
class CountersController < ApplicationController
  before_action :set_counter

  def show
  end

  def create
    @counter.increase
    @counter.save!
    ActionCable.server.broadcast 'counter', nil
  end

  def destroy
    @counter.decrease
    @counter.save!
    ActionCable.server.broadcast 'counter', nil
  end

  private
    def set_counter
      @counter = Counter.last || Counter.new
    end
end
  • 接続を許可する エンドポイント を設定します。
config/application.rb
module CounterView
  class Application < Rails::Application
    config.api_only = true
    config.action_cable.allowed_request_origins = ['http://localhost:4000']
  end
end

Demo

counter_view

counter_view

実践ドメイン駆動設計 では ドメインイベント で出版-購読型モデルが説明されています。
メッセージングミドルウェアには RabbitMQ などの AMQP を利用することを勧められています。


Async

  • Rails は Active Job を利用すると Publish を非同期にすることができます。
  • rails generate channel コマンドで counter の ActionCable が作成できます。

Example

$ rails generate job counter
$ tree
.
├── app
│   ├── channels
│   ├── controllers
│   ├── jobs
│   │   ├── application_job.rb
│   │   └── counter_job.rb
│   ├── models
│   └── views

Job

  • ActionCable.server.broadcastcounter を設定します。
app/jobs/counter_job.rb
class CounterJob < ApplicationJob
  queue_as :default

  def perform(*args)
    ActionCable.server.broadcast 'counter', nil
  end
end

Controller

  • 状態を変更した後に CounterJob.perform_later を設定します。
app/controllers/counters_controller.rb
class CountersController < ApplicationController
  before_action :set_counter

  def show
  end

  def create
    @counter.increase
    @counter.save!
    CounterJob.perform_later
  end

  def destroy
    @counter.decrease
    @counter.save!
    CounterJob.perform_later
  end

  private
    def set_counter
      @counter = Counter.last || Counter.new
    end
end

Demo

counter_view

Active Job は ミドルウェアに Redis を利用することができます。


Redux

Redux は Flux の流れなので最初に説明をした GUI-MVC の影響を受けています。
さらに Redux は CQRSEvent Sourcing の影響を受けて 三原則 に反映されています。

redux

@startuml

package "Redux" {
  boundary View
  entity Store
  control Action
  control Reducer

  [Browser] ..> Action

  Action ..> Store
  Store <.. Reducer
  Store ..> Reducer
  View <.. Store

  [Browser] <.. View
}

@enduml

アプリケーションソフトウェア は要求によって複雑になります。
その複雑には様々なアーキテクチャを駆使して立ち向かいましょう。

ogomr
RubyKansai, naniwa.rb, DDD.rb, CoderDojo Osakasayama/Hommachi, OSS Gate Osaka, Rails Girls Osaka, Open Source Software Developers Osaka, テクノ図工部
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away