使用 ASP.NET WebForms 视图引擎的 Model-View-Controller






4.28/5 (10投票s)
本文旨在演示一个成功的将 MVC 模式应用于传统 ASP.NET WebForms 引擎的示例框架。
引言
随着 ASP.NET MVC(Model-View-Controller)视图引擎的发布,许多开发者欣喜地认为这个旧模式终于来到了 .NET 世界。事实上,ASP.NET 早已有 MVC 视图引擎存在了。与普遍看法相反,我们熟悉的、也是许多开发者默认使用的 WebForms 视图引擎,完全支持 Model-View-Controller 范式。本文旨在演示一个成功的将 MVC 模式应用于传统 ASP.NET WebForms 引擎的示例框架。
背景
Model-View-Controller 存在已久,最早可追溯到 SmallTalk 语言/平台。你会发现,像许多设计模式一样,MVC 模式的实际解释和实现可能会有所不同。我无意提供一个遵循某种“黄金标准”的“纯粹”解决方案,而是想呈现一种适用于 ASP.NET WebForms 的 MVC 变体,并解释其用法和原因。
MVC 有三个内在部分,描述如下:
模型
模型通常被称为“领域实体”,本质上是数据。它是系统中信息传递的容器。通常,模型应包含属性和特性,但几乎没有或没有功能或操作,除了构造函数和可能的验证。例如,模型可以是联系信息或一组凭证。
View
视图接收模型的数据,并将其作为有意义的信息呈现给最终用户。一个视图可以将列表渲染成项目符号列表、下拉列表、多选框,甚至分页网格。可能有一个用于输出文本的控制台视图,以及另一个用于包含 3D 图形控件的丰富 Web 界面的视图。
我认为以下规则对于视图很重要:
- 视图只理解模型和它自己的“渲染空间”(Web、控制台、WinForm 等)。
- 视图主要关注将模型映射到渲染空间,并可能接收来自该渲染空间的变化(例如,用户输入)。
- 视图仅在必要范围内操作数据,以从模型中提取或放回模型中。业务逻辑应放在别处(我们稍后会与控制器讨论这一点)。
- 视图不理解如何将数据写入数据库(或检索它),也不了解诸如安全问题或计算等复杂逻辑。相反,视图公开属性,允许其条件由理解这些逻辑的另一个机制来设置。
- 视图通过公开的属性和方法接收信息。
- 视图就像电影《黑客帝国》中的大多数人……它们完全不知道自己被控制着,并且与控制器**毫无**关联。
- 由于视图不知道自己被控制着,如果视图需要额外信息或正在响应来自 UI 的命令,例如“删除”或“更新”,视图只能将这些值打包到一个容器中并引发一个事件。视图引发事件后就会忘记它。
视图与业务逻辑的分离很重要。在快速原型环境中,当我们为概念验证(POC)需要 UI 时,我们没有时间连接后端服务。一个对控制一无所知的视图可以轻松地用一个“模拟”控制器来设置,该控制器仅推送静态数据用于原型。另一方面,在生产环境中,可能有一个控制器管理实体列表,但必须根据用户不同地呈现它们。在这种情况下,可以使用同一个控制器调用多个视图。在一个大型开发团队中,一个与控制清晰分离的视图可以独立于控制器本身进行开发,例如,允许移动设备团队在设备上构建 UI,而 Silverlight 专家构建他们自己的丰富控件。
控制器
最后,我们有了控制器。控制器负责处理所有业务逻辑,获取数据,处理更改,响应事件,并使一切正常工作。控制器同时了解视图和模型。但是,我认为控制器也应该遵循自己的一套规则,以便稳固地融入企业软件环境。
- 控制器永远不应了解它正在处理视图的具体实例,而应只与视图接口交互。
- 控制器永远不应该关心视图逻辑。这意味着控制器不输出 HTML 片段,不理解下拉列表,也不必担心 JavaScript。控制器只处理模型列表和包含模型的事件,仅此而已。对控制器的一个真正考验是,无论视图是控制台输出、WinForm 还是 Web 界面,它都能完美运行。
- 控制器永远不应尝试与另一个控制器的视图进行交互。相反,控制器可以引发自己的事件供其他控制器消费。
- 控制器通过设置视图上的属性或调用方法,以及处理视图引发的事件来进行通信。
- 控制器永远不应直接与其他控制器对话,而应只与控制器接口对话(因此,“超级”控制器可能拥有子控制器,但同样,它与接口交互,而不是具体实例)。
通过遵循这些规则,您可以拥有一个灵活而强大的控制器架构。一个控制器可以生成原型所需的静态模型。另一个控制器可能会设置一些模拟对象来运行单元测试,而生产控制器则与服务交互以拉取模型列表并通过响应事件来操作模型。
传统的、开箱即用的 ASP.NET 似乎违反了这些原则。虽然代码隐藏将代码与显示元素分开,但两者之间存在很强的关联性。事实上,大多数页面都必须嵌入对用户控件的特定、静态引用才能正常工作。这造成了一种不允许我们讨论的灵活性的依赖关系。此外,像获取文本字段值并将其放入模型并发送到服务这样的操作,违反了控制器不理解视图细微之处或视图不了解其控制器的规则。
幸运的是,通过使用框架来定义视图作为控件和控制器,以及动态用户控件,我们可以构建一个遵循 MVC 原则的平台。动态用户控件非常重要,这样控制器才能处理视图接口,而工厂或其他机制可以在运行时调用视图的具体实例。包含的应用程序演示了这个框架,并为展示一个控制器和两个控制器“无知”但仍可通过事件进行交互的视图提供了概念验证。
Using the Code
包含的代码是使用 WebForms 实现 MVC 模式的概念验证。源应用程序包含一个完整的框架来支持应用程序。“模型”存在于我们的域项目中,目的是描述业务数据。在我们的例子中,这是一组 C# 语言的保留关键字。KeywordModel
包含关键字的唯一标识符和关键字本身的值。
代码很简单,应该可以“开箱即用”。您可以右键单击 Default.aspx 页面进行查看。您会看到一个非常简单的页面,包含两列:一列显示“当前选择”的关键字和下拉列表,另一列显示所有关键字的列表,最后是一个提交按钮。当您选择一个关键字时,它应该动态显示在“Selected”标签旁边,右侧列中对应的关键字将变为粗体。当您单击提交时,最后一个选定的关键字将在右侧列中显示为红色,以演示“记住”最近选择的服务器端操作(应用程序中没有查询字符串或表单解析)。
我创建了一个 Interface.Data 来描述与持久化数据的交互。有一个 Load
函数和一个 List
函数。为了节省时间,我没有连接到完整的数据库,而是简单地嵌入了一个 XML 资源,并使用了一个名为 KeywordDataAccess
的具体类来从 XML 中提取值。在我的应用程序中,我尽量使数据层保持专注(读、写、更新)。任何业务逻辑,如计算和排序,都位于数据层之上的服务层中。您会发现,数据层以上的所有内容都引用 IDataAccess<KeywordModel>
,而不是具体实例。
服务层仅调用数据层来获取数据。请注意,我们使用工厂模式来获取具体实例。这将使我们能够为单元测试“模拟”数据层,甚至用 SQL、Access 或其他数据层替换 XML 数据层,而无需更改其他任何东西,只需更改数据层的具体类和工厂返回的实例。当然,对于更大的应用程序,依赖注入框架将有助于实现这些——因此构造函数接收数据访问引用以进行构造函数注入。在这种情况下,服务唯一需要做的“额外”处理域模型是执行数据排序,然后呈现(请参阅 List
方法中的排序)。
最后,我们到达了表示层,以 Web 应用程序的形式提供服务。我们已经讨论了包含业务数据的模型。现在,我们还有两个部分:视图和控制器。我们先来谈谈控制器。
为了让应用程序真正扩展,控制器应独立于它需要控制的特定视图。控制器只是 T
类型(其中 T
是一个模型),并且可以操作任何 T
类型的视图。下图说明了控制器接口和实现。
请注意,控制器本身只有一个接收其将要管理的视图的构造函数。所有其他内容都由基类 DynamicController<T>
处理,它实现了 IController<T>
。我们还在基控制器中设置了几项内容:
- 上下文 — 生成一个 GUID 来管理回调和回发的状态。
- 控制器知道它正在管理的类型(
T
),因此我们的模型允许我们直接转到ServiceFactory
并获取T
的默认服务。请注意,还公开了一个SetService
方法,用于注入服务,例如用于使用模拟服务的单元测试。 - 控制器跟踪其视图(
_control
)并公开供其他控制器使用的事件。还记得我们的规则吗:控制器与控制器及其自己的控件通信,控件只引发事件,并且对被控制一无所知。 - 有一个模型列表以及一个“当前”模型。
- 控制器响应“select”事件。如果我们执行 CRUD 或删除等操作,我们将在控制器中处理,然后传递。在我们的示例中,我们只是将其传递,以便任何更高级别的控制器都可以响应该事件。
- 最后,我们有一个
Initialize
方法,用于在控制器首次创建时调用。后续的回发和回调不调用此方法,并且内部列表通过状态进行管理。当我们将讨论控件时,将会有更多内容。
接口很简单
using System;
using Interface.Domain;
namespace Interface.Controller
{
/// <summary>
/// Interface for a controller
/// </summary>
public interface IController<T> where T : IKey, new()
{
/// <summary>
/// Called the first time to initialize the controller
/// </summary>
void Initialize();
/// <summary>
/// Raised when a model is selected
/// </summary>
event EventHandler<EventArgs> OnSelect;
/// <summary>
/// Current active or selected model
/// </summary>
T CurrentModel { get; set; }
/// <summary>
/// Context (state management)
/// </summary>
Guid CurrentContext { get; set; }
}
}
以及基控制器类
/// <summary>
/// Controller base class
/// </summary>
/// <typeparam name="T">The type to control</typeparam>
public class DynamicController<T> : IController<T> where T : IKey, new()
{
/// <summary>
/// Manages the state of the controller and control
/// </summary>
public Guid CurrentContext
{
get { return _control.CurrentContext; }
set { _control.CurrentContext = value; }
}
/// <summary>
/// Inject the control and grab the default service
/// </summary>
/// <param name="control"></param>
public DynamicController(IControl<T> control)
{
_control = control;
_service = ServiceFactory.GetDefaultService<T>();
_control.NeedData += _ControlNeedData;
_control.OnSelect += _ControlOnSelect;
}
/// <summary>
/// Fired when the child control raises the select event
/// </summary>
/// <param name="sender">Sender</param>
/// <param name="e">Args</param>
private void _ControlOnSelect(object sender, EventArgs e)
{
if (OnSelect != null)
{
OnSelect(this, e);
}
}
/// <summary>
/// Needs the list again
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _ControlNeedData(object sender, EventArgs e)
{
_control.ControlList = _service.List();
}
/// <summary>
/// Fired when a model is selected from the list
/// </summary>
public event EventHandler<EventArgs> OnSelect;
/// <summary>
/// The control this controller will work with
/// </summary>
protected IControl<T> _control;
/// <summary>
/// Service related to the entity
/// </summary>
protected IService<T> _service;
/// <summary>
/// Current active or selected model
/// </summary>
public virtual T CurrentModel
{
get { return _control.CurrentModel; }
set { _control.CurrentModel = value; }
}
/// <summary>
/// Allows injection of the service
/// </summary>
/// <param name="service">The service</param>
public virtual void SetService(IService<T> service)
{
_service = service;
}
/// <summary>
/// Called the first time to initialize the controller
/// </summary>
public virtual void Initialize()
{
_control.Initialize(_service.List());
}
}
现在我们对控制器有了很好的了解,让我们继续讨论控件。该项目为同一个模型定义了两个控件:KeywordDropdownView
和 KeywordListView
。
这两个视图都实现了 IControl<T>
,即 KeywordModel
实体视图。控件更深入一些。您会注意到控件包含一个 IControlCache
,它允许控件持久化内部状态。例如,控件可以避免每次都访问服务(以及因此的数据)层来请求列表,而是可以将这些列表存储在缓存中。当缓存过期时,控件会引发 NeedData
事件,控制器将提供新列表。
包含了 IControlCache
实现的两个示例,以提供一些编码思路。其中,SimpleCache
只是将对象放入 Session
对象。另一个利用 ASP.NET Cache
对象,并将项目缓存在缓存中 5 分钟。
每个控件都通过上下文进行连接:唯一标识实例的 GUID。这解决了常见问题。许多开发人员乐于使用通用键将对象存储在会话中,例如:
...
Session["ControlList"] = ControlList;
...
问题是,如果您在同一个浏览器中打开多个标签页,每个标签页现在都在争夺同一个会话变量,并且跨页面可能会发生冲突。生成页面中的 GUID 并使用 GUID 进行缓存和会话存储,可以确保浏览器中的每个*实例*,即使它们共享同一个会话,也能得到妥善管理。
视图契约
/// <summary>
/// Interface for a generic control
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IControl<T> where T : IKey, new()
{
/// <summary>
/// Initializes the control with the list of items to manage
/// </summary>
/// <param name="list"></param>
void Initialize(List<T> list);
/// <summary>
/// Raised when something is selected from the list
/// </summary>
event EventHandler<EventArgs> OnSelect;
/// <summary>
/// Raised when the control needs data again
/// </summary>
event EventHandler<EventArgs> NeedData;
/// <summary>
/// The list for the control
/// </summary>
List<T> ControlList { get; set; }
/// <summary>
/// The current active model
/// </summary>
T CurrentModel { get; set; }
/// <summary>
/// Caching mechanism for the control to save/load state
/// </summary>
IControlCache ControlCache { get; set; }
/// <summary>
/// Context (state management) for the control
/// </summary>
Guid CurrentContext { get; set; }
}
以及视图基类
/// <summary>
/// A dynamic control
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class DynamicControl<T> : UserControl,
IControl<T> where T : IKey, new()
{
/// <summary>
/// Context for this control
/// </summary>
private Guid _context = Guid.Empty;
/// <summary>
/// Cache for the control
/// </summary>
public IControlCache ControlCache { get; set; }
/// <summary>
/// Unique context for storing state
/// </summary>
public Guid CurrentContext
{
get
{
if (_context.Equals(Guid.Empty))
{
_context = Guid.NewGuid();
}
return _context;
}
set
{
_context = value;
LoadState();
}
}
/// <summary>
/// List that the control works with
/// </summary>
public virtual List<T> ControlList { get; set; }
/// <summary>
/// The current selected model
/// </summary>
public virtual T CurrentModel { get; set; }
/// <summary>
/// Initializes the control with the list of items to manage
/// </summary>
/// <param name="list"></param>
public virtual void Initialize(List<T> list)
{
ControlList = list;
}
/// <summary>
/// Allow override of tis event
/// </summary>
public virtual event EventHandler<EventArgs> OnSelect;
/// <summary>
/// Need data?
/// </summary>
public virtual event EventHandler<EventArgs> NeedData;
/// <summary>
/// Load event - allow things to wire
/// and settle before trying to bring in state
/// </summary>
/// <param name="e"></param>
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
LoadState();
}
/// <summary>
/// Last thing to do is save state
/// </summary>
/// <param name="e"></param>
protected override void OnUnload(EventArgs e)
{
SaveState();
base.OnUnload(e);
}
/// <summary>
/// Save the control state
/// </summary>
public virtual void SaveState()
{
if (ControlCache != null)
{
object[] savedState = new object[] {ControlList ?? new List<T>(),
CurrentModel};
ControlCache.Save(CurrentContext, savedState);
}
}
/// <summary>
/// Load the control state
/// </summary>
public virtual void LoadState()
{
if (ControlCache != null)
{
object[] loadedState = ControlCache.Load(CurrentContext) as object[];
if (loadedState != null && loadedState.Length == 2)
{
ControlList = loadedState[0] as List<T> ?? ControlList;
CurrentModel = (T) loadedState[1];
}
}
}
}
KeywordDropdownView
视图只是从控制器接收模型列表,并将它们渲染成下拉列表。它包含一些 JavaScript 代码(嵌入为资源,这在 JavaScript and User Controls 101 中有更详细的介绍),用于响应选择。该示例演示了控件如何在客户端和服务器端都做出响应。当下拉列表中选择了一个新关键字时,它会在客户端引发一个事件,其他控件可以订阅该事件,称为 keywordDropdownChanged
。然后,它会回传到服务器端的控件,该控件会引发 OnSelect
事件以进行服务器端管理。我们将在后面更详细地检查这些事件。
我决定使用传统的、跨浏览器兼容的 JavaScript(我在 IE 6、IE 7、IE 8 和 FireFox 上进行了测试),而不是我最喜欢的 JQuery 库插件。这将为您提供一些关于动态 DOM 操作的示例,以及一种连接回调的方式。
KeywordListView
使用简单的 Repeater
将关键字列表渲染为标签,在客户端渲染为 DIV
标签。同样,这是一个概念验证,用于展示两种交互。首先,在客户端,控件注册 keywordDropdownChanged
事件。当事件被引发时,它会遍历其自身渲染的关键字列表,并将目标更改为粗体。您可以将此作为两个控件在彼此不知情或不了解实现的情况下进行通信的客户端示例。
第二个部分是主页面本身充当“主”控制器。它响应下拉控件的 OnSelect
事件,并将选定的关键字发送到列表控件。列表控件会持久化此信息,并在渲染新列表时将关键字着色为红色。您可以通过选择一个关键字然后单击“提交”来看到此行为。请注意,所有交互都通过事件完成,而不是通过解析表单数据并直接调用各种控件来响应的繁琐机制。
将 MVC 概念真正联系在一起的最后一部分是主页面。在这里,您会看到我们只处理抽象(IController<KeywordModel>
)。我认为我的框架与许多传统模型不同。大多数在 WebForms 中尝试 MVC 的方法仍然要求页面和控件之间存在很强的关联性。您有多少次发现自己通过使用 <%Register%>
标签指向控件来嵌入用户控件?这几乎没有给灵活性留下空间(例如,当用户使用 PDA 时获取不同的视图,但使用相同的控制器)。
在我们的示例中,我们只是在页面中放置了视图可以渲染的占位符。我们使用视图工厂来获取视图。有一个默认视图映射到下拉列表,还有一个为 Repeater
请求的更具体的视图。同一个控制器管理这两个视图,证明了控制器与其正在控制的视图之间真正的抽象。页面作为“主”控制器,生成了两个控制器都可以共享的上下文,并通过从一个控制器获取事件并将值推送到另一个控制器来协调控制器。控制器本身对彼此的存在以及它们所管理视图的实际实现一无所知——尽管有下拉列表和列表,控制器仍然只处理 IControl<KeywordModel>
。
这是页面的“骨架”
<%@ Page Language="C#" AutoEventWireup="true"
CodeBehind="Default.aspx.cs" Inherits="DynamicControls.Default" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<title>Dynamic Control Example</title>
</head>
<body>
<h1>Dynamic Control Example</h1>
<form
id="_form1"
runat="server">
<asp:ScriptManager ID="_sm" runat="server" />
<asp:HiddenField ID="_hdnGlobalContext" runat="server" />
<table>
<tr valign="top">
<td>
<asp:Panel
ID="_pnlLeft"
runat="server" />
</td>
<td>
<asp:Panel
ID="_pnlRight"
runat="server" />
</td>
<td>
<asp:Button
ID="_btnSubmit"
runat="server"
Text=" Submit " />
</td>
</tr>
</table>
</form>
</body>
</html>
这是“主控制器”的代码隐藏。请注意我们如何引用接口和工厂。我们甚至不会引用控件,除非是为了管理事件参数,这通常会隐藏在“主控制器”中。
using System;
using System.Web.UI;
using Domain;
using DynamicControls.Control;
using DynamicControls.Factory;
using Interface.Controller;
namespace DynamicControls
{
/// <summary>
/// Default page
/// </summary>
public partial class Default : Page
{
/// <summary>
/// Controller for the page
/// </summary>
private IController<KeywordModel> _ctrlrLeft;
/// <summary>
/// Another controller
/// </summary>
private IController<KeywordModel> _ctrlrRight;
private Guid _context = Guid.Empty;
/// <summary>
/// Init
/// </summary>
/// <param name="e"></param>
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
_ctrlrLeft = ControllerFactory.GetDefaultController(
ViewFactory.GetDefaultView<KeywordModel>(_pnlLeft));
_ctrlrRight = ControllerFactory.GetDefaultController(
ViewFactory.GetKeywordListView(_pnlRight));
_ctrlrLeft.OnSelect += _CtrlrLeftOnSelect;
}
/// <summary>
/// This is when viewstate is first available
/// to us to wire in the appropriate context
/// </summary>
/// <param name="e"></param>
protected override void OnPreLoad(EventArgs e)
{
base.OnPreLoad(e);
// bind a global context so the controllers and controls
// all can talk to each other with the same
// instance of state
if (string.IsNullOrEmpty(_hdnGlobalContext.Value))
{
_context = Guid.NewGuid();
_hdnGlobalContext.Value = _context.ToString();
}
else
{
_context = new Guid(_hdnGlobalContext.Value);
}
_ctrlrLeft.CurrentContext = _context;
_ctrlrRight.CurrentContext = _context;
}
/// <summary>
/// Let the right hand know what the left hand is doing
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _CtrlrLeftOnSelect(object sender, EventArgs e)
{
KeywordSelectArgs args = e as KeywordSelectArgs;
if (args != null)
{
_context = args.CurrentContext;
_ctrlrRight.CurrentContext = _context;
_ctrlrRight.CurrentModel = args.SelectedModel;
}
}
/// <summary>
/// Page load event
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
protected void Page_Load(object sender, EventArgs e)
{
if (!IsCallback && !IsPostBack)
{
_ctrlrLeft.Initialize();
_ctrlrRight.Initialize();
}
}
}
}
我特意为列表控件使用了直接脚本引用(而不是嵌入资源),以便您可以了解两种选项之间的区别。
注意列表控件中的状态管理是如何工作的。它将列表和选定的模型放入一个对象数组中,然后将这些对象发送到 IControlCache
。GUID 控制着缓存的键。主页面最初设置了它。当下拉列表触发回传时,它会将上下文传递给服务器,以便服务器可以使用上下文重置控件,并确保它们正在拉取正确的状态。否则,控件将无法“知道”列表是什么或当前选定的模型是什么。
以上解释了示例应用程序和框架。您可以在 http://apps.jeremylikness.com/mvcwebform/ 处尝试一个可用的版本。这是一个最基本概念验证,没有花哨的图形或字体。
关注点
我总是喜欢查看源代码,看看渲染了多少内容:内容是什么以及为什么。当然,您首先会注意到视图状态。由于我们通过页面中嵌入的 GUID(只需查看源代码并搜索 _hdn)来管理自己的状态,因此实际上可以关闭视图状态。
ASP.NET 连接了 __doPostBack
函数,该函数绑定到像提交按钮这样的控件。在此函数之后,您可以看到我们自定义控件的 JavaScript。第一个嵌入的引用了 WebResource.axd,该文件负责从程序集中提取 JavaScript 并将其渲染到浏览器。下一个是直接引用 KeywordListView.js,因为我们没有嵌入它。之后是几个连接 AJAX 框架的包含,然后就是我们实际的控件。
注意下拉框中连接的“onchange
”。我们在服务器端绑定了它,并输出了上下文以及各种控件的客户端 ID。我们这样做是因为代码必须在控件嵌套的任何深度都能工作。每个控件都连接了其相应的 init
函数,最后发出的代码是 AJAX 框架本身的初始化。
要扩展此框架,您需要创建不同类型的视图(IListControl
、IUpdateControl
等),它们执行不同的功能(我在自己的公司中最有趣的经历是创建用于管理大型数据集和分页的网格的控制器和控件)。您甚至可以尝试单元测试和构建不继承自 UserControl
的“测试”控件,因此不需要 HttpContext
即可工作。可能性很多,但希望这个例子能为您提供一个不错的基础。
历史
这是该示例的第一个版本。