MVPパターンを業務アプリに適用する

業務アプリケーションを開発するにあたって、最低限守るべき事の一つとして「画面とロジック」の分離が挙げられると思う。
簡単そうに思えて、これが意外と難しい。
一人での開発ならば自分だけでやり方を決めてしまえばそんなに難しくはない。しかし、開発要員が増えれば、これが途端に難しくなる。
この原因としては、

  • どこからが「画面」の役割で、どこまでが「ロジック」の役割かの定義が個々人でばらつきがある。
  • 定義を決めても守らない奴がいる(そもそも意味が理解できていない)。
  • 言語自体がへぼくて「画面とロジック」の分離がそもそも(充分に)できない。

なんていうのがある。

ほとんど、「それって開発者としてどうなの?」というレベルに思えるけど、現実の開発ってそんなもんだよね。

いつの時代もこの問題は付きまとうようで、実際色んなデザインパターンが考えられてきた。

知ってるやつでは、

  • MVC(モデル・ビュー・コントローラ、Strutsとか)
  • MVVM(モデル・ビュー・ビューモデル、WPFSilverlight
  • MVP(モデル・ビュー・プレゼンター、後述)
  • ドキュメント・ビュー アーキテクチャMFCのあれ)

なんてのがある。

他にも知らないだけで、いっぱいあるんでしょうね。

まぁ、そんなこんなで今回VB.NETを使って業務アプリを作る事になったんですけど、これがなんか20〜30人で開発するらしいんですよね(実装だけで)。

で、そこで開発サブリーダー的な役割を与えられるわけですけど、アサインする開発者の人たちはほとんど(というか全く).NETとか知らないわけですよ。ありがちな話ですけど。

だもんで、そのまま開発させたら画面のイベントハンドラにすべての処理を書いたりするわけですよ。

それを防ぐために何かしらのアーキテクチャが必要になってくるんですが、それに今回はMVPパターンを採用しようかと考えてます。WinFormアプリケーションなので、それとの相性とかも考えて、これが一番ベターかなと。

MVPパターンを実装するフレームワークとしてPatterns & Practiceの「Smart Client Software Factory」とかいうのがあるんですけど、試しにインストールしてプロジェクトを作ってみたら、山程ソースを生成しやがったので、こりゃ駄目だと思って採用は見送りました。

なので、単純なMVPパターンを実装するフレームワークを作ることにしました。

参考にしたのはこの辺

MVPパターンの特徴は、プレゼンターがビューとモデルの関連付けを行い、ビューとモデルはお互いに完全に独立しているという事。

このフレームワークで目指すところは、

  1. ビューやプレゼンター、モデルの関連付けは自動で行う。
  2. ビューの完全な分離(モデルやプレゼンターを参照させない、できない)

これをやるためにDIコンテナなんかの自動で関連付けを行うものが必要なので、「Unity Application Block」を使う事にする。

実装

前置きが長くなってしまったけど、実装を進めていく。今回、簡単なサンプルとして計算機アプリを作る。

画面イメージはこんな感じ。

画面イメージ

テキストボックスに数字を入れて、「計算」ボタンで計算結果を表示するだけのシンプルなもの。

とりあえず、これを実装したコードを先に紹介する。フレームワーク部分をのぞいたソースのみ。

まずは、実際の計算を行うCalculatorクラス。これが「モデル」

Calculator.vb
Public Class Calculator

    Function Add(ByVal value1 As Long, ByVal value2 As Long) As Long
        Return value1 + value2
    End Function

End Class

画面クラス。見てのとおり何もない*1。これが「ビュー」

MainForm.vb
Public Class MainForm

End Class

そして、ビューとモデルを結びつける「プレゼンター」クラス。プレゼンターはPresenterBase(Of TView)(後述)から継承する必要がある。

MainFormPresenter.vb
Public Class MainFormPresenter
    Inherits PresenterBase(Of MainForm)

    Private _calculator As Calculator
    <Dependency()> _
    Public Property Calculator() As Calculator
        Get
            Return _calculator
        End Get
        Set(ByVal value As Calculator)
            _calculator = value
        End Set
    End Property

    Protected Overrides Sub OnViewSet(ByVal view As MainForm)
        MyBase.OnViewSet(view)

        If view IsNot Nothing Then
            AddHandler view.btnCalc.Click, AddressOf btnCalc_Click
        End If
    End Sub

    Private Sub btnCalc_Click(ByVal sender As Object, ByVal eventArgs As EventArgs)
        With View
            .txtSum.Text = Calculator.Add( _
                Integer.Parse(.txtLValue.Text), Integer.Parse(.txtRValue.Text) _
            )
        End With
    End Sub
End Class

プレゼンターにビューが設定されるとOnViewSetメソッドが呼ばれるようになっているので、これをOverridesしてビューが設定された時に「計算」ボタンのClickイベントハンドラを登録している。

後は、イベントハンドラで画面から値を取ってきて、Calculatorオブジェクトで計算して結果を画面に設定している。

このプログラムのエントリポイントは以下のようにする必要がある。

Program.vb
Module Program
    Sub Main()
        Using dicon As New UnityContainer()
            dicon.AddNewExtension(Of PresenterExtension)()

            Application.Run(dicon.Resolve(Of MainForm)())
        End Using
    End Sub
End Module

UnityContainerインスタンス化して、PresenterExtensionAddNewExtensionメソッドで登録する。

