UML软件工程组织

设计模式:Model View Presenter
作者:Jean-Paul Boodhoo   出处:www.microsoft.com

本页内容

  遵循 MVP
  使第一次测试通过
  填充 DropDownList
  实现视图接口
  未来计划

随着 UI 创建技术(如 ASP.NET 和 Windows? Form)的功能越来越强大,让 UI 层执行更多功能已成为普遍的做法。由于没有清晰的职责划分,UI 层经常成为逻辑层的全能代理,而后者实际上属于应用程序的其他层。Model View Presenter (MVP) 模式是专门适用于解决此问题的一种设计模式。为了证明我的观点,我将遵循 MVP 模式为 Northwind 数据库中的客户创建一个显示屏。

为什么 UI 层中不应有过多逻辑?如果没有手动运行应用程序,或未能维护自动执行 UI 组件的高深 UI 运行程序脚本,则很难测试应用程序 UI 层中的代码。这本身就是一个麻烦事,而更大的麻烦是应用程序中普通视图间大量的重复代码。当在 UI 层的不同部分之间复制执行特定业务功能的逻辑时,通常很难发现好的重构候选者。MVP 设计模式使得将逻辑和代码从 UI 层分离更为轻松,从而更易于简化测试可重用代码。

图 1 显示组成示例应用程序的主要层。请注意 UI 层和表示层使用不同的软件包。您可能期望它们使用相同的软件包,但实际上一个项目的 UI 层只应由两种 UI 元素组成 — 窗体和控件。在 Web Forms 项目中,通常是 ASP.NET Web Forms、用户控件和服务器控件的集合。在 Windows Forms 中,是 Windows Forms、用户控件和第三方程序库的集合。此附加层用于分离显示和逻辑。在表示层中可以有实际实现 UI 行为的对象,如验证显示、UI 的集合输入等。


 图 1 应用程序体系结构

遵循 MVP
 如图 2 所示,此项目的 UI 是非常标准的。加载页面时,屏幕将会显示一个填充了 Northwind 数据库中所有客户的下拉框。如果您从下拉列表中选择一个客户,将会更新页面,以显示该客户的信息。通过遵循 MVP 设计模式,您可将各种行为从 UI 层分离,将其置入自身的类中。图 3 显示一个类图表,表示涉及的不同类之间的关联。


 图 2 客户信息

需要注意的很重要的一点是,表示器并不了解应用程序实际 UI 层的任何知识。它知道它可以与接口对话,但不知道也不关心接口的具体实现。这就促使了在不同 UI 技术间表示器的重用。

我将使用测试驱动开发 (TDD) 来创建客户屏幕功能。图 4 显示我将使用的第一个测试的详细信息,以说明我期望在页面加载上观察到的行为。TDD 使我可以一次将精力集中于一个问题,只编写可使测试通过的足够代码,然后再继续进行。在此测试中,我将利用一个名为 NMock2 的模拟对象框架来构建接口的模拟实现。


 图 3 MVP 类图表

在我的 MVP 实现中,我决定将表示器作为其将要配合工作的视图的附属。在能使对象立即工作的状态下创建对象总是很好的。在此应用程序中,表示层实际上是依靠服务层来调用域功能的。由于此需求,因此也有必要建立一个带接口的表示器,通过该接口它可以与服务类进行对话。这将确保一旦建立表示器后,它就可以进行所有需要它来完成的工作。我将通过创建两个特定的模拟开始:一个用于服务层,一个用于表示器将要使用的视图。

为什么要创建模拟?单元测试的规则是尽可能的隔离测试,以将精力集中于一个特定的对象。在此测试中,我只关注表示器的预期行为。此时,我并不在意视图接口或服务接口的实际实现,我相信那些接口定义的协议,并相应的设置模拟来表现。这可确保我将测试集中于我所期望的表示器行为,无需考虑其所依赖的对象。调用其初始化方法后,我所期望的表示器行为如下。

首先,表示器应调用 ICustomerTask 服务层对象上的 GetCustomerList 方法(在测试中模拟)。请注意您可以使用 NMock 模仿模拟的行为。而对于服务层,我希望它可将模拟 ILookupCollection 返回到表示器。然后,在表示器从服务层检索 ILookupCollection 后,它应调用集合的 BindTo 方法并将方法传递到 ILookupList 的实现。通过使用 NMockExpect.Once 方法,我可以确定如果表示器没有调用该方法一次(且仅一次),则测试将失败。

