MVP パターンは書籍「ドメイン駆動」で知りましたが、GUI Architectures は英文苦手なので読んでません。だから、違うこと言っている可能性もあります。どちらかというと、僕が MVP パターンを適用する時の個人的な考え方、ということになるかもしれません。
また、ここでは特に、ASP.NET 開発で MVP パターンを適用する場合について書いていきます。もしかしたら、それ以外の開発では若干当てはまらない点があるかもしれません。
[MVP パターンとは]
MVP (Model View Presenter) パターンは、MVC (Model View Controller) パターンの亜種です。
大きな違いとして、MVC パターンでは Controller がユーザーからの入力イベントを受け取りますが、MVP パターンでは View がユーザーからの入力イベントを受け取り、処理を Presenter に委譲します。
[Model]
Model は、ドメインモデルを表します。
ドメインとは、業務固有の問題領域のことです。
Model → View
- Model は、View に依存しません。
- Model は、Presenter に依存しません。
[View]
View は、Presenter の要求インターフェイスを実装し、ユーザーインターフェイスを直接操作します。
View は、極力無能にします。そのためには、UI コントロールとの直接的なやり取り以外をできるだけ行わないようにします。
View → Presenter
- View は、Presenter に自分自身を関連付けます。
- View は、ユーザーからの入力イベントを受け取り、処理を Presenter に委譲します。
- View は、上記以外の目的で Presenter を操作しません。
- View は、Presenter から Model を受け取って出力することができます。
- View は、Model を生成しません。
- View は、出力に必要な操作以外で、Model を操作しません。
[Presenter]
Presenter は、View を通じて、ユーザーからの入力イベントを受け取ります。その後、Model を操作したり View を操作したりします。
Presenter → View
- Presenter は、View から入力値を取得することができます。
- Presenter は、View を操作することができます。
- Presenter は、Model を生成したり操作することができます。
- Presenter は、Model や View に属さないもの (データストアやハードウェア等) に関する処理を行うことができます。
[View と Presenter の関係]
前述の通り、View は、Presenter の要求インターフェイスを実装します。この要求インターフェイスは、View が極力無能になるように考慮して定義します。
Presenter と View は1対1の関係で、一つの Presenter を複数の View に関連付けることは通常しません。
[Model の変更通知について]
MVP パターンも MVC パターンと同じく、Model が View に変更を通知することができます。ただし、これは必須ではなく、この記事でもこれを含めていません。(つか、そっちはよく知りません。)
こちらの記事によると、このように Model の変更通知を無くして Presenter が完全に View 操作を行うことを、「慎ましいビュー (Humble View)」 と呼ぶそうです。
つづく
public class Hoge
{
public Fuga F = new Fuga();
}
public class Fuga
{
public int V = 0;
}
更にこんなクラスがあったとします。
public class Foo
{
public Piyo(IBar bar)
{
this._bar = bar;
}
private _bar;
public void AAA()
{
Hoge h = new Hoge();
h.F.V = 10;
this._bar.BBB(h);
}
}
public interface IBar
{
void BBB(Hoge h);
}
Foo.AAA メソッドは IBar.BBB メソッドに h (Hoge オブジェクト) を渡しています。
この時、h.F.V に 10 を設定しています。
このことを、NUnit の DynamicMock を使って検証するには、次のような方法が取れます。
using NUnit.Framework;
using NUnit.Framework.Constraints;
using NUnit.Framework.SyntaxHelpers;
using NUnit.Mocks;
[TestFixture]
public class TestFixture1
{
[Test]
public void Test1()
{
DynamicMock barMockery = new DynamicMock(typeof(IBar));
Foo foo = new Foo((IBar)barMockery.MockInstance);
barMockery.Strict = true;
// BBB メソッドが受け取った Hoge オブジェクトの F プロパティ値の V プロパティ値が 10 であることを期待。
barMockery.Expect("BBB",
new PropertyConstraint("F",
new PropertyConstraint("V",
Is.EqualTo(10))));
foo.AAA();
barMockery.Verify();
}
}
Has.Property メソッドを使用するのではなく、PropertyConstraint オブジェクトを直接生成するのがポイントです。
DynamicMock.Expect メソッド (の第2引数) は Constraint が渡されることも想定した作りになっていますが、Has.Property メソッド (の第2引数) はこれを想定した作りにはなっていません。
なので、xxx.yyy.zzz というように、プロパティ値のプロパティ値を検証するには Has.Property メソッドが使えないわけです。
また、Current という静的プロパティを通じて操作するようにも変更してあります。
ちなみに、最近は MVP パターン使ってるのでリンク先のような使い方はしなくなりました。Presenter をセッションに格納する際に便利です。
SessionStateAdapter<TItem> クラス
using System;
using System.Collections.Generic;
using System.Web;
using System.Web.SessionState;
namespace MoroMoro.Enterprise.Web
{
/// <summary>
/// 型をキーとしてセッションステートを利用するアダプターです。
/// </summary>
/// <typeparam name="TItem">セッションステートに保存するインスタンスの型。</typeparam>
public class SessionStateAdapter<TItem>
where TItem : class
{
#region Static Members
/// <summary>
/// 現在のセッションステートに接続する SessionStateAdapter<TItem> オブジェクトを取得します。
/// </summary>
/// <exception cref="InvalidOperationException">現在の HttpContext を取得できませんでした。</exception>
/// <exception cref="InvalidOperationException">セッションステートが利用できません。</exception>
public static SessionStateAdapter<TItem> Current
{
get
{
HttpContext currentContext = GetCurrentContext();
object key = typeof(SessionStateAdapter<TItem>);
if (currentContext.Items[key] == null)
{
HttpSessionState currentSessionState = GetSessionState(currentContext);
currentContext.Items[key] = new SessionStateAdapter<TItem>(currentSessionState);
}
return (SessionStateAdapter<TItem>)currentContext.Items[key];
}
}
/// <summary>
/// 現在の HttpContext を取得します。
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">現在の HttpContext を取得できませんでした。</exception>
private static HttpContext GetCurrentContext()
{
HttpContext currentContext = HttpContext.Current;
if (currentContext == null)
{
throw new InvalidOperationException("現在の HttpContext を取得できませんでした。");
}
return currentContext;
}
/// <summary>
/// 現在のセッションステートを取得します。
/// </summary>
/// <returns>現在のセッションステート。</returns>
/// <exception cref="InvalidOperationException">セッションステートが利用できません。</exception>
private static HttpSessionState GetSessionState(HttpContext currentContext)
{
HttpSessionState currentSession;
try
{
currentSession = currentContext.Session;
}
catch (Exception ex)
{
throw new InvalidOperationException("セッションステートが利用できません。", ex);
}
if (currentSession == null)
{
throw new InvalidOperationException("セッションステートが利用できません。");
}
return currentSession;
}
#endregion
#region Constructors
/// <summary>
/// SessionStateAdapter<TItem> クラスの新しいインスタンスを初期化します。
/// </summary>
/// <param name="sessionState">接続するセッションステート。</param>
private SessionStateAdapter(HttpSessionState sessionState)
{
this._sessionState = sessionState;
this._key = typeof(TItem).FullName;
}
#endregion
#region Fields
/// <summary>
/// 接続するセッションステートを取得します。
/// </summary>
private readonly HttpSessionState _sessionState;
/// <summary>
/// 項目キーを取得します。
/// </summary>
private readonly string _key;
#endregion
#region Methods
/// <summary>
/// ジェネリックパラメータ TItem のインスタンスを現在のセッションステートから取得します。
/// </summary>
/// <returns>ジェネリックパラメータ TItem のインスタンス</returns>
/// <exception cref="NotFoundSessionItemException">指定した項目がセッションステート内に存在しません。</exception>
public TItem GetItem()
{
if (!ItemIsExists())
{
throw new SessionItemNotFoundException(typeof(TItem).FullName);
}
Dictionary<Type, object> holder = this.GetHolder(false);
return holder[typeof(TItem)] as TItem;
}
/// <summary>
/// ジェネリックパラメータ T のインスタンスを現在のセッションステートに設定します。
/// </summary>
/// <param name="target">ジェネリックパラメータ T のインスタンス。</param>
/// <exception cref="ArgumentNullException">引数 target が null です。</exception>
/// <exception cref="InvalidOperationException">セッションステートが読み取り専用です。</exception>
public void SetItem(TItem target)
{
if (target == null)
{
throw new ArgumentNullException("target");
}
if (this._sessionState.IsReadOnly)
{
throw this.CreateExceptionForSessionStateIsReadOnly();
}
Dictionary<Type, object> holder = this.GetHolder(true);
holder[typeof(TItem)] = target;
}
/// <summary>
/// ジェネリックパラメータ T のインスタンスを現在のセッションステートから削除します。
/// </summary>
/// <exception cref="InvalidOperationException">セッションステートが読み取り専用です。</exception>
public void RemoveItem()
{
if (this._sessionState.IsReadOnly)
{
throw this.CreateExceptionForSessionStateIsReadOnly();
}
Dictionary<Type, object> holder = this.GetHolder(true);
holder.Remove(typeof(TItem));
}
/// <summary>
/// ジェネリックパラメータ T のインスタンスが現在のセッションステートに存在するかどうかを表す値を返します。
/// </summary>
/// <returns>ジェネリックパラメータ T のインスタンスが現在のセッションステートに存在するかどうかを表す値。</returns>
/// <exception cref="InvalidOperationException">セッションステートが利用できません。</exception>
public bool ItemIsExists()
{
Dictionary<Type, object> holder = this.GetHolder(false);
if (holder == null)
{
return false;
}
if (!holder.ContainsKey(typeof(TItem)))
{
return false;
}
return (holder[typeof(TItem)] is TItem);
}
/// <summary>
/// ホルダーを取得します。
/// </summary>
/// <remarks>
/// ASP.NET 状態サービスを使用する場合、シリアル化の際に、セッション状態の各項目との間でオブジェクトグラフが共有されません。
/// これを回避するために、セッション状態にホルダー (Dictionary<TKey, Tvalue> オブジェクト) を一つ用意し、
/// セッション状態に格納する全てのオブジェクトをこのホルダーに格納します。
/// </remarks>
/// <returns>ホルダー。</returns>
private Dictionary<Type, object> GetHolder(bool createIfDoesNotExist)
{
Dictionary<Type, object> holder = this._sessionState[typeof(SessionStateAdapter<>).FullName] as Dictionary<Type, object>;
if ((holder == null) && createIfDoesNotExist)
{
if (this._sessionState.IsReadOnly)
{
throw this.CreateExceptionForSessionStateIsReadOnly();
}
holder = new Dictionary<Type, object>();
this._sessionState[typeof(SessionStateAdapter<>).FullName] = holder;
}
return holder;
}
/// <summary>
/// セッションステートが読み取り専用であることを示す例外を生成します。
/// </summary>
/// <returns>セッションステートが読み取り専用であることを示す例外。</returns>
private Exception CreateExceptionForSessionStateIsReadOnly()
{
return new InvalidOperationException("セッションステートが読み取り専用です。");
}
#endregion
}
}
SessionItemNotFoundException クラス
using System;
using System.Runtime.Serialization;
using System.Security.Permissions;
namespace MoroMoro.Enterprise.Web
{
/// <summary>
/// セッションステート内に特定の項目が見つからないことを表す例外。
/// </summary>
[Serializable]
public class SessionItemNotFoundException : Exception
{
#region Constructors
/// <summary>
/// SessionItemNotFoundException クラスの新しいインスタンスを初期化します。
/// </summary>
public SessionItemNotFoundException()
: this(null)
{
}
/// <summary>
/// この例外の原因である項目のキーを指定して、SessionItemNotFoundException クラスの新しいインスタンスを初期化します。
/// </summary>
/// <param name="key">例外の原因となった項目キー。</param>
public SessionItemNotFoundException(string key)
: this(key, "指定した項目がセッションステート内に存在しません。")
{
}
/// <summary>
/// エラー メッセージ、およびこの例外の原因である項目のキーを指定して、SessionItemNotFoundException クラスの新しいインスタンスを初期化します。
/// </summary>
/// <param name="key">例外の原因となった項目キー。</param>
/// <param name="message">エラーを説明するメッセージ。</param>
public SessionItemNotFoundException(string key, string message)
: this(key, message, null)
{
}
/// <summary>
/// エラー メッセージ、項目キー、およびこの例外の原因である内部例外への参照を使用して、SessionItemNotFoundException クラスの新しいインスタンスを初期化します。
/// </summary>
/// <param name="key">例外の原因となった項目キー。</param>
/// <param name="message">例外の原因を説明するエラー メッセージ。</param>
/// <param name="innerException">現在の例外の原因である例外。内部例外が指定されていない場合は、null 参照 (Visual Basic の場合は Nothing)。</param>
public SessionItemNotFoundException(string key, string message, Exception innerException)
: base(message, innerException)
{
this._key = key;
}
/// <summary>
/// シリアル化したデータを使用して、SessionItemNotFoundException クラスの新しいインスタンスを初期化します。
/// </summary>
/// <param name="context">転送元または転送先に関するコンテキスト情報を含んでいる System.Runtime.Serialization.StreamingContext。</param>
/// <param name="info">スローされている例外に関するシリアル化済みオブジェクト データを保持している System.Runtime.Serialization.SerializationInfo。</param>
/// <exception cref="System.Runtime.Serialization.SerializationException">クラス名が null であるか、または System.Exception.HResult が 0 です。</exception>
/// <exception cref="System.ArgumentNullException">info パラメータが null です。</exception>
protected SessionItemNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
this._key = info.GetString("key");
}
#endregion
#region Fields
/// <summary>
/// 例外の原因となった項目キーを取得します。
/// </summary>
private readonly string _key;
#endregion
#region Properties
/// <summary>
/// 例外の原因となった項目キーを取得します。
/// </summary>
public string Key
{
get
{
return this._key;
}
}
#endregion
#region Methods
/// <summary>
/// パラメータ名と追加の例外情報を使用して System.Runtime.Serialization.SerializationInfo オブジェクトを設定します。
/// </summary>
/// <param name="context">転送元または転送先に関するコンテキスト情報。</param>
/// <param name="info">シリアル化されたオブジェクト データを保持するオブジェクト。</param>
/// <exception cref="System.ArgumentNullException">info オブジェクトが null 参照 (Visual Basic の場合は Nothing) です。</exception>
[SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermissionFlag.SerializationFormatter)]
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
if (info == null)
{
throw new ArgumentNullException("info");
}
base.GetObjectData(info, context);
// 追加の例外情報がある場合、ここでパラメータ名と例外情報を info に追加します。
info.AddValue("key", this._key, typeof(string));
}
#endregion
}
}
テストケース (NUnit 2.4.8)
#pragma warning disable 1591
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Text;
using System.Web;
using System.Web.Hosting;
using System.Web.SessionState;
using NUnit.Framework;
using NUnit.Framework.SyntaxHelpers;
namespace MoroMoro.Enterprise.Web
{
[TestFixture]
[EditorBrowsable(EditorBrowsableState.Never)]
public class SessionStateAdapterTest
{
#region HttpContext Dummy
private void BuildHttpContext(bool sessionStateIsReadOnly, SimpleSessionStateItemCollection collection)
{
SimpleWorkerRequest workerRequest = new SimpleWorkerRequest("", "", "", "", null);
HttpContext.Current = new HttpContext(workerRequest);
if (collection != null)
{
HttpSessionStateContainer container =
new HttpSessionStateContainer("", collection, null, 1, true, HttpCookieMode.UseCookies, SessionStateMode.Custom, sessionStateIsReadOnly);
SessionStateUtility.AddHttpSessionStateToContext(HttpContext.Current, container);
}
}
private void BuildInvalidHttpContext()
{
SimpleWorkerRequest workerRequest = new SimpleWorkerRequest("", "", "", "", null);
HttpContext.Current = new HttpContext(workerRequest);
HttpContext.Current.Items.Add("AspSession", new object());
}
private sealed class SimpleSessionStateItemCollection : NameObjectCollectionBase, ISessionStateItemCollection
{
#region ISessionStateItemCollection メンバ
public void Clear()
{
base.BaseClear();
}
public bool Dirty
{
get;
set;
}
public NameObjectCollectionBase.KeysCollection Keys
{
get
{
return base.Keys;
}
}
public void Remove(string name)
{
base.BaseRemove(name);
}
public void RemoveAt(int index)
{
base.BaseRemoveAt(index);
}
public object this[int index]
{
get
{
return base.BaseGet(index);
}
set
{
base.BaseSet(index, value);
}
}
public object this[string name]
{
get
{
return base.BaseGet(name);
}
set
{
base.BaseSet(name, value);
}
}
#endregion
#region ICollection メンバ
public void CopyTo(Array array, int index)
{
((ICollection)this).CopyTo(array, index);
}
public int Count
{
get
{
return base.Count;
}
}
public bool IsSynchronized
{
get
{
return ((ICollection)this).IsSynchronized;
}
}
public object SyncRoot
{
get
{
return ((ICollection)this).SyncRoot;
}
}
#endregion
#region IEnumerable メンバ
public IEnumerator GetEnumerator()
{
return base.GetEnumerator();
}
#endregion
}
#endregion
[TearDown]
public void TearDown()
{
HttpContext.Current = null;
}
[Test]
public void 現在のHttpContextのインスタンスが再生成されるとSessionStateAdapterも再生成されること()
{
SimpleSessionStateItemCollection collection = new SimpleSessionStateItemCollection();
BuildHttpContext(false, collection);
SessionStateAdapter<object> adapter1 = SessionStateAdapter<object>.Current;
Assert.That(adapter1, Is.EqualTo(SessionStateAdapter<object>.Current));
BuildHttpContext(false, collection);
SessionStateAdapter<object> adapter2 = SessionStateAdapter<object>.Current;
Assert.That(adapter1, Is.Not.EqualTo(adapter2));
}
[Test]
public void 現在のHttpContextのインスタンスがNullの場合エラーになること()
{
bool wasThrown = false;
try
{
SessionStateAdapter<object> adapter1 = SessionStateAdapter<object>.Current;
}
catch (InvalidOperationException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void 現在のセッションステートがNullの場合エラーになること()
{
BuildHttpContext(false, null);
bool wasThrown = false;
try
{
SessionStateAdapter<object> adapter1 = SessionStateAdapter<object>.Current;
}
catch (InvalidOperationException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void 現在のセッションステートが正常に取得できない場合エラーになること()
{
BuildInvalidHttpContext();
bool wasThrown = false;
try
{
SessionStateAdapter<object> adapter1 = SessionStateAdapter<object>.Current;
}
catch (InvalidOperationException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void 項目を設定及び取得できること()
{
BuildHttpContext(false, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
object obj = new object();
adapter.SetItem(obj);
Assert.That(adapter.GetItem(), Is.EqualTo(obj));
}
[Test]
public void 項目を削除できること()
{
BuildHttpContext(false, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
adapter.SetItem(new object());
adapter.RemoveItem();
Assert.That(adapter.ItemIsExists(), Is.False);
}
[Test]
public void 項目の有無を確認できること()
{
BuildHttpContext(false, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
Assert.That(adapter.ItemIsExists(), Is.False);
adapter.SetItem(new object());
Assert.That(adapter.ItemIsExists(), Is.True);
adapter.RemoveItem();
Assert.That(adapter.ItemIsExists(), Is.False);
}
[Test]
public void 項目が存在しない場合項目の取得時にエラーになること()
{
BuildHttpContext(false, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
bool wasThrown = false;
try
{
adapter.GetItem();
}
catch (SessionItemNotFoundException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void 項目にNullを設定しようとするとエラーになること()
{
BuildHttpContext(false, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
bool wasThrown = false;
try
{
adapter.SetItem(null);
}
catch (ArgumentNullException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void セッションステートが読み取り専用の場合項目の設定時にエラーになること()
{
BuildHttpContext(true, new SimpleSessionStateItemCollection());
SessionStateAdapter<object> adapter = SessionStateAdapter<object>.Current;
bool wasThrown = false;
try
{
adapter.SetItem(new object());
}
catch (InvalidOperationException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
[Test]
public void セッションステートが読み取り専用の場合項目の削除時にエラーになること()
{
SimpleSessionStateItemCollection collection = new SimpleSessionStateItemCollection();
BuildHttpContext(false, collection);
SessionStateAdapter<object> adapter1 = SessionStateAdapter<object>.Current;
adapter1.SetItem(new object());
BuildHttpContext(true, collection);
SessionStateAdapter<object> adapter2 = SessionStateAdapter<object>.Current;
bool wasThrown = false;
try
{
adapter2.RemoveItem();
}
catch (InvalidOperationException)
{
wasThrown = true;
}
Assert.That(wasThrown, Is.True);
}
}
}
#pragma warning restore 1591
InProc の場合はそもそもシリアル化が行われていないので大丈夫。
[再現方法]
aspx に次の二つのコントロールを配置。
<asp:Label runat ="server" ID="Label1" />
<asp:Button runat="server" ID="Button1" Text="Button1" onclick="Button1_Click" />
コードビハインドに次のコードを記述。
protected void Page_Load(object sender, EventArgs e)
{
if (!this.IsPostBack)
{
object o = new object();
HttpContext.Current.Session["o1"] = o;
HttpContext.Current.Session["o2"] = o;
}
}
protected void Button1_Click(object sender, EventArgs e)
{
object o1 = HttpContext.Current.Session["o1"];
object o2 = HttpContext.Current.Session["o2"];
this.Label1.Text = object.ReferenceEquals(o1, o2).ToString();
}
Button1 をクリックすると、セッション状態のモードが InProc なら True と表示されるけど、StateServer だと False と表示される。
もちろん、InProc のように動作して欲しい。