後はResolveメソッドで画面をコンテナから取り出して、アプリケーションを開始するだけ。

これで「計算」ボタンが押されれば、プレゼンターのイベントハンドラが呼び出されて、そこからモデルが呼び出され、その結果がプレゼンター経由でビューに渡る事になる。

ビューが他の部分に一切関連していないのがわかると思う。

本来ならビューにもプレゼンターと関連付ける為のプロパティが必要になるけど、それをしたくない*2ので、UnityContainerの拡張機能を使って細工をした。

フレームワーク部分の解説

プレゼンター自体は何のひねりもない。

PresenterBase.vb
Public MustInherit Class PresenterBase(Of TView As Form)

    Private _view As TView
    Protected ReadOnly Property View() As TView
        Get
            Return _view
        End Get
    End Property

    Public Sub SetView(ByVal view As TView)
        _view = view

        OnViewSet(view)
    End Sub

    Protected Overridable Sub OnViewSet(ByVal view As TView)
    End Sub

End Class

Viewプロパティ(Protected)とビューを関連付ける為のSetViewメソッドとそれを通知するOnViewSetメソッドがあるだけ。

細工はUnityContainerインスタンス化した後にAddNewExtensionで追加したPresenterExtensionクラスにある。

UnityContainerはオブジェクトの生成プロセスにユーザが干渉できるようにする為の機構をいくつか用意していて、その一つとして「ストラテジ」というのがある。

これをUnityContainerに設定する為の窓口としてUnityContainerExtensionというクラスが用意されているのだ。

以下ソース

PresenterExtension.vb
Public NotInheritable Class PresenterExtension
    Inherits UnityContainerExtension

    Protected Overrides Sub Initialize()
        Me.Context.Strategies.Add( _
            New PresenterInitializeStrategy(Me.Container), UnityBuildStage.Setup _
        )
    End Sub

    Public NotInheritable Class PresenterInitializeStrategy
        Inherits BuilderStrategy

        Private _container As IUnityContainer
        Private _presenterDefinitions As Dictionary(Of Type, Type)
        ''' <summary>
        ''' システム定義のアセンブリ名と思われる正規表現パターン
        ''' </summary>
        ''' <remarks></remarks>
        Private ReadOnly SysAssemblyPtrn As Regex = New Regex("^System.*|^Microsoft.*|mscorlib|vshost")

        Public Sub New(ByVal container As IUnityContainer)
            _container = container

            Initialize()
        End Sub

        Private Sub Initialize()
            ' 現在読み込まれているアセンブリからPresenterBaseを継承した型を検索する。
            Dim types = AppDomain.CurrentDomain.GetAssemblies() _
                .Where(Function(a) Not SysAssemblyPtrn.IsMatch(a.GetName().Name)) _
                .SelectMany(Function(a) a.GetTypes()) _
                .Where(Function(t) t.BaseType IsNot Nothing AndAlso t.BaseType.Name Like "PresenterBase`1")

            ' Presenterの型引数とPresenterの型でマップを作る。
            _presenterDefinitions = types.ToDictionary( _
                Function(tp) tp.BaseType.GetGenericArguments().First() _
            )
        End Sub

        Public Overrides Sub PostBuildUp(ByVal context As IBuilderContext)
            Dim view = context.Existing
            Dim viewType = view.GetType()

            If _presenterDefinitions.ContainsKey(viewType) Then
                Dim presenter = _container.Resolve(_presenterDefinitions(viewType))
                presenter.SetView(view)
                ' コンテナに登録
                _container.RegisterInstance(presenter)
            End If

            MyBase.PostBuildUp(context)
        End Sub
    End Class
End Class

何をしているかというと、

  1. ExtensionInitializeメソッドでPresenterInitializeStrategyをストラテジに追加する。
  2. PresenterInitializeStrategyの初期化時に現在読み込まれているアセンブリからPresenterBaseを継承している型を探して、その型パラメータと継承している型のペアでディクショナリを生成する。
  3. PostBuildUpメソッドでDIコンテナがオブジェクトを生成した後をフックして、そのオブジェクトの型が先程のディクショナリに存在すれば、対応するプレゼンターをDIコンテナから取り出す。
  4. プレゼンターにビューを設定する。

ということをやっている。

PostBuildUpメソッドの遅延バインディングでメソッド呼び出しをしている所は今のC#にはできない事なので、ちょっとだけVB.NETを見直した。

これだけでは現実の開発には適用できないので、まだ機能を作りこむ必要があるけど、基本的な考え方はまとまってきた。ビューがこれだけ分離してれば、画面とロジックを平行して作っていくのも楽になるんじゃないかな。

今考えている問題点としては、

  • ビューに実装する機能はどこまでか?
    • 画面の入力値チェック?
    • 入力値チェックをするために必要な情報(DBとか)は、いつどうやって取得するか?
  • 「計算」ボタンの前と後に画面側の処理を入れるにはどうするか?
  • プレゼンターが画面のコントロールを直接参照しているので、画面側の変更をもろに受けてしまう。
    • インターフェースを使って分離すべきか?
    • インターフェースを使った分離はメンテナンスが面倒なので、あまりやりたくない。

と挙げればいくらでも出てくるけど、その辺もこれから考えていきたい。

*1:もちろんパーシャルクラスが別にいるけど、それは割愛

*2:というよりさせたくない、ビューからプレゼンターを参照させたくない