编写该测试后,我将会处于完全非编辑状态。我将尽可能做最简单的工作来使测试通过。

返回页首

 使第一次测试通过
 首先编写测试的好处之一是我现在拥有了一个远景蓝图,可以遵循它来对测试进行编译并最终通过。第一次测试包括两个还不存在的接口。这些接口是正确编译代码的先决条件。我将从 IViewCustomerView 的代码开始:

public interface IViewCustomerView
{
ILookupList CustomerList { get; }
}

此接口提供一个属性,该属性可返回一个 ILookupList 接口实现。对于该问题,我还没有一个 ILookupList 接口,甚至没有实施工具。为了通过此测试,我不需要明确的实施工具,这样我可以继续创建 ILookupList 接口:?

public interface ILookupList { }

此时,ILookupList 接口看起来没什么用处。我的目标是编译并通过测试,而这些接口可以满足测试的需求。现在该将焦点转向我要实际测试的对象 - ViewCustomerPresenter 了。?此类尚不存在,但回头查看该测试,您可以从中得出两个重要事实:它有一个构造函数,该函数需要视图和服务实现作为依赖,并且有一个空的 Initialize 方法。图 5 中的代码显示如何编译测试。

请牢记表示器需要其所有依赖关系,以便富有成效的进行工作;这就是传入视图和服务的原因。我没有实现初始化方法,因此如果运行测试,我将得到 NotImplementedException。

如上所述,我没有盲目的编写表示器代码;通过查看测试,我已了解在调用初始化方法后表示器应表现的行为。行为的实现代码如下:

public void Initialize()
{
task.GetCustomerList().BindTo(view.CustomerList);
}

本文附带的源代码中有 CustomerTask 类(实现了 ICustomerTask 接口)中 GetCustomerList 方法的完整实现。虽然从实现和测试表示器的角度看,我还无需了解是否存在工作实现。但正是该抽象级别使我难以通过表示器类的测试。第一个测试现在正处于将要编译和运行的状态。这证明在调用表示器上的 Initialize 方法时,它将以我在测试中指定的方式与其依赖对象进行交互,并且最终当这些依赖对象的具体实现被插入表示器时,我可以确信结果视图(ASPX 页)将被客户列表所填充。

返回页首

 填充 DropDownList
 到目前为止,我主要处理了接口,抛开实际的实现细节,将精力集中于表示器。现在,该建立一些探测代码了,它最终将允许表示器以一种可测试的方式在 Web 页面上填充列表。实现此功能的关键是将在 LookupCollection 类的 BindTo 方法中发生的交互。如果您看一下图 6 中 LookupCollection 类的实现,就会注意到它实现了 ILookupCollection 接口。本文的源代码带有随附测试,可用于建立 LookupCollection 类的功能。

BindTo 方法的实现特别有趣。请注意在此方法中,集合将重复 ILookupDTO 实现本身的私有列表。ILookupDTO 是一个接口,可很好地与 UI 层的组合框绑定:

public interface ILookupDTO
{
string Value { get; }
string Text { get; }
}

图 7 显示用于测试查找集合的 BindTo 方法的代码,此方法将会帮助解释 LookupCollection 与 ILookupList 之间的预期交互。最后一点特别有趣。在此测试中,我希望在尝试向列表添加项目前,LookupCollection 将会调用 ILookupList 实现中的 Clear 方法。然后,我希望可以在 ILookupList 上调用 Add 10 次,而作为 Add 方法的参数,LookupCollection 将在实现 ILookupDTO 接口的对象中传递。若要使其与 Web 项目中的控件(例如下拉列表框)配合使用,则您需要创建一个 ILookupList 实现,该实现知道如何与 Web 项目中的控件配合使用。

本文附带的源代码包含一个名为 MVP.Web.Controls 的项目。该项目包含我选择用于创建完整解决方案的所有 Web 特定控件或类。为什么我将代码放在此项目中,而不是放在 APP_CODE 目录或 Web 项目中?回答是可测试性。在没有手动运行应用程序或没有使用某种测试程序自动执行 UI 测试的情况下,很难直接测试 Web 项目中的任何控件。MVP 模式使我可在不必手动运行应用程序的情况下考虑更高的抽象级别,并测试核心接口(ILookupList 和 ILookupCollection)的实现。我打算向 Web.Controls 项目中添加一个新类:WebLookupList 控件。图 8 显示此类的第一次测试。

