Flux は Smalltalk の MVC を実装したフレームワークと言われています。
MVC の流れでは GUI で利用される Smalltalk の MVC が原点です。
また、MVC の歴史では Web で利用される サーバーサイド の MVC(Model 2) が登場します。
この GUI-MVC(クライアントサイド) と Web-MVC(サーバーサイド) の実装を個別に説明して
さらに 2つの MVC を コラボレーション するまでを紹介します。
Agenda
- GUI-MVC は Smalltalk の MVC を React と Flux(簡易) で説明
- Web-MVC は Rails の MVC を API only apps で説明
- GUI-MVC と Web-MVC の接続を client-server(C/S) モデル で説明
- 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 の別名は Dependents や Publish-Subscribe と呼ばれます。
@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 はモデルの状態を取得して再表示します。
@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 のコードを参考にしています。)
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 メソッドが実装されます。
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)のキーで減少する仕様です。
export default function CounterKeyboardController(model) {
return {
u: () => model.action('increase'),
d: () => model.action('decrease')
}
}
View
- React の Component クラスを継承して、ConcreteObserver クラスを作成します。
- componentDidMount メソッドで、キーの入力と Controller を設定します。
- render メソッドで、表示をする要素の構成を設定します。
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 に追加します。
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
サンプルで分かるように 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 を返します。
@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 クラスを作成します。
- カンターの状態を保持し、増加と減少のメソッドを実装します。
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 のメソッドをアクションと呼びます。)
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 のテンプレートに設定します。
json.extract! @counter, :state
Router
- URL の ルーティング を設定します。
- コントローラーとアクションを結びつけます。
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階層モデルと呼ばれます。
@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
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 に追加した後に、表示のアクションを実施します。
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 の対応が簡単です。
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
ドメイン駆動設計 では レイヤアーキテクチャ で責務に応じた4つの層が説明されています。
そのプレゼンテーション層とアプリケーション層で MVC を利用することができました。
GUI-MVCとWeb-MVCの依存
client-server(C/S) にも Observer Pattern を実装することができます。
GUI-MVC(client) と Web-MVC(server) を依存させて状態変更を通知します。
@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
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 を追加します。
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
-
subscribed
にcounter
を設定します。
class CounterChannel < ApplicationCable::Channel
def subscribed
stream_from 'counter'
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
Controller
- 状態を変更した後に
ActionCable.server.broadcast
でcounter
を設定します。
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
- 接続を許可する エンドポイント を設定します。
module CounterView
class Application < Rails::Application
config.api_only = true
config.action_cable.allowed_request_origins = ['http://localhost:4000']
end
end
Demo
実践ドメイン駆動設計 では ドメインイベント で出版-購読型モデルが説明されています。
メッセージングミドルウェアには 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.broadcast
のcounter
を設定します。
class CounterJob < ApplicationJob
queue_as :default
def perform(*args)
ActionCable.server.broadcast 'counter', nil
end
end
Controller
- 状態を変更した後に
CounterJob.perform_later
を設定します。
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
Active Job は ミドルウェアに Redis を利用することができます。
Redux
Redux は Flux の流れなので最初に説明をした GUI-MVC の影響を受けています。
さらに Redux は CQRS や Event Sourcing の影響を受けて 三原則 に反映されています。
@startuml
package "Redux" {
boundary View
entity Store
control Action
control Reducer
[Browser] ..> Action
Action ..> Store
Store <.. Reducer
Store ..> Reducer
View <.. Store
[Browser] <.. View
}
@enduml
アプリケーションソフトウェア は要求によって複雑になります。
その複雑には様々なアーキテクチャを駆使して立ち向かいましょう。