在 ASP.NET 中实现 Model-View-Presenter






4.80/5 (25投票s)
ASP.NET 2.0 中 Model-View-Presenter 的三种实现方式。
引言
我使用 Model-View-Presenter 模式在我的 ASP.NET Web 应用程序中已经一年多了。我最初是通过一个事件驱动的实现方式接触到 MVP 的,是在一个智能客户端应用程序中,远在将其应用于 ASP.NET 之前。在花费了一些时间研究厚客户端环境后,我发现将 MVP 应用于 Web 会暴露出一系列需要解决的新问题。本文将阐述这些问题,并提供一种我认为可以最大化可用性和可测试性的 ASP.NET 实现方式。
本文将简要解释该模式的基础知识,并提供三种 ASP.NET 的 MVP 实现方式。我提供三种实现方式是为了让读者了解该模式可以如何变化,以及在每种方式中 ASPX 页面、ASCX 用户控件和 Presenter 的角色是如何定义的。在 ASP.NET 中,没有一种 MVP 实现方式是绝对正确的。无论采用哪种实现方式,都只是个人偏好和理论辩论的问题。
ASP.NET 中的 Model-View-Presenter:铺垫
默认情况下,ASP.NET 实现的是一种 Page Controller 模式,这种模式不利于关注点分离和可测试性。对 ASP.NET 运行时的依赖使得不生成不切实际的测试场景就难以测试该实现。由于视图特定的页面难以进行单元测试,我们就会寻找能够促进可测试性的模式。MVP 就是这样一种模式。
Model-View-Presenter 是一种旨在提高关注点分离和可测试性的设计模式。其主要目标是将视图特定的逻辑与域/业务逻辑分离开来。在设计面向对象应用程序时,我们希望对象之间是松散耦合的,并且易于重用。为了实现这一点,我们需要构建特定于某些任务的类和层,例如视图、表示、服务和数据访问等。在 ASP.NET 中,很容易将域或业务逻辑添加到我们的 ASPX 页面或 ASCX 用户控件类中,从而创建紧耦合的类,这些类变得难以重用和测试。MVP 旨在通过使用表示层来分离视图特定的逻辑与域/业务逻辑。
MVP 的次要目标是提高视图的可测试性。为依赖于 Session 或 ViewState、AJAX、HTML、Web 控件和域/业务对象的类编写单元测试是困难的。相反,我们将视图特定的逻辑保留在 ASPX/ASCX 类中,并将表示和域/业务逻辑从视图中移出,放到相应的类中。在 MVP 中,Presenter 充当视图与我们的域/业务逻辑之间的中介。
Martin Fowler 将 MVP 模式分为两种新模式:Supervising Controller 和 Passive View。与强制视图层与表示(控制器)严格分离的真正 MVC(Model-View-Controller)框架不同,在 ASP.NET 中,这种分离默认情况下并不强制执行。因此,如果不由开发人员付出认真的努力,就很难强制执行任何一种 MVP 实现方式,Supervising Controller 和 Passive View 之间的灰色地带也会扩大。作为经验法则,在创建 Presenter 时,我尽量将尽可能多的逻辑从我想要进行单元测试的视图中移出,并放入 Presenter 中。我让视图处理视图特定的逻辑,如 JavaScript、HTML、WebControls 和 AJAX 框架。由于我的视图中仍然存在一些逻辑,我倾向于将其归类为 Supervising Controller 而非 Passive View,经过数个不眠之夜的反复思考,我对 ASP.NET 中的 Supervising Controller 感到满意。
如果您需要比上述更详细的介绍,您可能会发现以下链接很有帮助
- GUI 架构
- Supervising Controller
- Passive View
- Presenter First
- ASP.NET 中的 Model View Presenter
- ASP.NET Supervising Controller (Model View Presenter) 从示意图到单元测试再到代码
ASP.NET 中 MVP 的不同实现方式
在 ASP.NET 中实现 MVP 时,我的设计遵循了几种不同的思想流派。其中一种方法由 Billy McCafferty 详细介绍,另一种由 Phil Haack 详细介绍。由于我最初接触 MVP 是在 Windows 应用程序中使用事件驱动的方式,所以我采用了我最熟悉的方式。Web 的无状态特性是我发现需要克服的第一个障碍。在 ASP.NET 中,我们会在每次服务器往返时重新创建 MVP 关系。持久化状态和引用“Page.IsPostBack
”变得必要。示例应用程序和下面的代码片段说明了我们如何重新创建 Presenter 并传递 IsPostBack
值来管理此困难。我通过 MVP 模式发现,它在 ASP.NET 中可以有多种变体,选择哪种实现方式实际上取决于个人喜好。我喜欢的实现方式包含一些上述文章的特点,以及我自己在过去一年中的一些发现。
下一节将分为三个部分,每个部分介绍一种实现方式。我将从我最初接触 ASP.NET MVP 开始,然后转向我更熟悉的事件驱动方法。最后,我将提供第三种我认为能提供更大重用性的实现方式。我随本文附带的示例应用程序包含每种实现的简单示例。每个部分将描述示例应用程序中的对应模块。我下面提供的代码片段非常简单,并不完整。它们足以说明其要点。
第一种实现方式
第一种实现方式是 Billy McCafferty 的。它引入了 ASPX 页面“视图初始化器和页面重定向器”的角色。视图是 ASCX 用户控件,Presenter 只知道描述视图的接口。ASPX 页面负责实例化 Presenter 并将其与视图和 Presenter 所需的任何模型对象一起传递。然后,它将 Presenter 附加到视图,以便视图在需要时可以引用 Presenter。最后,它调用 Presenter 上的“InitView
”来模拟 ASP.NET 中的 Page.IsPostBack
事件。
此示例已实现为示例应用程序中的“产品”模块。
注意:下面的代码用于突出此设计的主要要点。请参阅示例应用程序以获取工作模型。
Presenter
public class Presenter
{
public Presenter(IView view, IModel model)
{
this.view = view;
this.model = model;
}
public void InitView(bool isPostBack)
{
if(!isPostBack)
{
view.SetProducts(model.GetProducts());
}
}
public void SaveProducts(IList<IProduct> products)
{
model.SaveProducts(products);
}
}
ASPX 页面:起点
ASPX HTML 引用了 ASCX 用户控件,在代码隐藏中,我们有这个
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
presenter = new Presenter(view,model);
view.AttachPresenter(presenter);
presenter.InitView(Page.IsPostBack);
}
ASCX 用户控件
public void AttachPresenter(Presenter presenter)
{
this.presenter = presenter;
}
public void SetProducts(IList<IProduct> products)
{
// bind products to view
}
视图接口
public interface IView
{
void AttachPresenter(Presenter presenter);
void SetProducts(IList<IProduct> products);
}
第二种实现方式
第二种实现方式是事件驱动的方法。它像第一种一样,为 ASPX 页面使用了“视图初始化器和页面重定向器”的角色。ASCX 用户控件实现了一个视图接口,该接口声明了将要引发给 Presenter 的事件。视图对 Presenter 一无所知;它只知道如何引发事件。ASPX 页面初始化 Presenter,并将视图和任何模型对象传递给它。ASPX 页面不负责将 Presenter 附加到视图,也不负责调用 Presenter 上的“InitView
”。它的唯一任务是连接 Presenter 与视图实例和模型对象,并响应 Presenter 可能引发的事件,例如页面重定向或某种状态事件。
此示例已实现为示例应用程序中的“客户”模块。
注意:下面的代码用于突出此设计的主要要点。请参阅示例应用程序以获取工作模型。
Presenter
public class Presenter
{
public Presenter(IView view, IModel model)
{
this.view = view;
this.model = model;
this.view.OnViewLoad +=
new EventHandler<SingleValueEventArgs<bool>>(OnViewLoadListener);
this.view.SaveProducts +=
new EventHandler<SingleValueEventArgs<IList<IProduct>>>(SaveProductListener);
}
private void OnViewLoadListener(object sender,
SingleValueEventArgs<bool> isPostBack)
{
if (!isPostBack.Value)
{
// Set the view for the first time
view.SetProducts(model.GetProducts());
}
}
private void SaveProductListener(object sender,
SingleValueEventArgs<IList<IProduct>> products)
{
model.SaveProducts(products.Value);
}
}
ASPX 页面:起点
ASPX HTML 引用了 ASCX 用户控件,在代码隐藏中,我们有这个
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
presenter = new Presenter(view,model);
}
ASCX 用户控件
protected override void OnLoad(EventArgs e)
{
EventHandler eventHandler = OnViewLoad;
if (eventHandler != null)
{
// Invoke our delegate
eventHandler(this,
new SingleValueEventArgs<bool>(Page.IsPostBack));
}
base.OnLoad(e);
}
public void SetProducts(IList<IProduct> products)
{
// bind products to view
}
protected void btnSave_Click(object sender, EventArgs e)
{
// Raise our event
OnSaveProducts(GetProducts());
}
public event EventHandler<SingleValueEventArgs<string>> SaveProducts;
public virtual void OnSaveProducts(IList<IProduct>> products)
{
EventHandler<SingleValueEventArgs<IList<IProduct>>> eventHandler = SaveProducts;
if (eventHandler != null)
{
eventHandler(this, new SingleValueEventArgs<IList<IProduct>>(products));
}
}
视图接口
public interface IView
{
event EventHandler OnViewLoad;
event EventHandler<SingleValueEventArgs<IList<IProduct>>>SaveProducts;
void SetProducts(IList<IProduct> products);
}
第三种实现方式
第三种实现方式将创建 Presenter、传递视图和模型以及调用 Presenter 上的“InitView
”的责任委托给 ASCX 用户控件(视图)。视图对其 Presenter 拥有引用。Presenter 只知道视图的接口。ASPX 页面用于将用户控件添加到页面,仅此而已。由于第一种和第二种实现方式中的 ASPX 的职责现在已完全包含在 ASCX 用户控件的职责范围内,因此我的视图在整个应用程序中可以轻松重用。我可以将一个用户控件拖放到新页面上,它就会自带 Presenter,开箱即用。
此示例已实现为示例应用程序中的“员工”模块。
注意:下面的代码用于突出此设计的主要要点。请参阅示例应用程序以获取工作模型。
Presenter
public class Presenter
{
public Presenter(IView view, IModel model)
{
this.view = view;
this.model = model;
}
public void InitView(bool isPostBack)
{
if(!isPostBack)
{
view.SetProducts(model.GetProducts());
}
}
public void SaveProducts(IList<IProduct> products)
{
model.SaveProducts(products);
}
}
ASPX 页面
ASPX HTML 引用了 ASCX 用户控件,在代码隐藏中没有其他内容。
ASCX 用户控件:起点
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
presenter = new Presenter(this,model);
presenter.InitView(Page.IsPostBack);
}
public void SetProducts(IList<IProduct> products)
{
// bind products to view
}
视图接口
public interface IView
{
void SetProducts(IList<IProduct> products);
}
反思实现方式
对于每种实现方式,都有我偏爱和难以接受的特点。再次强调,由于目前 ASP.NET 没有真正的 MVP 框架,因此没有强制我遵循某一种实现的约束。确定您的 MVP 风格,很大程度上取决于您最适应的关注点分离程度,以及您认为您的类应该在多大程度上可测试。
我喜欢在前两种实现方式中使用 ASPX 页面作为“视图初始化器和页面重定向器”。我认为这是 ASPX 页面的合适职责,不应由视图承担。在我看来,视图只应关注视图特定的职责。确定什么是视图特定的职责是值得商榷的,也是我认为我经常纠结的问题。
在第二种实现方式中,我更喜欢视图不知道 Presenter 的方式。视图与 Presenter 解耦。它只引发事件,它引发的第一个事件“OnViewLoad
”表示控件的加载状态,并传递 Page
的 IsPostBack
值。Presenter 监听视图接口上的事件,并在响应时命令视图执行某些操作。ASPX 页面实例化 Presenter,并将视图实例和模型传递进去。它可以根据需要注册 Presenter 上的事件。
我不喜欢前两种实现方式的原因是,由于 ASPX 页面的参与,重用 ASCX 用户控件需要更多工作。如果我想将一个用户控件添加到另一个 ASPX 页面,我现在需要在新 ASPX 页面中实例化我的 Model-View-Presenter 关系。当存在嵌套的 MVP 关系时,一个用户控件可能包含另一个用户控件,这会变得很麻烦。如果我将实例化 Presenter 与视图和模型从 ASPX 页面委托给 ASCX 用户控件(视图)的责任分配出去,就可以消除这种依赖。结果是,视图承担了更多责任,但它现在也更具可重用性。这种增加的责任在哲学上可能不被我认同,但它有助于提高可用性,并且我的类仍然是可测试的。
虽然我喜欢事件驱动方法中视图与 Presenter 解耦的想法,但实际上并没有必要进行这种分离。使用事件并非总是直观和可靠的,为事件编写单元测试需要额外的努力。无法保证 Presenter 订阅了视图上的所有适当事件。
在解决了我的哲学争论,并最终对 ASP.NET 中 ASPX 页面、ASCX 用户控件和 Presenter 的特定职责感到满意后,我创建了第三种实现方式。这种第三种实现方式与第一种实现方式类似,但它省略了 ASPX“视图初始化器和页面重定向器”的角色。有利的一面是,由于我的视图更加自给自足,因此在整个应用程序中具有更高的可重用性。不利的一面是,我的视图现在承担了创建 Presenter 和响应 Presenter 可能引发的事件的额外责任。即使我可能觉得某些职责越界了,但我不断提醒自己,这是 ASP.NET 中的 MVP - 它不是像 Monorail 那样强制执行良好关注点分离的真正 MVC 框架。
结论
MVP 提供了许多优势,但对我来说,最重要的两个是关注点分离和可测试性。使用 MVP 会涉及相当多的开销,所以如果您不打算编写单元测试,我肯定会重新考虑使用该模式。
正如我们在三种不同的实现方式中看到的,在 ASP.NET 中实现该模式有多种方法。甚至有比我选择展示的更多的方法。选择最适合您需求的实现方式。我必须努力在 ASP.NET 中实现 MVP,并且需要愿意接受某些权衡。只要我的代码是可测试的、可重用的、可维护的,并且存在良好的关注点分离程度,我就很满意。
随着微软发布 ASP.NET MVC 框架的消息,未来有望出现一个强制执行良好关注点分离和可测试性的框架。我强烈推荐 Castle Project's Monorail 这个 MVC 框架。如果您等不及微软的 MVC 框架,或者此时不想将您的应用程序迁移到 Monorail,那么实现 MVP 可能是您的解决方案。
关于示例项目
示例项目是用 ASP.NET 2.0 和 C# 编写的。我使用的是 Northwind 数据库。我使用 SubSonic 作为我的数据访问层。由于 SubSonic 是使用 ActiveRecord 模式构建的,我确实需要使用接口来使我的 DAO 类可测试。对于我的单元测试,我使用 RhinoMocks 作为我的模拟框架。
示例应用程序由五个项目组成:WebApp、Model、Presentation layer、Presentation.Tests 和 SubSonic 数据访问层。这个示例很简单,应仅作为演示。我可能出于简洁和简化概念的目的在代码中做了一些事情。这是我没有提供通常创建的“生产级”代码(包含所有框架、工具和层)的免责声明。
历史
- 首次上传:2007 年 11 月 6 日。