某些事项在图 8 所示的测试中比较突出。显然,测试项目需要一个到 System.Web 库的引用,这样它就可以实例化 DropDownList Web 控件。进一步查看测试,您应了解 WebLookupList 类将会实现 ILookupList 接口。它还会将 ListControl 作为一个依赖对象。System.Web.UI.WebControls 命名空间中两个最常见的 ListControl 实现是 DropDownList 和 ListBox 类。图 8 中测试的主要功能是要确保 WebLookupList 正确的将实际 Web ListControl 的状态更新为其正在委派责任的状态。图 9 显示 WebLookupList 实现中涉及的类的类图表。我可以通过图 10 中的代码,满足对 WebLookupList 控件第一次测试的要求。


 图 9 WebLookupList 类

请记住,MVP 的一个关键是由创建视图接口引入的层的分离。表示器不了解视图的具体实现,以及它要对话的各个 ILookupList,它只知道它可以调用这些接口定义的任何方法。最后,WebLookupList 类是一个包装并委托至底层 ListControl 的类(在 System.Web.UI.WebControls 项目中定义的某些 ListControls 的基类)。利用这些代码,我可以编译并运行 WebLookupList 控件测试,现在测试应该顺利通过了。我可以为 WebLookupList 再添加一个测试,以测试 Clear 方法的实际行为:

[Test]
public void ShouldClearUnderlyingList()
{
ListControl webList = new DropDownList();
ILookupList list = new WebLookupList(webList);

webList.Items.Add(new ListItem("1", "1"));

list.Clear();

Assert.AreEqual(0, webList.Items.Count);
}

另外,我将测试在调用 WebLookupList 类自身的方法时,它是否会真正更改底层 ListControl (DropDownList) 的状态。WebLookupList 现在可以完成填充 Web Form 中 DropDownList 的功能。现在可将所有程序绑定在一起,就可获得已填充客户列表的 Web 页面下拉列表。

返回页首

 实现视图接口
 由于我在建立 Web Form 前端,因此 IViewCustomerView 接口的实现程序必须是 Web Form 或用户控件。出于此列的原因,我将其设为 Web Form。页面的常规外观已经创建,如图 2 所示。现在我只需要实现视图接口。切换到 ViewCustomers.aspx 页的源代码,我可以添加以下代码,表示需要此页来实现 IViewCustomersView 接口:

public partial class ViewCustomers :Page,IViewCustomerView

如果观察示例代码,您将会发现 Web 项目和 Presentation 是两个完全不同的程序集。而且,Presentation 项目没有引用任何 Web.UI 项目,这样可进一步维护分离层。另一方面,Web.UI 项目必须引用 Presentation 项目,因为视图接口和表示器都位于该项目中。

通过选择实现 IViewCustomerView 接口,现在我们的 Web 页面可以实现由该接口定义的任何方法或属性。当前 IViewCustomerView 接口上只有一个属性,是一个可返回 ILookupList 接口任何实现的 getter。我已向 Web.Controls 项目中添加了引用,这样就可以实例化 WebLookupListControl。我这样做是因为 WebLookupListControl 实现了 ILookupList 接口,并且它知道如何委托给 ASP.NET 中的实际 WebControls。请查看 ViewCustomer 页面的 ASPX,您将会发现客户列表只是一个 asp:DropDownList 控件:

<td>Customers:</td>
<td><asp:DropDownList id="customerDropDownList" AutoPostBack="true"
runat="server" Width="308px"></asp:DropDownList></td>
</tr>

利用这些已有代码,我可以快速的继续实现满足 IViewCustomerView 接口实现所需的代码:

public ILookupList CustomerList
{
get { return new WebLookupList(this.customerDropDownList);}
}

