Readable Code その1
いいコード書いてますか?
最近読んだ「Readable Code」という本が面白かったので、その感想とためになったことを紹介したいと思います。
この本ではいいコードを読み易いコードと定義付けて、そういったコードを書くためにはどうすればいいかという事を具体的なコード例を交えて解説しています。
コードのインデントや書式などの見た目を整えるといった事から、変数の命名や意味付け、関数の構造化、デザインパターンの導入まで、その内容は多岐に渡ります。今回はその中でも特に使えそうな内容について説明したいと思います。
と、その前に個人的にいいコードの条件として考えているポイントについて説明しておきます。
俺的いいコードの条件
いいコードの条件としては、まずバグが無いというのが理想でしょう。
しかしプログラムは書いた通りに完璧に動作しますが、それを実装する人間は完璧とは程遠い不完全な代物です。バグの無いプログラムを書く事は不可能と言えます。
では、いいコードの条件としてバグが無いというのを挙げるのは非現実なことでしょうか?
確かにそうかもしれません、しかしこう考える事もできます。
バグを無くす事は出来ないがバグが発生した時、原因の発見、修正の迅速化、修正による影響範囲を局所化する事はできる。
間違いは必ず起こることを認めて、それをいかにして迅速に確実に収束させるかを目指す。
これがエンジニアとして目指すべき姿勢だと思いませんか?そのためにできる事ならなんだってする。それが在るべきエンジニア像でしょう。
少し話がそれますが、プログラミングはどこまで行っても結局のところ職人技です。そこにはどうしても属人性が含まれてしまいます。
しかし、チーム・組織で働く以上、属人性はなるべく入り込まない方が不確定要素が減り、色々とコントロールし易くなります。
実際私もフレームワークやドキュメントを作る時は、誰でも使えるように、誰でも理解出来るように注意しています。
これはチーム・企業で働く以上、当たり前の行動規範でしょう。
しかし、いちエンジニア・いちプログラマとして見た場合、こんなことはクソくらえとなります。
属人性が排除されたコードなんてものは、魂のこもらない人形みたいなもの、ようするにクソだということです。
こんなことは公には言えないですが、いち企業人といちエンジニアとで思想や信条を使い分ける必要はあるが、エンジニアとしての魂や誇りといったものは捨てる必要はなく、持ち続けて欲しい、持ち続けたいと思う今日この頃です。
では、ここから本書に書かれている内容に触れていきます。
目次
Readable Codeの目次を以下に示します。
1.表面上の改善
- 名前に情報を詰め込む
- 誤解されない名前
- 美しさ
- コメントすべきことを知る
- コメントは正確で簡潔に
2.ループとロジックの単純化
- 制御フローを読みやすくする
- 巨大な式を分割する
- 変数と読みやすさ
3.コードの再構成
- 無関係の下位問題を抽出する
- 一度に1つのことを
- コードに思いを込める
- 短いコードを書く
4.選抜テーマ
- テストと読みやすさ
目次を見ただけでどういった内容の事が書かれているかだいたい把握できるでしょう。
前置きが長くなってしまったので、本編はまた次回に続きます。
つづく…
アーキテクト宣言
長い間お休みしてましたが、転職して東京に来てから早二年、ようやくアーキテクトとしての道を歩みはじめました。
プログラマーとアーキテクトの違いは、本人がアーキテクトと宣言したかどうかなんて事がよく言われたりしますが、実際の所、周りが認めてくれなければアーキテクトもクソもないわけで。。。
まぁ、そんなこんなで周りに認めてもらってアーキテクトとして働ける環境や仕事がやっとこさ構築できたというわけです。
これからは今までと同様に技術的なネタも書いていきますが、それプラス、アーキテクチャとは何か?とか、アーキテクチャをどのようにして他者に伝えるか?とか、アーキテクチャに関する勉強ネタなんかも書いていきたいと思います。
それでは、
俺はアーキテクトだ!
NDesk.Options(Mono) - コマンドラインパーサー
Monoでコマンドラインパーサーがあったので使ってみた。
使い方はこんな感じ
string data = null; bool help = false; int verbose = 0; var p = new OptionSet () { { "file=", v => data = v }, { "v|verbose", v => { ++verbose } }, { "h|?|help", v => help = v != null } }; List<string> extra = p.Parse(args);
OptionSetにAddメソッドが定義されていて、以下のようなシグネチャのメソッドが定義されている。
- Add(string prototype, Action
action); - Add(string prototype, string description, Action
action)
prototype引数には、コマンドラインのオプション引数の引数名を指定する。|(パイプ)はorを意味する。
v|verbose
と指定した場合は、コマンドライン引数として、
PS > コマンド名 -v PS > コマンド名 -verbose
末尾に=(イコール)を付けた場合は、
file=
以下のように引数名と値という形で指定できるようになる。
PS > コマンド名 -file=100.txt
description引数はその名の通り、オプション引数の説明になる。これはOptionSetクラスに定義されているWriteOptionDescriptionsメソッドというコマンドラインの説明文を出力するメソッドを呼び出した時にその出力に含まれるようになる。
action引数には、コマンドラインをパースする中で実際にオプション引数が見つかった時にその値をどのように処理するかを委譲するデリゲートを指定する。
これらの設定を行ってから、Parseメソッドを呼び出すと戻り値として、オプション以外のコマンドライン引数がコレクションとして返ってくる。
使い易いといえば使い易いけど、値の設定にデリゲートを渡すという発想が、完全に一般のデベロッパを無視しているので、もう少し使いやすくするために属性ベースで値を取得できるラッパを作ってみた。
CommandlineParser
使い方は以下のようになる。
まず、オプション引数を格納するクラスを定義し、値を格納するプロパティをCommandlineOption属性でマークする。
MyOptions.cs
class MyOptions { [CommandlineOption("n", "name", Description = "名前を指定します。")] public string Name { get; set; } [CommandlineOption("s", "switch", Description = "スイッチ", OptionType = CommandlineOptionType.Switch)] public bool Switch { get; set; } [CommandlineOption("value")] public int Value { get; set; } }
後はCommandlineParserクラスをインスタンス化し、Parseメソッドを呼び出すだけ。
[Test] public void コマンドラインをパースできる() { // コマンドライン引数 var args = new[] { "-n=hoge", "-switch", "value1", "value2", "-value=20" }; var result = cmdlineParser.Parse<MyOptions>(args); Assert.That(result, Is.Not.Null); Assert.That(result.Values, Has.Count.EqualTo(2)); // Valuesプロパティにオプション引数以外の値が入っている。 Assert.That(result.Values[0], Is.EqualTo("value1")); Assert.That(result.Values[1], Is.EqualTo("value2")); // オプション引数がカスタム属性でマークしたプロパティに設定されている。 Assert.That(result.Options.Name, Is.EqualTo("hoge")); Assert.That(result.Options.Switch, Is.True); Assert.That(result.Options.Value, Is.EqualTo(20)); // コマンドラインの説明文を取得できる。 Console.WriteLine(cmdlineParser.GetCommandlineUsage<MyOptions>()); }
戻り値としてCommandlineParseResult
探せば色々とあるものですね。
以下ソース
DataGridViewにデータバインドして編集・削除するのに最適な方法は?
WindowsFormで業務アプリを開発する場合、DataGridViewの様にデータの表示と編集作業を実際のデータソースの種類を問わずに一括してやってくれるコントロールがあると非常に便利である。
単純な編集作業ならば、これにデータバインドしてちょこちょこっとコードを書いてやるだけで、目的の仕様を達成できるのだから、ありがたくて涙が出てくる。
.NETにはデータベースにアクセスする技術が何種類かあって、どれを選ぶかによってDataGridViewとの連携のし易さもかなり変わってくる。
その辺の違いを調べる為に以下の仕様をいくつかのデータベースアクセス技術で実装して、その違いを比べてみた。
- 表示するレコードは複数のテーブルをJOINしている(JOINで対応できない場合はビューを使う)。
- 属性を変更できる。
- レコードを削除できる。
DataSet
まずはDataSetから(コードを書くスタイル)
MainForm.vb
Imports System.Text Imports System.Data.SqlClient Public Class MainForm Private Const CONNECTION_STRING = "Data Source=localhost\SQLEXPRESS;Initial Catalog=Sample;Persist Security Info=True;User ID=sa;Password=*******" Protected Overrides Sub OnLoad(ByVal e As System.EventArgs) Dim dataSet = New DataSet() Using sqlCon = New SqlConnection() With {.ConnectionString = CONNECTION_STRING} sqlCon.Open() Dim sql = New StringBuilder() sql.AppendLine("SELECT t1.Id,t1.Name,t2.Name AS Organization") sql.AppendLine("FROM Person AS t1") sql.AppendLine("INNER JOIN Organization AS t2") sql.AppendLine(" ON t1.Organization = t2.Id") Using sqlDa = New SqlDataAdapter(sql.ToString(), sqlCon) sqlDa.Fill(dataSet) End Using End Using DataGridView1.DataSource = dataSet.Tables(0) MyBase.OnLoad(e) End Sub Protected Overrides Sub OnFormClosed(ByVal e As System.Windows.Forms.FormClosedEventArgs) Dim dataTable = DirectCast(DataGridView1.DataSource, DataTable).GetChanges() ' 変更無し If dataTable Is Nothing Then Return Using sqlCon = New SqlConnection() With {.ConnectionString = CONNECTION_STRING} sqlCon.Open() Using sqlDa = New SqlDataAdapter() sqlDa.UpdateCommand = New SqlCommand("UPDATE Person SET Name = @Name WHERE Id = @Id", sqlCon) sqlDa.UpdateCommand.Parameters.Add("@Name", SqlDbType.VarChar, 50, "Name") sqlDa.UpdateCommand.Parameters.Add("@Id", SqlDbType.BigInt, 8, "Id") sqlDa.DeleteCommand = New SqlCommand("DELETE Person WHERE Id = @Id", sqlCon) sqlDa.DeleteCommand.Parameters.Add( _ New SqlParameter("@Id", SqlDbType.BigInt, 8, "Id") With {.SourceVersion = DataRowVersion.Original} _ ) sqlDa.Update(dataTable) End Using End Using MyBase.OnFormClosed(e) End Sub End Class
SqlDataAdapterを使い、最低限のコードで済ませているつもり。検索するレコードはJOINするので、SqlCommandBuilderによるUpdate文などの自動生成には対応していない。
なのでUpdateCommandとDeleteCommandは独自に設定している。
思っていたよりもコードがすっきりしていて、わかり易い。
型付けDataSet
次、型付けDataSet。こちらはデザイナによるコード生成で全てまかなえるので、コード無し。
LINQ to SQL
DLINQ
まずはLINQデザイナでテーブルをぽとぺたする。
MainForm.vb
Imports System.Data.SqlClient Public Class MainForm Private _db As SampleDatabaseDataContext = New SampleDatabaseDataContext() Protected Overrides Sub OnLoad(ByVal e As System.EventArgs) Dim dataSource = From p In _db.PersonView BindingSource1.DataSource = dataSource MyBase.OnLoad(e) End Sub Protected Overrides Sub OnFormClosed(ByVal e As FormClosedEventArgs) Dim changeSet = _db.GetChangeSet() Dim deleteSet = changeSet.Deletes.Select(Function(o As PersonView) o.Id) Dim updateSet = changeSet.Updates.Select( _ Function(o As PersonView) New With {.Id = o.Id, .Name = o.Name} _ ) For Each id In deleteSet _db.ExecuteCommand("DELETE Person WHERE Id = {0}", id) Next For Each o In updateSet _db.ExecuteCommand("UPDATE Person SET Name = {0} WHERE Id = {1}", o.Name, o.Id) Next MyBase.OnFormClosed(e) End Sub End Class
更新するところがLINQでなくなっているのが痛いけど、仕方がない。*1
以外とすっきりしている。しかし、DataSetに比べ若干敷居が高いかもしれない。
ORM(iBATIS.NET)
iBATIS.NETを使った場合
まずはPersonクラスを定義する。
Person.vb
Public Class Person Private _id As Long Public Property Id() As Long Get Return _id End Get Set(ByVal value As Long) _id = value End Set End Property Private _name As String Public Property Name() As String Get Return _name End Get Set(ByVal value As String) _name = value End Set End Property Private _organization As String Public Property Organization() As String Get Return _organization End Get Set(ByVal value As String) _organization = value End Set End Property End Class
マッピングファイル
Person.xml
<?xml version="1.0" encoding="utf-8" ?> <sqlMap namespace="OrmSample" xmlns="http://ibatis.apache.org/mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <resultMaps> <resultMap id="Person" class="OrmSample.Person, OrmSample"> <result column="Id" property="Id" /> <result column="Name" property="Name" /> <result column="Organization" property="Organization" /> </resultMap> </resultMaps> <statements> <select id="selectPerson" resultMap="Person"> SELECT t1.Id, t1.Name, t2.Name AS Organization FROM Person AS t1 INNER JOIN Organization AS t2 ON t1.Organization = t2.Id </select> </statements> </sqlMap>
画面側
MainForm.vb
Imports System.ComponentModel Imports IBatisNet.DataMapper Imports IBatisNet.DataMapper.Configuration Public Class MainForm Private _sqlMapper As ISqlMapper Protected Overrides Sub OnLoad(ByVal e As System.EventArgs) Dim sqlMapBuilder = New DomSqlMapBuilder() _sqlMapper = sqlMapBuilder.Configure() BindingSource1.DataSource = _sqlMapper.QueryForList(Of Person)("selectPerson", Nothing) MyBase.OnLoad(e) End Sub Protected Overrides Sub OnFormClosed(ByVal e As FormClosedEventArgs) ' 面倒くさい MyBase.OnFormClosed(e) End Sub End Class
どのオブジェクトが変更されたかを調べるのが面倒くさかったので更新系は実装していない。だいぶ面倒くさいのは確か。
結果をまとめると、
技術 | 参照系 | 更新系 | 総合点 (5点満点) |
雑感 |
---|---|---|---|---|
DataSet | ○ | ○ | 4 | 動的なSQLには不向き |
型付けDataSet | ○ | ○ | 4 | 動的なSQLには不向き。 デザイナを使用するため、画面のコードに直接SQLを書き込む必要がある。 |
LINQ to SQL | ○ | △ | 3.5 | イカしてる。 |
ORM | ○ | △ | 2.5 | とにかく面倒くさい。 使うならば変更を検知するなんらかの仕組みが必要 |
参照系ならばどの技術を使っても大差は無い(動的な条件に強いか弱いかぐらい)。
逆に更新系では、変更を検知する仕組みが初めから組み込まれているDataSetやDLINQはかなり強いので、ORMなんかのPONOをエンティティとして使うフレームワークはどうしても弱くなってしまう。
まぁ、DataGridView使って楽したかったらDataSetを使えって事ですね。結論でました。
MVPパターンを業務アプリに適用する − 画面遷移
MVPパターンにおける画面遷移のやり方を調べていたが、具体的な情報があまりなく、これだというのが無かったので自分なりに考えてみた。
業務アプリでやるからには極力シンプルで、誰にでもわかるやり方にする必要がある*1。
とりあえず考えたのは、下図のようにプレゼンター側で遷移するビューを取り出し、必要な値を設定し表示するというやり方。
概要図
これくらい単純なのが一番いい。
ビューに値を設定する方法としては、
- コントロールを参照して直接値を設定する。
- プロパティを定義して、間接的に値を設定する。
が考えられるが、プロジェクト的には1.を採用したい。簡単だし、受け入れられ易い。プロパティってなに?って言われちゃうレベルだからね。
しかし、いくつか問題点がある。
- コントロールを直接参照するので、コントロール名を変更されたり、削除されたりするとコンパイルエラー
- 型の不一致(例えばTextBoxのTextプロパティはString型だけど、欲しいのはInteger型)
1.は、画面がころころ変わるのは事前に設計がちゃんとできていないからなので、それ以前の問題
事前の設計がきちんと行われていれば、コントロール名が変更されたり、削除される事は(ほとんど)無い。
2.は、利用する側でその都度変換をかけていては、あちこちに同じ様なコードが記述されてしまうので、
バグの温床になりかねない。
結果として、一番安全なのは、
3.は、プロパティを定義してもらう為の説得材料にもなりそう。こうしないと危険でしょ?みたいな。
実装
ということで、プレゼンターからビューにアクセスする為の変更を稚拙のMVPフレームワークに施していく。
まずは、PresenterBaseにコンテナからビューを取得する為のメソッドを追加する。
PresenterBase.vb(一部省略)
Public MustInherit Class PresenterBase(Of TView As Form) Private _container As IUnityContainer Public Sub SetContainer(ByVal container As IUnityContainer) _container = container End Sub Protected Function GetView(Of T As Form)() As T Return _container.Resolve(Of T)() End Function End Class
コンテナの設定は後でやるとして、GetViewというメソッドを定義した。
このメソッドは単に型パラメータで渡された型のインスタンスをResolveメソッドで取り出して返すだけ。
プレゼンターへのコンテナの設定はPresenterExtensionが行う。
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 Public Sub New(ByVal container As IUnityContainer) _container = container Initialize() 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) presenter.SetContainer(_container) End If MyBase.PostBuildUp(context) End Sub End Class End Class
サンプルプログラムには画面遷移が無いので、画面遷移するように下図のメニュー画面を作る。
画面イメージ
コードはこんな感じ
MenuView.vb
Public Class MenuView Public Event MenuClick(ByVal sender As Object, ByVal e As MenuClickEventArgs) Private Sub menu_Click(ByVal sender As Object, ByVal e As EventArgs) Handles btnCalc.Click RaiseEvent MenuClick(sender, New MenuClickEventArgs(DirectCast(sender, Button).Tag)) End Sub End Class Public Class MenuClickEventArgs Inherits EventArgs Private _menuKind As MenuKind Public ReadOnly Property MenuKind() As MenuKind Get Return _menuKind End Get End Property Public Sub New(ByVal menuKind As MenuKind) _menuKind = menuKind End Sub End Class
クリックされたメニューはMenuClickEventArgsクラスのMenuKindプロパティで判断できるようになっている。
MenuKind列挙型の定義は以下
Enums.vb
Public Enum MenuKind 計算機 = 1 End Enum
で、このメニュー画面のプレゼンター
MenuViewPresenter.vb
Public Class MenuViewPresenter Inherits PresenterBase(Of MenuView) Protected Overrides Sub OnViewSet(ByVal view As MenuView) If view IsNot Nothing Then AddHandler view.MenuClick, AddressOf view_MenuClick End If MyBase.OnViewSet(view) End Sub Private Sub view_MenuClick(ByVal sender As Object, ByVal e As MenuClickEventArgs) Select Case e.MenuKind Case MenuKind.計算機 Dim calcView = GetView(Of CalcView)() calcView.StartPosition = FormStartPosition.CenterParent calcView.ShowDialog(Me.View) Case Else End Select End Sub End Class
実行して「計算機」ボタンをクリックすると、こんな感じ
スクリーンキャプチャ
これで適当な値を入力して計算したりなんかして、計算画面を閉じて、もう一度「計算機」ボタンをクリックすると前の値は保持されるずに初期状態の計算画面が表示される。
GetViewメソッドはDIコンテナから新しいオブジェクトを取り出すだけなので、毎回違うオブジェクトだからこの動きは当然の結果。
でも、使い捨ての画面ならこれでもいいけど、画面の値をずっと保持していたい場合なんかがあった場合、別途画面のインスタンスをどこかに保持しておくか、毎回画面の値をセットし直すなどのなんらかの対処をする必要がある。
こういう事はあまりしたくないので、DIコンテナから毎回同じインスタンスを取得できる、つまりSingletonなビューを取り出せるメソッドを用意しておく。
PresenterBase.vb(一部省略)
Protected Function GetViewOfSingleton(Of T As Form)(ByVal viewName As String) As T Dim view = _container.Resolve(Of T)(viewName) Dim singletonView = TryCast(view, SingletonViewBase) ' オブジェクトのLifetimeをSingletonで管理するオブジェクト Dim lifetimeMgr = New ContainerControlledLifetimeManager() If singletonView IsNot Nothing Then singletonView.SetLifetime(lifetimeMgr) End If ' Singletonで登録する。 ' 次回以降、コンテナからSingletonで取り出せるようになる。 _container.RegisterInstance(viewName, view, lifetimeMgr) Return view End Function
Protected Function GetViewOfSingleton(Of T As Form)(ByVal viewName As String) As T Dim view = _container.Resolve(Of T)(viewName) Dim lifetimeMgr = _context.GetLifetimeManager(view) If lifetimeMgr Is Nothing Then lifetimeMgr = New ContainerControlledLifetimeManager() ' Singletonで登録する。 ' 次回以降、コンテナからSingletonで取り出せるようになる。 _container.RegisterInstance(viewName, view, lifetimeMgr) Dim singletonView = TryCast(view, SingletonViewBase) ' SingletonViewBaseから継承していれば、Lifetimeの管理を任せる。 If singletonView IsNot Nothing Then singletonView.SetLifetime(lifetimeMgr) End If End If Return view End Function
GetViewOfSingletonメソッドで取得したビューはSingletonインスタンスになる。もちろんviewName引数で指定した名前単位でだけど。
ついでにこういうのも作っておいた。
SingletonViewBase.vb
Public Class SingletonViewBase Private _lifetimeMgr As ILifetimePolicy Friend Sub SetLifetime(ByVal lifetimeMgr As ILifetimePolicy) _lifetimeMgr = lifetimeMgr End Sub <System.Diagnostics.DebuggerNonUserCode()> _ Protected Overrides Sub Dispose(ByVal disposing As Boolean) Try If disposing AndAlso components IsNot Nothing Then components.Dispose() End If ' singletonオブジェクトを破棄 ' これやるとDisposeが呼ばれてStackOverflowを起こす 'If _lifetimeMgr IsNot Nothing Then '_lifetimeMgr.RemoveValue() '_lifetimeMgr = Nothing 'End If Finally MyBase.Dispose(disposing) End Try End Sub End Class
GetViewOfSingletonメソッドで生成したビューがSingletonViewBaseを継承していた場合はILifetimePolicyのインスタンスを設定しておき、SingletonViewBaseがDisposeされたタイミングでILifetimePolicyのRemoveValueメソッドを呼び出す事で、DIコンテナ上からもインスタンスが削除されるようになっている。
これで次からは同じviewNameでも新しいインスタンスが生成されるようになる。
ビューがSingletonになる可能性ができたことでPresenterExtension側にも対応が必要になったけど、その部分は割愛しておく。
とまぁ、こんな感じでビューを生成して画面遷移していけばいいと思う。
MVPパターンを業務アプリに適用する
業務アプリケーションを開発するにあたって、最低限守るべき事の一つとして「画面とロジック」の分離が挙げられると思う。
簡単そうに思えて、これが意外と難しい。
一人での開発ならば自分だけでやり方を決めてしまえばそんなに難しくはない。しかし、開発要員が増えれば、これが途端に難しくなる。
この原因としては、
- どこからが「画面」の役割で、どこまでが「ロジック」の役割かの定義が個々人でばらつきがある。
- 定義を決めても守らない奴がいる(そもそも意味が理解できていない)。
- 言語自体がへぼくて「画面とロジック」の分離がそもそも(充分に)できない。
なんていうのがある。
ほとんど、「それって開発者としてどうなの?」というレベルに思えるけど、現実の開発ってそんなもんだよね。
いつの時代もこの問題は付きまとうようで、実際色んなデザインパターンが考えられてきた。
知ってるやつでは、
- MVC(モデル・ビュー・コントローラ、Strutsとか)
- MVVM(モデル・ビュー・ビューモデル、WPF、Silverlight)
- MVP(モデル・ビュー・プレゼンター、後述)
- ドキュメント・ビュー アーキテクチャ(MFCのあれ)
なんてのがある。
他にも知らないだけで、いっぱいあるんでしょうね。
まぁ、そんなこんなで今回VB.NETを使って業務アプリを作る事になったんですけど、これがなんか20〜30人で開発するらしいんですよね(実装だけで)。
で、そこで開発サブリーダー的な役割を与えられるわけですけど、アサインする開発者の人たちはほとんど(というか全く).NETとか知らないわけですよ。ありがちな話ですけど。
だもんで、そのまま開発させたら画面のイベントハンドラにすべての処理を書いたりするわけですよ。
それを防ぐために何かしらのアーキテクチャが必要になってくるんですが、それに今回はMVPパターンを採用しようかと考えてます。WinFormアプリケーションなので、それとの相性とかも考えて、これが一番ベターかなと。
MVPパターンを実装するフレームワークとしてPatterns & Practiceの「Smart Client Software Factory」とかいうのがあるんですけど、試しにインストールしてプロジェクトを作ってみたら、山程ソースを生成しやがったので、こりゃ駄目だと思って採用は見送りました。
なので、単純なMVPパターンを実装するフレームワークを作ることにしました。
参考にしたのはこの辺
- C#と諸々 MVP (Model View Presenter)パターン
- http://blog.inomata.lolipop.jp/?eid=924433
- Enterprise Library 2.0を特徴づけるDI機能とは(3/3) - @IT
MVPパターンの特徴は、プレゼンターがビューとモデルの関連付けを行い、ビューとモデルはお互いに完全に独立しているという事。
このフレームワークで目指すところは、
- ビューやプレゼンター、モデルの関連付けは自動で行う。
- ビューの完全な分離(モデルやプレゼンターを参照させない、できない)
これをやるために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
後は、イベントハンドラで画面から値を取ってきて、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をインスタンス化して、PresenterExtensionをAddNewExtensionメソッドで登録する。
後は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
何をしているかというと、
- ExtensionのInitializeメソッドでPresenterInitializeStrategyをストラテジに追加する。
- PresenterInitializeStrategyの初期化時に現在読み込まれているアセンブリからPresenterBaseを継承している型を探して、その型パラメータと継承している型のペアでディクショナリを生成する。
- PostBuildUpメソッドでDIコンテナがオブジェクトを生成した後をフックして、そのオブジェクトの型が先程のディクショナリに存在すれば、対応するプレゼンターをDIコンテナから取り出す。
- プレゼンターにビューを設定する。
ということをやっている。
PostBuildUpメソッドの遅延バインディングでメソッド呼び出しをしている所は今のC#にはできない事なので、ちょっとだけVB.NETを見直した。
これだけでは現実の開発には適用できないので、まだ機能を作りこむ必要があるけど、基本的な考え方はまとまってきた。ビューがこれだけ分離してれば、画面とロジックを平行して作っていくのも楽になるんじゃないかな。
今考えている問題点としては、
- ビューに実装する機能はどこまでか?
- 画面の入力値チェック?
- 入力値チェックをするために必要な情報(DBとか)は、いつどうやって取得するか?
- 「計算」ボタンの前と後に画面側の処理を入れるにはどうするか?
- プレゼンターが画面のコントロールを直接参照しているので、画面側の変更をもろに受けてしまう。
- インターフェースを使って分離すべきか?
- インターフェースを使った分離はメンテナンスが面倒なので、あまりやりたくない。
と挙げればいくらでも出てくるけど、その辺もこれから考えていきたい。
フォームのエクスポート/インポート
AccessのVSSがよくぶっこわれるので、フォームのソースのエクスポート/インポート方法のメモ
' エクスポート Call SaveAsText(acForm, "フォーム名", "パス") ' インポート Call LoadFromText(acForm, "フォーム名", "パス")