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

Pub/SubパターンでMVCを実装してみた

More than 3 years have passed since last update.

概要

最近、Fluxやreduxなどが登場してますが、現在の仕事は大規模なアプリケーション開発ではなくて、小規模なものをサクサク作っていくということが多いので、もっと小規模なアプリ開発に向いてるアーキテクチャはないものかと調べていたところ、MVC with Pub/Subに戻ったというお話です。たぶん、よく知っている人にとっては普通のことだと思うのですが、「あ、なんだ、そうなんだ」という気持ちを記録しておきます。

Fluxやreduxでは何がいけないのか?

Fluxやreduxは、本当によくできている素晴らしいアーキテクチャです。ただ、自分にとっては大きすぎるということです。
2,3人日程度でサクッと作りたいアプリケーション開発では、登場する役割が多すぎると感じました。例えば、FluxだとStore, Action, Action Creator, Dispatcher, Viewと5つもあります。

MVCへの回帰とよくある問題の回避

そこで、お手軽なデザインパターンであるMVCをうまく問題を回避しながらやってみようと、少し考えてみました。

MVCを実装する上でよく知られた問題

1. Flux/reduxと違って双方向になる問題
MVCの概念としては、完全に一方向のフローですが、実装を間違えるとやらかしてしまうのではないかと思ってます。
(ご参考情報:Fluxは本質的に何を解決しようとしたのだろうか?

2. Controllerが肥大する問題
こちらもMVCの概念としてControllerが肥大する傾向にあるわけではなく、Controllerの役割に応じた実装を行うことで問題を回避できるのだと思います。
(ご参考情報:「MVCの勘違い」について、もう一度考えてみる

MVCの各パートの役割

MVCの各役割を以下のように捉えてます。

Model

  • アプリケーションのデータと状態の保有及び変更を担う。
    • データだけ保有しようとするとハマる。
    • ボタンの有効・無効のような状態も保有しておく。
  • 変更があった場合には、変更の内容に応じたイベントを発行する。

View

  • Modelが管理するアプリケーションのデータと状態にもとづいて描画する。
  • ユーザーが何かをしたら、その内容に応じたイベントを発行する。
    • Webに限ればこの部分はブラウザがやってくれる。

Controller
ViewとModelで発生したイベントのイベントハンドラーの登録。

  • Viewで発生したイベントに応じて、Modelの変更メソッドを呼ぶイベントハンドラーを登録
  • Modelで発生したイベントに応じて、Viewの描画メソッドを呼ぶイベントハンドラーを登録

MVC実装上のポイント

上記の概念を実装する上で以下のようなことを押さえる必要があると思います。
ここでは、例として超簡易的なTodoアプリを実装することを考えてみます。

また、Githubにソースコードを公開しておきました。

Model

  • 保有しているデータや状態はプライベートな変数で実装する。
  • 保有しているデータや状態を変更するためのメソッドを用意する。
  • 保有しているデータや状態を変更したらPub/Subパターンを使ってイベントを発行する。
app.js
var App = (function(){
    var state = {
        filter: 'undone',
        todos: []
    }
    return {
        event: {
            loading:       'loading',
            load_done:     'load_complete',
            change_filter: 'change_filter'
        },

        getState: function(){
            return $.extend(true, {}, state);
        },

        on: function() {
            $(this).on.apply($(this), arguments);
        },

        off: function() {
            $(this).off.apply($(this), arguments);
        },

        trigger: function() {
            $(this).trigger.apply($(this), arguments);
        },

        loadTodos: function(){
            var _this = this;
            this.trigger(this.event.loading);
            $.ajax({
                type: "GET",
                url: "https://jsonplaceholder.typicode.com/todos",
                dataType: "JSON",
                success: function(data){
                    state.todos = data.filter(function(todo){
                        return todo.userId == 1;
                    })
                    _this.trigger(_this.event.load_done);
                }
            })
        },

        setFilter: function(filter){
            state.filter = filter;
            this.trigger(this.event.change_filter);
       }
    }
})();

View

  • 描画だけを行う。
  • GUIの部品を部位ごとに分けて考える。
    • この例では、Todoのリスト todosTodoのフィルタ filterの2つに分けている
  • イベントハンドリングしない。($(document).on とかしない)
view.js
var View = (function(){
    return {
        filter: {
            render: function(state){
                var filter = state.filter;
                $('.filter .btn').removeClass('btn-primary');
                $('.filter input[value=' + filter + ']').parent().addClass('btn-primary');
            }
        },
        todos: {
            render: function(state){
                var todos = state.todos.filter(function(todo){
                    return state.filter == "all" || todo.completed == (state.filter == "done");
                });

                $('.todo-list').children().remove();
                todos.forEach(function(todo){
                    var title =  todo.title;
                    var checked = (todo.completed) ? "check" : "unchecked";

                    $('.todo-list').append(
                        '<li class="list-group-item">' +
                            '<button type="button" class="btn btn-default task-button">' +
                                '<span class="glyphicon glyphicon-' + checked + '"></span>' +
                            '</button>' +
                            '<span style="margin-left:10px;">' + title + '</span>' +
                        '</li>'
                    );
                });
            }
        }
    }
})();

Controller

  • イベントハンドラーの登録だけ。(onするだけ)
  • 描画しない。代わりにViewに描画させる。
  • ここで描画処理をするとViewの責任とControllerの責任が曖昧になる。
controller.js
//
// Modelのイベントハンドラー
//

// Modelがtodoデータを読み込み終わった
App.on(App.event.load_done, function(event){
    View.todos.render(App.getState());
})

// Modelがtodoのフィルタ条件を変更した
App.on(App.event.change_filter, function(event){
    View.filter.render(App.getState());
    View.todos.render(App.getState());
})

//
// Viewのイベントハンドラー
//

// フィルタ選択ラジオボタンをクリックして変更した
$(document).on('change', '.filter', function(event){
    var filter = event.target.value;
    App.setFilter(filter);
});

// HTMLドキュメントが読み込み終わった(初期処理)
$(document).ready(function(event){
    App.loadTodos();
});

まとめ

MVCの各パートの責任範囲を明確にして、それぞれのパートの連携をPub/Subパターンで実装することで、「何が起きたのか(event)」と「今、どうなってるか(state)」だけを伝え、「どうして欲しいのか」を伝えないようにしています。
ここでうっかり、「どうしてほしいか」を伝えようとして、「Viewにこんな感じの絵を書いて...」的な処理をControllerに書いてしまうと、本来、Viewに任せることをControllerが奪ってしまうことになります。

PKで選手に任せるべきところで、監督がボールを蹴るようなものです。

ただ、上記の例のように限定された理想的な条件下ではうまくいくことも、もう少し現実の複雑さが入り込んでくると適用が困難になるかもしれないです。「こんな時はどうするんすかね?」というコメントを頂けるとありがたいです。

会社の組織でも似たような問題がありませんかね?

例えば、組織の役割の定義が曖昧だと、2つの部門で同じことをしていたり、あるいはどちらかの部門に仕事が偏ったりと色々な問題が起きたりします。
また、組織が大きくなると役割が細分化される傾向があると思いますが、これは、MVCでは大きすぎて捉えられないシステムを、Flux/reduxのようにより細分化された役割構造で把握しようとすることとに似ている気がします。

ここでの問題は細分化そのものの善し悪しではなくて、粒度の問題だと思います。大きすぎるとなんだか分からないが、小さすぎると意味が失われてしまうという、バランスをどう取るのかという問題に帰着するのだと思います。

何れにしても「何をするのか」と「何をしないのか」をきちんと定義する必要があるのは、どこの世界でも同じなんだなと思った次第です。

maloninc
IT部 はじめました。
http://maloninc.com/
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