我现在需要调用表示器上的 Initialize 方法,以触发该方法实际执行一些操作。因此,视图需要能够实例化表示器,这样就可以调用它的方法了。如果回头查看一下表示器,您会记得它需要视图和服务与之配合使用。ICustomerTask 接口表示位于应用程序服务层的接口。服务层通常负责协调域对象之间的交互,并将这些交互的结果转换为“数据传输对象”(Data Transfer Objects, DTO),然后将其从服务层传递到表示层,再到 UI 层。但是此处有一个问题:我已规定表示器需要与视图和服务实现一同构造。

表示器的实际实例化将在 Web 页的源代码中进行。这是一个问题,因为 UI 项目没有引用任何服务层项目。但是,表示项目却引用了服务层项目。通过将一个重载构造函数添加到 ViewCustomerPresenterClass 中,可以解决此问题:

public ViewCustomerPresenter(IViewCustomerView view) :
this(view, new CustomerTask()) {}

这一新的构造函数同时满足了表示器视图和服务的实现要求,同时还可从服务层维护 UI 层的分离。现在完成源代码的后续代码就很简单了:

protected override void OnInit(EventArgs e)
{
base.OnInit(e);
presenter = new ViewCustomerPresenter(this);
}

protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack) presenter.Initialize();
}

请注意,表示器实例化的关键是:我将利用新建的构造函数重载,并且 Web Form 会将其自身作为实现视图接口的对象传入。

利用实现的源代码中的代码,我可以立即创建并运行应用程序。现在不需要源代码中的任何数据绑定代码,就可以使用客户名称列表来填充 Web 页上的 DropDownList。另外,已在最终一起工作的所有代码段上运行了测试分数,这可确保表示层体系结构将按预期运转。

现在我准备展示一下在 DropDownList 中显示选定客户信息所需的步骤,以此来总结我对 MVP 的讨论。再次重申,我将首先编写一个测试,来描述我所希望观察到的行为。(请参阅图 11)。

如上所述,我将利用 NMock 程序库来创建任务和视图接口的模拟。此特定测试将通过向服务层请求表示特定客户的 DTO 来验证表示器的行为。表示器从服务层检索到 DTO 后,它将直接更新视图上的属性,这样视图就不必了解任何有关如何正确显示对象信息的知识。简便起见,我将不再讨论 WebLookupList 控件上 SelectedItem 属性的实现;相反,我会将它留给您去检查源代码,以了解实现的详细信息。此测试真正展示的是在表示器从服务层检索 CustomerDTO 后,表示器和视图之间发生的交互。如果现在尝试运行测试,我将面临一个严重的失败,因为视图接口上的许多属性都还不存在。因此,我将继续进行并为 IViewCustomerView 接口添加必要的成员,如图 12 所示。

这些接口成员添加完成之后,我的 Web Form 也许会抱怨,因为它不再满足接口协议了,所以我必须返回 Web Form 的源代码并实现其余的成员。如上所述,Web 页的整个标记已经创建,同时表格单元格已被标记为 "runat=server" 属性,并且已根据其应显示的信息进行了命名。这样就可以使结果代码非常轻松的实现接口成员:

public string CompanyName
{
set { this.companyNameLabel.InnerText = value; }
}
public string ContactName
{
set { this.contactNameLabel.InnerText = value; }
}
...

随着 setter 属性的实现,现在只剩下最后一件事要完成。我需要一种方法来告诉表示器显示选定客户的信息。回头看看测试,您会发现此行为的实现位于表示器的 DisplayCustomerDetails 方法中。但是,此方法不带有任何参数。调用时,表示器将返回视图,从中提取其所需的任何信息(使用 ILookupList 检索),然后使用该信息检索选定客户的详细信息。从 UI 角度看,我需要做的就是将 DropDownList 的 AutoPostBack 属性设置为 true,我还需要将以下事件处理程序挂钩代码添加到页面的 OnInit 方法中:

protected override void OnInit(EventArgs e)
{
base.OnInit(e);
presenter = new ViewCustomerPresenter(this);
this.customerDropDownList.SelectedIndexChanged += delegate
{
presenter.DisplayCustomerDetails();
};
}

此事件处理程序可确保在下拉列表中选择新客户时,视图将请求表示器显示该客户的详细信息。

重要的是注意这是典型行为。当视图请求表示器执行操作时,它不会给予任何特定的详细信息,并且将由表示器来决定是否返回视图,并使用视图接口来获取其所需的任何信息。图 13 显示实现表示器中所需行为的代码。

希望您现在可以了解添加表示器层的价值了。表示器负责尝试检索需要显示其详细信息的客户 ID。这就是通常在源代码中执行的代码,但是它现在位于类中,我可以在任何表示层技术以外对其进行完全的测试和实践。

如果表示器能够从视图中检索有效的客户 ID,则它将转向服务层并请求表示该客户详细信息的 DTO。表示器获得 DTO 后,它将使用 DTO 中包含的信息更新视图。要注意的关键一点是视图接口的简单性,除 ILookupList 接口以外,视图接口完全由字符串 DataTypes 组成。表示器的最终职责是正确地转换和格式化从 DTO 中检索的信息,这样它就可以作为字符串,实际被传递到视图。虽然未在此例中说明,但表示器还可负责从视图中读取信息,并将其转换为服务层所期待的必要类型。

完成所有代码段后,我现在就可以运行应用程序了。首次加载页面时,我会获得一个客户列表,并且在 DropDownList 中显示(未选中)第一个客户。如果我选择一个客户,则会出现回发,视图与表示器之间发生交互,并且会使用相关的客户信息更新 Web 页面。

返回页首

 未来计划
 Model View Presenter 设计模式实际上就是许多开发人员已经熟悉的模板视图控制器的一个最新版本;两者的主要区别是 MVP 真正将 UI 从应用程序的域/服务层中分离。虽然从需求角度看,此示例十分简单,但它可以帮助您抽象化 UI 与应用程序其他层之间的交互。而且,现在您可了解多种方法:您可间接使用这些层来自动测试您的应用程序。随着您对 MVP 模式的深入研究,我希望您可以找到其他方法,从源代码中提取更多格式和条件逻辑,并将其置入可测试视图/表示器交互模型中。

请将您的问题和意见发送至 mmpatt@microsoft.com。

Jean-Paul Boodhoo 是 ThoughtWorks 的一名高级 .NET 交付专家,他曾参与了许多使用 .NET 框架和各种灵活方法的企业级应用程序交付。他经常利用测试驱动开发提供有关使用 .NET 功能的演示。可通过 mailtio:bitwisejp@gmail.com 或 www.jpboodhoo.com/blog 联系 Jean-Paul。

[Test]
public void ShouldLoadListOfCustomersOnInitialize()
{
mockery = new Mockery();
ICustomerTask mockCustomerTask = mockery.NewMock<ICustomerTask>();
IViewCustomerView mockViewCustomerView =
mockery.NewMock<IViewCustomerView>();
ILookupList mockCustomerLookupList = mockery.NewMock<ILookupList>();

ViewCustomerPresenter presenter =
new ViewCustomerPresenter(mockViewCustomerView,
mockCustomerTask);

ILookupCollection mockLookupCollection =
mockery.NewMock<ILookupCollection>();

Expect.Once.On(mockCustomerTask).Method(
"GetCustomerList").Will(Return.Value(mockLookupCollection));
Expect.Once.On(mockViewCustomerView).GetProperty(
"CustomerList").Will(Return.Value(mockCustomerLookupList));
Expect.Once.On(mockLookupCollection).Method(
"BindTo").With(mockCustomerLookupList);

presenter.Initialize();
}



public class ViewCustomerPresenter
{
private readonly IViewCustomerView view;
private readonly ICustomerTask task;

public ViewCustomerPresenter(
IViewCustomerView view, ICustomerTask task)
{
this.view = view;
this.task = task;
}

public void Initialize()
{
throw new NotImplementedException();
}
}



public class LookupCollection : ILookupCollection
{
private IList<ILookupDTO> items;

public LookupCollection(IEnumerable<ILookupDTO> items)
{
this.items = new List<ILookupDTO>(items);
}

public int Count { get { return items.Count; } }

public void BindTo(ILookupList list)
{
list.Clear();
foreach (ILookupDTO dto in items) list.Add(dto);
}
}


[Test]
public void ShouldBeAbleToBindToLookupList()
{
IList<ILookupDTO> dtos = new IList;
ILookupList mockLookupList = mockery.NewMock<ILookupList>();

Expect.Once.On(mockLookupList).Method("Clear");

for (int i = 0; i < 10; i++)
{
SimpleLookupDTO dto =
new SimpleLookupDTO(i.ToString(),i.ToString());
dtos.Add(dto);
Expect.Once.On(mockLookupList).Method("Add").With(dto);
}

new LookupCollection(dtos).BindTo(mockLookupList);
}


[Test]
public void ShouldAddItemToUnderlyingList()
{
ListControl webList = new DropDownList();
ILookupList list = new WebLookupList(webList);

SimpleLookupDTO dto = new SimpleLookupDTO("1","1");
list.Add(dto);

Assert.AreEqual(1, webList.Items.Count);
Assert.AreEqual(dto.Value, webList.Items[0].Value);
Assert.AreEqual(dto.Text, webList.Items[0].Text);
}


public class WebLookupList : ILookupList
{
private ListControl underlyingList;

public WebLookupList(ListControl underlyingList)
{
this.underlyingList = underlyingList;
}

public void Add(ILookupDTO dto)
{
underlyingList.Items.Add(new ListItem(dto.Text, dto.Value));
}
}


[Test]
public void ShouldDisplayCustomerDetails()
{
SimpleLookupDTO lookupDTO = new SimpleLookupDTO("1","JPBOO");

CustomerDTO dto = new CustomerDTO("BLAH", "BLAHCOMPNAME",
"BLAHCONTACTNAME", "BLAHCONTACTTILE", "ADDRESS", "CITY",
"REGION", "POSTALCODE", Country.CANADA, "4444444", "4444444");

Expect.Once.On(mockViewCustomerView).GetProperty(
"CustomerList").Will(Return.Value(mockCustomerLookupList));
Expect.Once.On(mockCustomerLookupList).GetProperty(
"SelectedItem").Will(Return.Value(lookupDTO));
Expect.Once.On(mockCustomerTask).Method(
"GetDetailsForCustomer").With(1).Will(Return.Value(dto));
Expect.Once.On(mockViewCustomerView).SetProperty(
"CompanyName").To(dto.CompanyName);
Expect.Once.On(mockViewCustomerView).SetProperty(
"ContactName").To(dto.ContactName);
Expect.Once.On(mockViewCustomerView).SetProperty(
"ContactTitle").To(dto.ContactTitle);
Expect.Once.On(mockViewCustomerView).SetProperty(
"Address").To(dto.Address);
Expect.Once.On(mockViewCustomerView).SetProperty(
"City").To(dto.City);
Expect.Once.On(mockViewCustomerView).SetProperty(
"Region").To(dto.Region);
Expect.Once.On(mockViewCustomerView).SetProperty(
"PostalCode").To(dto.PostalCode);
Expect.Once.On(mockViewCustomerView).SetProperty(
"Country").To(dto.CountryOfResidence.Name);
Expect.Once.On(mockViewCustomerView).SetProperty(
"Phone").To(dto.Phone);
Expect.Once.On(mockViewCustomerView).SetProperty("Fax").To(dto.Fax);

presenter.DisplayCustomerDetails();
}


public interface IViewCustomerView
{
ILookupList CustomerList{get;}
string CompanyName{set;}
string ContactName{set;}
string ContactTitle{set;}
string Address{set;}
string City{set;}
string Region{set;}
string PostalCode{set;}
string Country{set;}
string Phone{set;}
string Fax{set;}
}

public void DisplayCustomerDetails()
{
int? customerId = SelectedCustomerId;
if (customerId.HasValue)
{
CustomerDTO customer =
task.GetDetailsForCustomer(customerId.Value);
UpdateViewFrom(customer);
}
}

private int? SelectedCustomerId
{
get
{
string selectedId = view.CustomerList.SelectedItem.Value;

if (String.IsNullOrEmpty(selectedId)) return null;

int? id = null;

try
{
id = int.Parse(selectedId.Trim());
}
catch (FormatException) {}

return id;
}
}

private void UpdateViewFrom(CustomerDTO customer)
{
view.CompanyName = customer.CompanyName;
view.ContactName = customer.ContactName;
view.ContactTitle = customer.ContactTitle;
view.Address = customer.Address;
view.City = customer.City;
view.Region = customer.Region;
view.Country = customer.CountryOfResidence.Name;
view.Phone = customer.Phone;
view.Fax = customer.Fax;
view.PostalCode = customer.PostalCode;
}

 

版权所有:UML软件工程组织