我 从事专业开发迄今为止已有 15 年,在此之前,我利用业余时间从事开发至少也有 10 年了。与我这一代的大多数人一样,我是从 8 位计算机起步,然后转用 PC 平台的。随着计算机的复杂性日益增加,我编写的应用程序涵盖了从小型游戏到个人数据管理再到控制外部硬件的各项功能。
不过,在我职业生涯的前半段,我编写的所有软件都有一个共同点:即,都是运行在用户桌面 上的本地应用程序。我最早是在 90 年代初期听说万维网这件新生事物。那时我发现,通过构建 Web 应用程序,可以让我输入我的考勤卡信息而不必再费时费力从工作场所赶回办公室。
一言以蔽之,我感觉很是困惑。我当时满脑子是面向桌面的理念,很难接纳这种无状态的 Web。要添加很多让人头疼的调试、我没有 UNIX 服务器的超级用户访问权限,再加上这个奇怪的角括号,这些因素使年轻时的我止步不前,又重返桌面开发渡过了几年时光。
我远离了 Web 开发领域,虽然这领域显然很重要,但我并没有真正理解其编程模型。然后,Microsoft® .NET Framework 和 ASP.NET 发行了。尽管它与桌面应用程序编程有许多相似之处,但终于有了可以让我从事 Web 应用程序编程的框架。我可以构建窗口(页面),将控件与事件挂钩,而设计器使我不必处理那些讨厌的角括号。最妙的是,ASP.NET 会通过查看状态自动为我处理 Web 的无状态性质!我又重新找回了程序员的快乐 ... 至少在一段时间内是如此。
随着经验的增加,我的设计内容也随之丰富。我早已掌握了几种最佳实践,并将其应用到桌面 应用程序编程。其中的两种就是:
- 分离关注点:不要将 UI 逻辑与基础行为混合在一起。
- 自动单元测试:编写自动测试以验证您的代码是否按预期执行。
这些是适用于任何技术的基本原则。分离关注点是一项可帮助您处理复杂问题的基本原则。在 同一个对象内混合多种责任(如计算剩余的工时、设置数据格式并绘图)会给维护带来很大的负担。而自动测试对于获得生产质量的代码同时仍保持条理性至关重 要,尤其是当您更新现有项目时更是如此。
ASP.NET Web 窗体使入门变得非常简单,但另一方面,要将我的设计理念应用到 Web 应用程序却并非易事。Web 窗体坚持以 UI 为侧重点;其基本单位为页面。首先设计 UI 并拖曳控件。只需将应用程序逻辑融入页面的事件处理程序(与为 Windows® 应用程序启用的 Visual Basic® 非常相似)就万事大吉,这一点非常吸引人。
但进一步的页面单元测试常常有很大困难。您必须先启动所有 ASP.NET,然后才能在“页面”对象的生命周期内运行该对象。尽管可以通过发送 HTTP请求到服务器或自动化浏览器来测试 Web 应用程序,但这类测试非常脆弱(更换一个控制 ID 测试就会中断)、难以设置(您必须以完全相同的方式在每位开发人员的计算机上设置该服务器)并且运行缓慢。
当我开始构建更复杂的 Web 应用程序时,Web 窗体提供的抽象概念(如控件、视图状态和页面生命周期)就开始添乱而不是帮忙了。我需要花越来越多的时间来配置数据绑定(并编写大量的事件处理程序对其进 行正确配置)。我不得不想办法缩减视图状态的大小以便更快加载我的页面。Web 窗体要求每个 URL 均存在物理文件,这对于动态站点(例如 wiki)非常困难。而成功编写一个自定义的 WebControl 是一个非常复杂的过程,需要全面了解页面生命周期和 Visual Studio® 设计器。
自从在 Microsoft 工作开始,我就一直与其他人分享关于各种 .NET 难题的体验并希望可以解决一些难题。最近,作为开发人员参加有关模式与实践的 Web 客户端软件工厂项目 (codeplex.com/websf) 时,我遇到了一个这样的机会。特别是,模式与实践交付的内容之一就是自动单元测试。在 Web 客户端软件工厂中,我们建议使用 Model View Presenter (MVP) 模式构建可测试的 Web 窗体。
简而言之,MVP 并非将您的逻辑放入页面中,而是让您构建自己的页面,页面 (View) 只需调用单独的对象,即 Presenter。Presenter 对象随即执行响应视图上活动必需的任何逻辑,通常通过使用其它对象 (Model) 访问数据库、执行业务逻辑等。一旦这些步骤完成后,Presenter 会更新视图。这种方法提供了可测试性,因为表示器从 ASP.NET 管道中隔离出来;它与视图通过界面进行通信并可脱离页面独立进行测试。
MVP 的这种功能实现有点笨;您需要单独的视图界面,并且您必须在源代码文件中编写许多事件转发函数。但如果您想要在 Web 窗体应用程序中得到可测试的 UI,这差不多是最佳途径。任何改进均需要在基础平台中做出更改。
模型视图控制器模式
幸运的是,ASP.NET 团队听取了象我这样的开发人员的意见,并且已经着手开发一种新的 Web 应用程序框架,该框架与您所熟知并喜爱的 Web 窗体处于同一层级,但采用一组完全不同的设计目标:
- 使用 HTTP 和 HTML—不隐藏。
- 可测试性贯穿整个框架之内。
- 几乎在每个点均可扩展。
- 对输出进行总体控制。
由于此新框架基于模型视图控制器 (MVC) 模式,因此其名称为 ASP.NET MVC。MVC 模式最初在 70 年代发明,是 Smalltalk 技术的一部分。正如我将在本文中所展示的,它实际上非常适合 Web 的性质。MVC 将您的 UI 分为三种不同的对象:用于接收和处理输入的控制器;包含您域逻辑的模型;以及用于生成输出的视图。在 Web 环境中,输入为 HTTP 请求,而请求流程与图 1 类似。
Figure 1 MVC 模式请求流程 (单 击该图像获得较大视图)
这实际上与 Web 窗体中的过程完全不同。在 Web 窗体模型中,输入进入页面(视图),然后视图负责处理输入并生成输出。而 MVC 中这些责任是分开的。
因此,您可能立即会产生以下一种想法:“嘿,这太好了。我应该如何使用它?”或“为什么 我要编写这些对象,以前只需要编写一个对象?”这两个问题都问得很好,最好通过示例来进行解释。因此,我将使用 MVC Framework 编写一个小型 Web 应用程序以说明其优点。
创建控制器
要继续进行,您将需要安装 Visual Studio 2008 并获得 MVC Framework 的副本。在撰写本文时,ASP.NET 扩展的 2007 年 12 月社区技术预览 (CTP) 中已提供了这些内容 (asp.net/downloads/3.5-extensions)。您可能想要获取扩展 CTP 和 MVC 工具包,其中包括一些非常有用的帮助程序对象。一旦下载并安装 CTP 后,您将在“新建项目”对话框中获得名为“ASP.NET MVC Web 应用程序”的新项目类型。
选择“MVC Web 应用程序”项目后,会为您提供一个与常用网站或应用程序稍有不同的解决方案。该解决方案模板会创建一个带有一些新目录的 Web 应用程序(如图 2 中所示)。特别是 Controllers 目录包含各种控制器类,而 Views 目录(及其所有子目录)包含了各种视图。
Figure 2 MVC 项目结构
我将会编写一个非常简单的控制器,返回 URL 中传递的名称。右键单击 Controllers 文件夹并选择“添加项目”以显示常用的“添加项目”对话框以及一些新增加的内容,包括 MVC 控制器类和几个 MVC 视图组件。在此例中,我将添加一个非常富有想象力、名为 HelloController 的类:
复制代码
using System;
using System.Web;
using System.Web.Mvc;
namespace HelloFromMVC.Controllers
{
public class HelloController : Controller
{
[ControllerAction]
public void Index()
{
...
}
}
}
控制器类比页面简单得多。实际上,唯一真正必需做的就是从 System.Web.Mvc.Controller 中衍生并将 [ControllerAction] 属性置于您的操作方法中。操作是调用以响应特定 URL 请求的一种方法。操作负责执行所需的一切处理,然后呈现一个视图。我将通过编写一个将名称传递到视图的简单操作着手,如下所示:
复制代码
[ControllerAction]
public void HiThere(string id)
{
ViewData["Name"] = id;
RenderView("HiThere");
}
操作方法会通过 ID 参数从 URL 接收该名称(稍后会介绍方法),将其存储在 ViewData 集合中,然后呈现名为 HiThere 的视图。
在讨论如何调用此方法,或该视图的显示内容之前,我希望说一说可测试性。还记得我之前关 于测试 Web 窗体页面类有多难的评论吗?控制器的测试简单得多。实际上,控制器可以直接实例化,而调用操作方法无需任何附加的基础结构。您不需要 HTTP 上下文,也不需要服务器,只要测试工具即可。作为示例,我在图 3 中为此类包括了 Visual Studio Team System (VSTS) 测试单元。
Figure 3 Controller Unit Test
复制代码
namespace HelloFromMVC.Tests
{
[TestClass]
public class HelloControllerFixture
{
[TestMethod]
public void HiThereShouldRenderCorrectView()
{
TestableHelloController controller = new
TestableHelloController();
controller.HiThere("Chris");
Assert.AreEqual("Chris", controller.Name);
Assert.AreEqual("HiThere", controller.ViewName);
}
}
class TestableHelloController : HelloController
{
public string Name;
public string ViewName;
protected override void RenderView(
string viewName, string master, object data)
{
this.ViewName = viewName;
this.Name = (string)ViewData["Name"];
}
}
}
下面将进行几项操作。实际的测试相当简单:实例化该控制器,使用预期的数据调用该方法, 然后检查呈现的视图是否正确。我通过创建测试专用的子类覆盖 RenderView 方法进行检查。这可以缩短实际创建 HTML 的时间。我只关心是否将正确的数据发送到视图以及是否呈现了正确的视图。我不关心此测试视图本身的底层详细信息。
创建视图
当然,最终我必须生成一些 HTML,因此,让我们创建该 HiThere 视图。要进行此操作,首先,我将在解决方案中的 Views 文件夹下创建名为 Hello 的新文件夹。默认情况下,控制器将在 Views\<控制器前缀> 文件夹(控制器前缀为控制器类的名称去掉 "Controller" 字样)中查找视图。因此,对于 HelloController 呈现的视图,它会在 Views\Hello 中查找。解决方案的查找结果如图 4 所示。
Figure 4 将视图添加到项目中 (单 击该图像获得较大视图)
视图的 HTML 如下所示:
复制代码
<html >
<head runat="server">
<title>Hi There!</title>
</head>
<body>
<div>
<h1>Hello, <%= ViewData["Name"] %></h1>
</div>
</body>
</html>
应注意以下几件事。没有 runat="server" 标记。没有 form 标记。没有控件声明。实际上,这看起来更象传统的 ASP 而不是 ASP.NET。请注意,MVC 视图仅负责生成输出,因此其不需要任何 Web 窗体页面所需的事件处理或复杂控件。
MVC Framework 借用了 .aspx 文件格式作为一种有用的文本模板语言。如果需要,甚至可以使用源代码,但默认情况下,源代码文件如下所示:
复制代码
using System;
using System.Web;
using System.Web.Mvc;
namespace HelloFromMVC.Views.Hello
{
public partial class HiThere : ViewPage
{
}
}
没有页面初始化或加载方法,没有事件处理程序,除了基类声明以外没有任何内容,基类声明 为 ViewPage 而不是 Page。这就是 MVC 视图所需的一切。运行该应用程序,导航至 http://localhost:<端口>/Hello/HiThere/Chris,您将看到如图 5 所示的内容。
Figure 5 成功的 MVC 视图 (单 击该图像获得较大视图)
如果您看到的并非如图 5 所示,而是难以理解的意外情况,请不要惊慌。如果您将 HiThere.aspx 文件设置为 Visual Studio 中的活动文档,则当按 F5 后,Visual Studio 将尝试直接访问 .aspx 文件。由于 MVC 视图要求控制器在显示前运行,因此尝试直接导航至该页面将不起作用。只需将该 URL 编辑为与图 5 中所示的内容相匹配,即可正常工作。
MVC Framework 如何知道调用我的操作方法?该 URL 甚至没有文件扩展名。答案是 URL 路由。如果您仔细查看 global.asax.cs 文件,则会看到如图 6 所示的代码段。全局 RouteTable 会存储 Route 对象的集合。每个 Route 说明一个 URL 窗体以及对其进行何种操作。默认情况下,会向该表中添加两个路由。第一个是该方法的内容。它说明每个 URL 在服务器名后均由三部分组成,第一部分应为控制器名,第二部分为操作名称,而第三部分为 ID 参数。
Figure 6 Route Table
复制代码
public class Global : System.Web.HttpApplication
{
protected void Application_Start(object sender, EventArgs e)
{
// Change Url= to Url="[controller].mvc/[action]/[id]"
// to enable automatic support on IIS6
RouteTable.Routes.Add(new Route
{
Url = "[controller]/[action]/[id]",
Defaults = new { action = "Index", id = (string)null },
RouteHandler = typeof(MvcRouteHandler)
});
RouteTable.Routes.Add(new Route
{
Url = "Default.aspx",
Defaults = new {
controller = "Home",
action = "Index",
id = (string)null },
RouteHandler = typeof(MvcRouteHandler)
});
}
}
复制代码
Url = "[controller]/[action]/[id]"
此默认路由是能让我的 HiThere 方法得以调用的路由。请记住此 URL:http://localhost/Hello/HiThere/Chris?此路由将 Hello 与控制器、HiThere 与操作以及 Chris 与 ID 一一对应。MVC Framework 随即创建 HelloController 实例,调用 HiThere 方法,然后将 Chris 作为 ID 参数的值传递。
此默认路由为您提供了许多功能,但您也可以添加自己的路由。例如,我想要一个真正友好的 站点,好友们只需输入他们的姓名即可获得个性化的问候。如果我在路由表的顶部添加以下路由
复制代码
RouteTable.Routes.Add(new Route
{
Url = "[id]",
Defaults = new {
controller = "Hello",
action = "HiThere" },
RouteHandler = typeof(MvcRouteHandler)
});
随后,我只需访问 ,我的操作仍处于调用状态,而我将会看到熟悉的友好问候。
系统如何知道调用哪个控制器和操作?答案是 Defaults 参数。它利用新的 C# 3.0 匿名类型语法来创建一个伪词典。Route 上的 Defaults 对象可包含任意附加的信息,对于 MVC,它还可以包含一些众所周知的条目:即控制器和操作。如果 URL 中没有指定控制器或操作,则其将使用 Defaults 中的名称。这就是为什么即使我在 URL 中忽略它们,但仍可以将我的请求映射到正确的控制器和操作。
还有一件事需要注意:还记得我说过“添加到表格的顶部”吗?如果您将其置于底部,将会出 现错误。路由根据先到先得的原则进行工作。当处理 URL 时,路由系统会自上至下浏览表格,并且使用第一个匹配的路由。在本例中,默认路由 "[controller]/[action]/[id]" 匹配,因为它们是操作和 ID 的默认值。这样,它会继续查找 ChrisController,但我没有控制器,因此会出现错误。
稍大的示例
现在,我已经说明了 MVC Framework 的基础知识,将为您展示一个更大的示例,实现比仅显示字符串更多的功能。wiki 是一种可以在浏览器中进行编辑的网站。可以轻松地添加或编辑页面。我使用 MVC Framework 编写了一个小型的示例 wiki。“编辑此页面”屏幕如图 7 所示。
Figure 7 编辑主页 (单击该 图像获得较大视图)
您可以检查本文的代码下载以查看如何实现底层 wiki 逻辑。现在我想重点说明 MVC Framework 如何使 Web 上的 wiki 获取变得简单。让我们先设计 URL 结构。我想要以下各项:
- /[pagename] 显示该名称的页面。
- /[pagename]?version=n 显示页面的请求版本,其中 0 = 当前版本,1 = 以前的版本,以此类推。
- /Edit/[pagename] 打开该页的编辑屏幕。
- /CreateNewVersion/[pagename] 是为提交编辑而传入的 URL。
让我们从 wiki 页面的基本显示开始。我为它创建了一个名为 WikiPageController 的新类。接下来,我会添加一个名为 ShowPage 的操作。启动的 WikiPageController 如图 8 所示。ShowPage 方法相当简单。WikiSpace 和 WikiPage 类分别表示一组 wiki 页面和特定的页面(及其修订)。此操作只需加载模型并调用 RenderView。但此处的 "new WikiPageViewData" 行是什么意思?
Figure 8 WikiPageController Implementation of ShowPage
复制代码
public class WikiPageController : Controller
{
ISpaceRepository repository;
public ISpaceRepository Repository
{
get {
if (repository == null)
{
repository = new FileBasedSpaceRepository(
Request.MapPath("~/WikiPages"));
}
return repository;
}
set { repository = value; }
}
[ControllerAction]
public void ShowPage(string pageName, int? version)
{
WikiSpace space = new WikiSpace(Repository);
WikiPage page = space.GetPage(pageName);
RenderView("showpage",
new WikiPageViewData
{
Name = pageName,
Page = page,
Version = version ?? 0
});
}
}
我前面的示例说明了一种将数据从控制器传递到视图的方法:即 ViewData 词典。词典非常方便,但也很危险。它们几乎包含一切内容,您不能获取内容的任何 IntelliSense®,并且由于 ViewData 词典属于 Dictionary<string, object> 类型,它将消耗内容,您必须计算所有一切。
当您了解在视图中将需要什么数据后,就可以传递强类型化的 ViewData 对象。在我的示例中,我创建了一个简单的对象 (WikiPageViewData),如图 9 中所示。此对象将 wiki 页面信息带到视图,同时还携带了一些实用工具方法,执行获取 wiki 标记的 HTML 版本这类任务。
Figure 9 WikiPageViewData Object
复制代码
public class WikiPageViewData {
public string Name { get; set; }
public WikiPage Page { get; set; }
public int Version { get; set; }
public WikiPageViewData() {
Version = 0;
}
public string NewVersionUrl {
get {
return string.Format("/CreateNewVersion/{0}", Name);
}
}
public string Body {
get { return Page.Versions[Version].Body; }
}
public string HtmlBody {
get { return Page.Versions[Version].BodyAsHtml(); }
}
public string Creator {
get { return Page.Versions[Version].Creator; }
}
public string Tags {
get { return string.Join(",", Page.Versions[Version].Tags); }
}
}
现在,我已经定义了视图数据,那么,我如何使用它呢?在 ShowPage.aspx.cs 中,您将看到以下内容:
复制代码
namespace MiniWiki.Views.WikiPage {
public partial class ShowPage : ViewPage<WikiPageViewData>
{
}
}
请注意,我将基类类型定义为 ViewPage<WikiPageViewData>。这意味着页面的 ViewData 属性为 WikiPageViewData 类型,而不是象以前示例中的“Dictionary”。
.aspx 文件中的实际标记非常简单:
复制代码
<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
AutoEventWireup="true" CodeBehind="ShowPage.aspx.cs"
Inherits="MiniWiki.Views.WikiPage.ShowPage" %>
<asp:Content
ID="Content1"
ContentPlaceHolderID="MainContentPlaceHolder"
runat="server">
<h1><%= ViewData.Name %></h1>
<div id="content" class="wikiContent">
<%= ViewData.HtmlBody %>
</div>
</asp:Content>
请注意,当引用 ViewData 时,我没有使用索引操作符 []。由于我现在有强类型化的 ViewData,我可以直接访问该属性。不需要进行任何计算,而 Visual Studio 会提供 IntelliSense。
目光敏锐的读者将会注意到此文件中的 <asp:Content> 标记。没错,“母版页”确实可以与 MVC 视图配合使用。并且“母版页”还可以成为视图。让我们看看“母版页”的源代码:
复制代码
namespace MiniWiki.Views.Layouts
{
public partial class Site :
System.Web.Mvc.ViewMasterPage<WikiPageViewData>
{
}
}
相关标记如图 10 中所示。现在,“母版页”将获得与视图完全相同的 ViewData 对象。我已经将“母版页”的基类声明为 ViewMasterPage<WikiPageViewData>,因此,我拥有了正确类型的 ViewData。我会在那里设置各种 DIV 标记以对页面进行布局,填写版本列表,然后以常用内容占位符收尾。
Figure 10 Site.Master
复制代码
<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.master.cs" Inherits="MiniWiki.Views.Layouts.Site" %> <%@ Import Namespace="MiniWiki.Controllers" %> <%@ Import Namespace="MiniWiki.DomainModel" %> <%@ Import Namespace="System.Web.Mvc" %> <html > <head runat="server"> <title><%= ViewData.Name %></title> <link href="http://http://www.cnblogs.com/Content/Site.css" rel="stylesheet" type="text/css" /> </head> <body> <div id="inner"> <div id="top"> <div id="header"> <h1><%= ViewData.Name %></h1> </div> <div id="menu"> <ul> <li><a href="http://Home">Home</a></li> <li> <%= Html.ActionLink("Edit this page", new { controller = "WikiPage", action = "EditPage", pageName = ViewData.Name })%> </ul> </div> </div> <div id="main"> <div id="revisions"> Revision history: <ul> <% int i = 0; foreach (WikiPageVersion version in ViewData.Page.Versions) { %> <li> <a href="http://<%= ViewData.Name %>?version=<%= i %>"> <%= version.CreatedOn %> by <%= version.Creator %> </a> </li> <% ++i; } %> </ul> </div> <div id="maincontent"> <asp:ContentPlaceHolder ID="MainContentPlaceHolder" runat="server"> </asp:ContentPlaceHolder> </div> </div> </div> </body> </html>
另一件需要注意的事是对 Html.ActionLink 的调用。以下是呈现帮助程序的一个示例。各种视图类均具有两种属性,Html 和 Url。每种均有输出 HTML 代码块的有用方法。在本例中,Html.ActionLink 获取一个对象(此处为匿名类型)并通过路由系统将其返回。这将会生成一个 URL,该 URL 将路由至我指定的控制器和操作。这样一来,无论我如何更改路由,“编辑此页面”链接将始终指向正确的位置。
您可能还注意到,我还不得不依靠手动构建链接(到先前页面版本的链接)。遗憾的是,当前 的路由系统在涉及查询字符串时生成 URL 的功能不是十分完善。这应会在框架的后续版本中得到修复。
创建表单和回发
现在,让我们看看控制器上的 EditPage 操作:
复制代码
[ControllerAction]
public void EditPage(string pageName)
{
WikiSpace space = new WikiSpace(Repository);
WikiPage page = space.GetPage(pageName);
RenderView("editpage",
new WikiPageViewData {
Name = pageName,
Page = page });
}
同样,该操作所做的不多—它只是呈现指定页面的视图。视图中的内容变得更加有趣,如图 11 中所示。此文件构建了一个 HTML 表单,但没有出现 Runat="server"。Url.Action helper 用于生成表单回发的 URL。其中还使用了几种不同的 HTML 帮助程序(如 TextBox、TextArea 和 SubmitButton)。它们会出色完成您的预期目标:为各种输入字段生成 HTML。
Figure 11 EditPage.aspx
复制代码
<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="EditPage.aspx.cs" Inherits="MiniWiki.Views.WikiPage.EditPage" %> <%@ Import Namespace="System.Web.Mvc" %> <%@ Import Namespace="MiniWiki.Controllers" %> <asp:Content ID="Content1" ContentPlaceHolderID="MainContentPlaceHolder" runat="server"> <form action="<%= Url.Action( new { controller = "WikiPage", action = "NewVersion", pageName = ViewData.Name })%>" method=post> <% if (ViewContext.TempData.ContainsKey("errors")) { %> <div id="errorlist"> <ul> <% foreach (string error in (string[])ViewContext.TempData["errors"]) { %> <li><%= error%></li> <% } %> </ul> </div> <% } %> Your name: <%= Html.TextBox("Creator", ViewContext.TempData.ContainsKey("creator") ? (string)ViewContext.TempData["creator"] : ViewData.Creator)%> <br /> Please enter your updates here:<br /> <%= Html.TextArea("Body", ViewContext.TempData.ContainsKey("body") ? (string)ViewContext.TempData["body"] : ViewData.Body, 30, 65)%> <br /> Tags: <%= Html.TextBox( "Tags", ViewContext.TempData.ContainsKey("tags") ? (string)ViewContext.TempData["tags"] : ViewData.Tags)%> <br /> <%= Html.SubmitButton("SubmitAction", "OK")%> <%= Html.SubmitButton("SubmitAction", "Cancel")%> </form> </asp:Content>
处理 Web 编程最头疼的事情之一就是表单中的错误。更确切地说,您想要显示错误信息,但同时想要保留原来输入的数据。我们都有过那种经历,在填写一张有 35 个字段的表单时出现一个错误,程序却只是提供一堆错误信息和一张新的空白表单。MVC Framework 使用 TempData 存储以前输入信息,以便可以重新填入表单。这是 ViewState 实际上在 Web 窗体中变得非常简单的原因,因为保存控件的内容几乎是自动的。
我想在 MVC 中如法炮制,因此引入了 TempData。TempData 是一种词典,与非类型化的 ViewData 很相似。不过,TempData 的内容仅针对单一请求存在,随后就会被删除。要了解如何使用此方法,请参阅图 12,NewVersion 操作。
Figure 12 NewVersion Action
复制代码
[ControllerAction] public void NewVersion(string pageName) { NewVersionPostData postData = new NewVersionPostData(); postData.UpdateFrom(Request.Form); if (postData.SubmitAction == "OK") { if (postData.Errors.Length == 0) { WikiSpace space = new WikiSpace(Repository); WikiPage page = space.GetPage(pageName); WikiPageVersion newVersion = new WikiPageVersion( postData.Body, postData.Creator, postData.TagList); page.Add(newVersion); } else { TempData["creator"] = postData.Creator; TempData["body"] = postData.Body; TempData["tags"] = postData.Tags; TempData["errors"] = postData.Errors; RedirectToAction(new { controller = "WikiPage", action = "EditPage", pageName = pageName }); return; } } RedirectToAction(new { controller = "WikiPage", action = "ShowPage", pageName = pageName }); }
首先,它创建一个 NewVersionPostData 对象。这是另一个帮助程序对象,具有存储记入的内容和进行某些验证的属性和方法。为加载 postData 对象,我将使用 MVC 工具包的帮助程序。UpdateFrom 实际上是工具包提供的扩展方法,它使用反射将表单字段的名称与我的对象中属性的名称相对映。最终结果是,所有字段值均载入到我的 postData 对象中。不过,UpdateFrom 使用起来确实有缺点,由于它直接从 HttpRequest 获取表单数据,使单元测试变得更为困难。
NewVersion 检查的第一项是 SubmitAction。如果用户单击“确定”按钮并确实想要发布编辑的页面,则此项检查将通过。如果此处有任何其它值,操作会重定向回 ShowPage,只是重新显示原来的页面。
如果用户确实单击了“确定”,则检查 postData.Errors 属性。这将在记入内容上运行一些简单的验证。如果没有任何错误,我会将新版本的页面重新写入 wiki。不过,如果出现错误,情况会变得饶有趣味。
如果出现错误,我会设置 TempData 词典的各个字段,以便其包含 PostData 的内容。然后,我会重定向回“编辑”页面。现在,由于已设置 TempData,页面将重新显示以用户上次记入的值初始化的表单。
处理记入、验证和 TempData 的这个过程现在变得有些烦琐,并且需要多做一些手动工作。将来发行的版本应包括至少会将一些 TempData 检查自动化的帮助程序方法。关于 TempData 的最后一个注意事项是:TempData 的内容存储在用户的服务器端会话中。如果您关闭会话,TempData 将无法正常工作。
创建控制器
现在,wiki 的基础已在发挥功效,但继续进行之前,我想要明确实现中的以下几个要点。例如,Repository 属性用于分离 wiki 的逻辑与物理存储。您可以提供在文件系统(正如我所做的那样)、数据库或您想要的任何位置中存储内容的存储库。遗憾的是,我需要解决两个问题。
首先,我的控制器类与具体的 FileBasedSpaceRepository 类紧密地连在一起。我需要一个默认值,以便在属性没有设置时,也能合理使用。更糟的是,磁盘上文件的路径在这里也是硬编码的。最起码,它应取自配置。
其次,我的对象必须依赖存储库,否则无法运行。对于良好的设计,存储库实际应为构造函数 参数,而不是属性。但我无法将其添加到构造函数中,因为 MVC Framework 要求控制器上的构造函数不能有参数。
幸运的是,我可以通过一个扩展性挂接摆脱此限制:即控制器工厂。控制器工厂的功能正如其 名称所指:它创建 Controller 实例。您只需要创建一个类实现 IControllerFactory 接口并向 MVC 系统注册即可。您可以为所有控制器或仅为指定的类型注册控制器工厂。图 13 所示为 WikiPageController 的控制器工厂,其现在将存储库作为构造函数参数传递。在这种情况下,实现非常烦琐,但它可以创建能使用更强大工具(特别是依赖关系注入容器)的控制器。 无论如何,现在我拥有了将控制器依赖关系分离到对象中(易于管理和维护)的所有详细信息。
Figure 13 Controller Factory
复制代码
public class WikiPageControllerFactory : IControllerFactory { public IController CreateController(RequestContext context, Type controllerType) { return new WikiPageController( GetConfiguredRepository(context.HttpContext.Request)); } private ISpaceRepository GetConfiguredRepository(IHttpRequest request) { return new FileBasedSpaceRepository(request.MapPath("~/WikiPages")); } }
此工作的最后一步是向框架注册工厂。通过 ControllerBuilder 类可进行此操作,方法是将以下行添加到 Application_Start 方法中的 Global.asax.cs(路由前后均可):
复制代码
ControllerBuilder.Current.SetControllerFactory(
typeof(WikiPageController), typeof(WiliPageControllerFactory));
这将注册 WikiPageController 的工厂。如果此项目中有其他控制器,它们不会使用此工厂,因为此工厂仅针对 WikiPageController 类型进行了注册。如果您想要将工厂设置为供所有控制器使用,还可以调用 SetDefaultControllerFactory。
其他扩展点
控制器工厂只是框架扩展性的起点。本文中无法详述所有的细节,因此我将仅仅说明要点。首 先,如果您想要输出的内容不是 HTML,或想要使用其他模板引擎而不是 Web 窗体,可将控制器的 ViewFactory 设为其他项。您可以实现 IviewFactory 界面,然后即可完全控制如何生成输出。这对于生成 RSS、XML 或图形非常有用。
正如您所见到的,路由系统非常灵活。但路由系统中没有任何内容是 MVC 专用的。每个路由均有一个 RouteHandler 属性;目前为止,我始终将其设为 MvcRouteHandler。但可以实现 IRouteHandler 界面并将路由系统与其他 Web 技术挂接。将来推出的框架将附带 WebFormsRouteHandler,并且其他技术也会在将来利用通用路由系统的优势。
控制器并非必须从 System.Web.Mvc.Controller 衍生。控制器需要做的仅仅是实现 IController 界面,该界面只有称为 Execute 的一种方法。您可以从中进行任何操作。另一方面,如果您想将 Controller 基类的几种行为组合在一起,您可以覆盖 Controller 的许多虚拟函数:
- OnPreAction、OnPostAction 和 OnError 可让您将每个已执行操作上的预处理和后处理连接起来。OnError 为您提供在控制器内处理错误的机制。
- 当 URL 路由到控制器但控制器没有实现路由中请求的操作时,会调用 HandleUnknownAction。默认情况下,此方法会抛出一个异常,但您可以用所需的操作覆盖默认值。
- InvokeAction 是一种方法,它负责解决调用何种操作方法并进行调用。如果您想要自定义过程(例如,除去 [ControllerAction] 属性的要求),应使用该方法。
还有其他几种针对 Controller 的虚拟方法,但这些方法主要是测试挂接而不是作为扩展点。例如,RedirectToAction 是虚拟的,因此您可以创建实际并不进行重定向的衍生类。这样,您不需要完全运行 Web 服务器就能测试重定向操作。
要告别 Web 窗体吗?
现在您可能在想:“Web 窗体会面临怎样的命运?MVC 会取代它吗?”答案是否定的!Web 窗体是一种普及技术,Microsoft 将继续支持并改进它。它在许多应用程序中发挥着重要的作用;例如,可使用 Web 窗体创建典型的 Intranet 数据库报表应用程序,所花的时间比使用 MVC 编写短得多。此外,Web 窗体支持大量的控件,许多控件均具备非常先进的功能,可以大大提高效率。
那么,什么时候应该选择 MVC 呢?这主要取决于您的要求和喜好。您是否正在为获得想要的 URL 格式而烦恼?您是否想要对 UI 进行单元测试?以上情况均需要依靠 MVC。反之,如果您要显示许多数据,提供可编辑的网格和优良的树形视图控件?那么,您暂时最好还是使用 Web 窗体。
今后,MVC Framework 很可能在 UI 控制部分有所改进,但在便利性上,它可能始终不及 Web 窗体,因为后者具备大量拖曳功能。同时,ASP.NET MVC Framework 为 Web 开发人员提供了一种在 Microsoft .NET Framework 中构建 Web 应用程序的新方法。Framework 针对可测试性设计、推倡使用 HTTP 并且几乎在每个点均可扩展。对于那些想要完全控制其 Web 应用程序的开发人员来说,这是一个对 Web 窗体的诱人补充。
Chris Tavares 是 Microsoft 模式和实施方案小组的一名开发人员,他致力于帮助开发社区了解在 Microsoft 平台上构建系统的最佳实践。他还是 ASP.NET MVC 小组的虚拟成员,帮助您设计新的框架。可以通过 cct@tavaresstudios.com 与 Chris 取得联系。
我 从事专业开发迄今为止已有 15 年,在此之前,我利用业余时间从事开发至少也有 10 年了。与我这一代的大多数人一样,我是从 8 位计算机起步,然后转用 PC 平台的。随着计算机的复杂性日益增加,我编写的应用程序涵盖了从小型游戏到个人数据管理再到控制外部硬件的各项功能。
不过,在我职业生涯的前半段,我编写的所有软件都有一个共同点:即,都是运行在用户桌面 上的本地应用程序。我最早是在 90 年代初期听说万维网这件新生事物。那时我发现,通过构建 Web 应用程序,可以让我输入我的考勤卡信息而不必再费时费力从工作场所赶回办公室。
一言以蔽之,我感觉很是困惑。我当时满脑子是面向桌面的理念,很难接纳这种无状态的 Web。要添加很多让人头疼的调试、我没有 UNIX 服务器的超级用户访问权限,再加上这个奇怪的角括号,这些因素使年轻时的我止步不前,又重返桌面开发渡过了几年时光。
我远离了 Web 开发领域,虽然这领域显然很重要,但我并没有真正理解其编程模型。然后,Microsoft® .NET Framework 和 ASP.NET 发行了。尽管它与桌面应用程序编程有许多相似之处,但终于有了可以让我从事 Web 应用程序编程的框架。我可以构建窗口(页面),将控件与事件挂钩,而设计器使我不必处理那些讨厌的角括号。最妙的是,ASP.NET 会通过查看状态自动为我处理 Web 的无状态性质!我又重新找回了程序员的快乐 ... 至少在一段时间内是如此。
随着经验的增加,我的设计内容也随之丰富。我早已掌握了几种最佳实践,并将其应用到桌面 应用程序编程。其中的两种就是:
- 分离关注点:不要将 UI 逻辑与基础行为混合在一起。
- 自动单元测试:编写自动测试以验证您的代码是否按预期执行。
这些是适用于任何技术的基本原则。分离关注点是一项可帮助您处理复杂问题的基本原则。在 同一个对象内混合多种责任(如计算剩余的工时、设置数据格式并绘图)会给维护带来很大的负担。而自动测试对于获得生产质量的代码同时仍保持条理性至关重 要,尤其是当您更新现有项目时更是如此。
ASP.NET Web 窗体使入门变得非常简单,但另一方面,要将我的设计理念应用到 Web 应用程序却并非易事。Web 窗体坚持以 UI 为侧重点;其基本单位为页面。首先设计 UI 并拖曳控件。只需将应用程序逻辑融入页面的事件处理程序(与为 Windows® 应用程序启用的 Visual Basic® 非常相似)就万事大吉,这一点非常吸引人。
但进一步的页面单元测试常常有很大困难。您必须先启动所有 ASP.NET,然后才能在“页面”对象的生命周期内运行该对象。尽管可以通过发送 HTTP请求到服务器或自动化浏览器来测试 Web 应用程序,但这类测试非常脆弱(更换一个控制 ID 测试就会中断)、难以设置(您必须以完全相同的方式在每位开发人员的计算机上设置该服务器)并且运行缓慢。
当我开始构建更复杂的 Web 应用程序时,Web 窗体提供的抽象概念(如控件、视图状态和页面生命周期)就开始添乱而不是帮忙了。我需要花越来越多的时间来配置数据绑定(并编写大量的事件处理程序对其进 行正确配置)。我不得不想办法缩减视图状态的大小以便更快加载我的页面。Web 窗体要求每个 URL 均存在物理文件,这对于动态站点(例如 wiki)非常困难。而成功编写一个自定义的 WebControl 是一个非常复杂的过程,需要全面了解页面生命周期和 Visual Studio® 设计器。
自从在 Microsoft 工作开始,我就一直与其他人分享关于各种 .NET 难题的体验并希望可以解决一些难题。最近,作为开发人员参加有关模式与实践的 Web 客户端软件工厂项目 (codeplex.com/websf) 时,我遇到了一个这样的机会。特别是,模式与实践交付的内容之一就是自动单元测试。在 Web 客户端软件工厂中,我们建议使用 Model View Presenter (MVP) 模式构建可测试的 Web 窗体。
简而言之,MVP 并非将您的逻辑放入页面中,而是让您构建自己的页面,页面 (View) 只需调用单独的对象,即 Presenter。Presenter 对象随即执行响应视图上活动必需的任何逻辑,通常通过使用其它对象 (Model) 访问数据库、执行业务逻辑等。一旦这些步骤完成后,Presenter 会更新视图。这种方法提供了可测试性,因为表示器从 ASP.NET 管道中隔离出来;它与视图通过界面进行通信并可脱离页面独立进行测试。
MVP 的这种功能实现有点笨;您需要单独的视图界面,并且您必须在源代码文件中编写许多事件转发函数。但如果您想要在 Web 窗体应用程序中得到可测试的 UI,这差不多是最佳途径。任何改进均需要在基础平台中做出更改。
模型视图控制器模式
幸运的是,ASP.NET 团队听取了象我这样的开发人员的意见,并且已经着手开发一种新的 Web 应用程序框架,该框架与您所熟知并喜爱的 Web 窗体处于同一层级,但采用一组完全不同的设计目标:
- 使用 HTTP 和 HTML—不隐藏。
- 可测试性贯穿整个框架之内。
- 几乎在每个点均可扩展。
- 对输出进行总体控制。
由于此新框架基于模型视图控制器 (MVC) 模式,因此其名称为 ASP.NET MVC。MVC 模式最初在 70 年代发明,是 Smalltalk 技术的一部分。正如我将在本文中所展示的,它实际上非常适合 Web 的性质。MVC 将您的 UI 分为三种不同的对象:用于接收和处理输入的控制器;包含您域逻辑的模型;以及用于生成输出的视图。在 Web 环境中,输入为 HTTP 请求,而请求流程与图 1 类似。
Figure 1 MVC 模式请求流程 (单 击该图像获得较大视图)
这实际上与 Web 窗体中的过程完全不同。在 Web 窗体模型中,输入进入页面(视图),然后视图负责处理输入并生成输出。而 MVC 中这些责任是分开的。
因此,您可能立即会产生以下一种想法:“嘿,这太好了。我应该如何使用它?”或“为什么 我要编写这些对象,以前只需要编写一个对象?”这两个问题都问得很好,最好通过示例来进行解释。因此,我将使用 MVC Framework 编写一个小型 Web 应用程序以说明其优点。
创建控制器
要继续进行,您将需要安装 Visual Studio 2008 并获得 MVC Framework 的副本。在撰写本文时,ASP.NET 扩展的 2007 年 12 月社区技术预览 (CTP) 中已提供了这些内容 (asp.net/downloads/3.5-extensions)。您可能想要获取扩展 CTP 和 MVC 工具包,其中包括一些非常有用的帮助程序对象。一旦下载并安装 CTP 后,您将在“新建项目”对话框中获得名为“ASP.NET MVC Web 应用程序”的新项目类型。
选择“MVC Web 应用程序”项目后,会为您提供一个与常用网站或应用程序稍有不同的解决方案。该解决方案模板会创建一个带有一些新目录的 Web 应用程序(如图 2 中所示)。特别是 Controllers 目录包含各种控制器类,而 Views 目录(及其所有子目录)包含了各种视图。
Figure 2 MVC 项目结构
我将会编写一个非常简单的控制器,返回 URL 中传递的名称。右键单击 Controllers 文件夹并选择“添加项目”以显示常用的“添加项目”对话框以及一些新增加的内容,包括 MVC 控制器类和几个 MVC 视图组件。在此例中,我将添加一个非常富有想象力、名为 HelloController 的类:
复制代码
using System;
using System.Web;
using System.Web.Mvc;
namespace HelloFromMVC.Controllers
{
public class HelloController : Controller
{
[ControllerAction]
public void Index()
{
...
}
}
}
控制器类比页面简单得多。实际上,唯一真正必需做的就是从 System.Web.Mvc.Controller 中衍生并将 [ControllerAction] 属性置于您的操作方法中。操作是调用以响应特定 URL 请求的一种方法。操作负责执行所需的一切处理,然后呈现一个视图。我将通过编写一个将名称传递到视图的简单操作着手,如下所示:
复制代码
[ControllerAction]
public void HiThere(string id)
{
ViewData["Name"] = id;
RenderView("HiThere");
}
操作方法会通过 ID 参数从 URL 接收该名称(稍后会介绍方法),将其存储在 ViewData 集合中,然后呈现名为 HiThere 的视图。
在讨论如何调用此方法,或该视图的显示内容之前,我希望说一说可测试性。还记得我之前关 于测试 Web 窗体页面类有多难的评论吗?控制器的测试简单得多。实际上,控制器可以直接实例化,而调用操作方法无需任何附加的基础结构。您不需要 HTTP 上下文,也不需要服务器,只要测试工具即可。作为示例,我在图 3 中为此类包括了 Visual Studio Team System (VSTS) 测试单元。
Figure 3 Controller Unit Test
复制代码
namespace HelloFromMVC.Tests
{
[TestClass]
public class HelloControllerFixture
{
[TestMethod]
public void HiThereShouldRenderCorrectView()
{
TestableHelloController controller = new
TestableHelloController();
controller.HiThere("Chris");
Assert.AreEqual("Chris", controller.Name);
Assert.AreEqual("HiThere", controller.ViewName);
}
}
class TestableHelloController : HelloController
{
public string Name;
public string ViewName;
protected override void RenderView(
string viewName, string master, object data)
{
this.ViewName = viewName;
this.Name = (string)ViewData["Name"];
}
}
}
下面将进行几项操作。实际的测试相当简单:实例化该控制器,使用预期的数据调用该方法, 然后检查呈现的视图是否正确。我通过创建测试专用的子类覆盖 RenderView 方法进行检查。这可以缩短实际创建 HTML 的时间。我只关心是否将正确的数据发送到视图以及是否呈现了正确的视图。我不关心此测试视图本身的底层详细信息。
创建视图
当然,最终我必须生成一些 HTML,因此,让我们创建该 HiThere 视图。要进行此操作,首先,我将在解决方案中的 Views 文件夹下创建名为 Hello 的新文件夹。默认情况下,控制器将在 Views\<控制器前缀> 文件夹(控制器前缀为控制器类的名称去掉 "Controller" 字样)中查找视图。因此,对于 HelloController 呈现的视图,它会在 Views\Hello 中查找。解决方案的查找结果如图 4 所示。
Figure 4 将视图添加到项目中 (单 击该图像获得较大视图)
视图的 HTML 如下所示:
复制代码
<html >
<head runat="server">
<title>Hi There!</title>
</head>
<body>
<div>
<h1>Hello, <%= ViewData["Name"] %></h1>
</div>
</body>
</html>
应注意以下几件事。没有 runat="server" 标记。没有 form 标记。没有控件声明。实际上,这看起来更象传统的 ASP 而不是 ASP.NET。请注意,MVC 视图仅负责生成输出,因此其不需要任何 Web 窗体页面所需的事件处理或复杂控件。
MVC Framework 借用了 .aspx 文件格式作为一种有用的文本模板语言。如果需要,甚至可以使用源代码,但默认情况下,源代码文件如下所示:
复制代码
using System;
using System.Web;
using System.Web.Mvc;
namespace HelloFromMVC.Views.Hello
{
public partial class HiThere : ViewPage
{
}
}
没有页面初始化或加载方法,没有事件处理程序,除了基类声明以外没有任何内容,基类声明 为 ViewPage 而不是 Page。这就是 MVC 视图所需的一切。运行该应用程序,导航至 http://localhost:<端口>/Hello/HiThere/Chris,您将看到如图 5 所示的内容。
Figure 5 成功的 MVC 视图 (单 击该图像获得较大视图)
如果您看到的并非如图 5 所示,而是难以理解的意外情况,请不要惊慌。如果您将 HiThere.aspx 文件设置为 Visual Studio 中的活动文档,则当按 F5 后,Visual Studio 将尝试直接访问 .aspx 文件。由于 MVC 视图要求控制器在显示前运行,因此尝试直接导航至该页面将不起作用。只需将该 URL 编辑为与图 5 中所示的内容相匹配,即可正常工作。
MVC Framework 如何知道调用我的操作方法?该 URL 甚至没有文件扩展名。答案是 URL 路由。如果您仔细查看 global.asax.cs 文件,则会看到如图 6 所示的代码段。全局 RouteTable 会存储 Route 对象的集合。每个 Route 说明一个 URL 窗体以及对其进行何种操作。默认情况下,会向该表中添加两个路由。第一个是该方法的内容。它说明每个 URL 在服务器名后均由三部分组成,第一部分应为控制器名,第二部分为操作名称,而第三部分为 ID 参数。
Figure 6 Route Table
复制代码
public class Global : System.Web.HttpApplication
{
protected void Application_Start(object sender, EventArgs e)
{
// Change Url= to Url="[controller].mvc/[action]/[id]"
// to enable automatic support on IIS6
RouteTable.Routes.Add(new Route
{
Url = "[controller]/[action]/[id]",
Defaults = new { action = "Index", id = (string)null },
RouteHandler = typeof(MvcRouteHandler)
});
RouteTable.Routes.Add(new Route
{
Url = "Default.aspx",
Defaults = new {
controller = "Home",
action = "Index",
id = (string)null },
RouteHandler = typeof(MvcRouteHandler)
});
}
}
复制代码
Url = "[controller]/[action]/[id]"
此默认路由是能让我的 HiThere 方法得以调用的路由。请记住此 URL:http://localhost/Hello/HiThere/Chris?此路由将 Hello 与控制器、HiThere 与操作以及 Chris 与 ID 一一对应。MVC Framework 随即创建 HelloController 实例,调用 HiThere 方法,然后将 Chris 作为 ID 参数的值传递。
此默认路由为您提供了许多功能,但您也可以添加自己的路由。例如,我想要一个真正友好的 站点,好友们只需输入他们的姓名即可获得个性化的问候。如果我在路由表的顶部添加以下路由
复制代码
RouteTable.Routes.Add(new Route
{
Url = "[id]",
Defaults = new {
controller = "Hello",
action = "HiThere" },
RouteHandler = typeof(MvcRouteHandler)
});
随后,我只需访问 ,我的操作仍处于调用状态,而我将会看到熟悉的友好问候。
系统如何知道调用哪个控制器和操作?答案是 Defaults 参数。它利用新的 C# 3.0 匿名类型语法来创建一个伪词典。Route 上的 Defaults 对象可包含任意附加的信息,对于 MVC,它还可以包含一些众所周知的条目:即控制器和操作。如果 URL 中没有指定控制器或操作,则其将使用 Defaults 中的名称。这就是为什么即使我在 URL 中忽略它们,但仍可以将我的请求映射到正确的控制器和操作。
还有一件事需要注意:还记得我说过“添加到表格的顶部”吗?如果您将其置于底部,将会出 现错误。路由根据先到先得的原则进行工作。当处理 URL 时,路由系统会自上至下浏览表格,并且使用第一个匹配的路由。在本例中,默认路由 "[controller]/[action]/[id]" 匹配,因为它们是操作和 ID 的默认值。这样,它会继续查找 ChrisController,但我没有控制器,因此会出现错误。
稍大的示例
现在,我已经说明了 MVC Framework 的基础知识,将为您展示一个更大的示例,实现比仅显示字符串更多的功能。wiki 是一种可以在浏览器中进行编辑的网站。可以轻松地添加或编辑页面。我使用 MVC Framework 编写了一个小型的示例 wiki。“编辑此页面”屏幕如图 7 所示。
Figure 7 编辑主页 (单击该 图像获得较大视图)
您可以检查本文的代码下载以查看如何实现底层 wiki 逻辑。现在我想重点说明 MVC Framework 如何使 Web 上的 wiki 获取变得简单。让我们先设计 URL 结构。我想要以下各项:
- /[pagename] 显示该名称的页面。
- /[pagename]?version=n 显示页面的请求版本,其中 0 = 当前版本,1 = 以前的版本,以此类推。
- /Edit/[pagename] 打开该页的编辑屏幕。
- /CreateNewVersion/[pagename] 是为提交编辑而传入的 URL。
让我们从 wiki 页面的基本显示开始。我为它创建了一个名为 WikiPageController 的新类。接下来,我会添加一个名为 ShowPage 的操作。启动的 WikiPageController 如图 8 所示。ShowPage 方法相当简单。WikiSpace 和 WikiPage 类分别表示一组 wiki 页面和特定的页面(及其修订)。此操作只需加载模型并调用 RenderView。但此处的 "new WikiPageViewData" 行是什么意思?
Figure 8 WikiPageController Implementation of ShowPage
复制代码
public class WikiPageController : Controller
{
ISpaceRepository repository;
public ISpaceRepository Repository
{
get {
if (repository == null)
{
repository = new FileBasedSpaceRepository(
Request.MapPath("~/WikiPages"));
}
return repository;
}
set { repository = value; }
}
[ControllerAction]
public void ShowPage(string pageName, int? version)
{
WikiSpace space = new WikiSpace(Repository);
WikiPage page = space.GetPage(pageName);
RenderView("showpage",
new WikiPageViewData
{
Name = pageName,
Page = page,
Version = version ?? 0
});
}
}
我前面的示例说明了一种将数据从控制器传递到视图的方法:即 ViewData 词典。词典非常方便,但也很危险。它们几乎包含一切内容,您不能获取内容的任何 IntelliSense®,并且由于 ViewData 词典属于 Dictionary<string, object> 类型,它将消耗内容,您必须计算所有一切。
当您了解在视图中将需要什么数据后,就可以传递强类型化的 ViewData 对象。在我的示例中,我创建了一个简单的对象 (WikiPageViewData),如图 9 中所示。此对象将 wiki 页面信息带到视图,同时还携带了一些实用工具方法,执行获取 wiki 标记的 HTML 版本这类任务。
Figure 9 WikiPageViewData Object
复制代码
public class WikiPageViewData {
public string Name { get; set; }
public WikiPage Page { get; set; }
public int Version { get; set; }
public WikiPageViewData() {
Version = 0;
}
public string NewVersionUrl {
get {
return string.Format("/CreateNewVersion/{0}", Name);
}
}
public string Body {
get { return Page.Versions[Version].Body; }
}
public string HtmlBody {
get { return Page.Versions[Version].BodyAsHtml(); }
}
public string Creator {
get { return Page.Versions[Version].Creator; }
}
public string Tags {
get { return string.Join(",", Page.Versions[Version].Tags); }
}
}
现在,我已经定义了视图数据,那么,我如何使用它呢?在 ShowPage.aspx.cs 中,您将看到以下内容:
复制代码
namespace MiniWiki.Views.WikiPage {
public partial class ShowPage : ViewPage<WikiPageViewData>
{
}
}
请注意,我将基类类型定义为 ViewPage<WikiPageViewData>。这意味着页面的 ViewData 属性为 WikiPageViewData 类型,而不是象以前示例中的“Dictionary”。
.aspx 文件中的实际标记非常简单:
复制代码
<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
AutoEventWireup="true" CodeBehind="ShowPage.aspx.cs"
Inherits="MiniWiki.Views.WikiPage.ShowPage" %>
<asp:Content
ID="Content1"
ContentPlaceHolderID="MainContentPlaceHolder"
runat="server">
<h1><%= ViewData.Name %></h1>
<div id="content" class="wikiContent">
<%= ViewData.HtmlBody %>
</div>
</asp:Content>
请注意,当引用 ViewData 时,我没有使用索引操作符 []。由于我现在有强类型化的 ViewData,我可以直接访问该属性。不需要进行任何计算,而 Visual Studio 会提供 IntelliSense。
目光敏锐的读者将会注意到此文件中的 <asp:Content> 标记。没错,“母版页”确实可以与 MVC 视图配合使用。并且“母版页”还可以成为视图。让我们看看“母版页”的源代码:
复制代码
namespace MiniWiki.Views.Layouts
{
public partial class Site :
System.Web.Mvc.ViewMasterPage<WikiPageViewData>
{
}
}
相关标记如图 10 中所示。现在,“母版页”将获得与视图完全相同的 ViewData 对象。我已经将“母版页”的基类声明为 ViewMasterPage<WikiPageViewData>,因此,我拥有了正确类型的 ViewData。我会在那里设置各种 DIV 标记以对页面进行布局,填写版本列表,然后以常用内容占位符收尾。
Figure 10 Site.Master
复制代码
<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.master.cs" Inherits="MiniWiki.Views.Layouts.Site" %> <%@ Import Namespace="MiniWiki.Controllers" %> <%@ Import Namespace="MiniWiki.DomainModel" %> <%@ Import Namespace="System.Web.Mvc" %> <html > <head runat="server"> <title><%= ViewData.Name %></title> <link href="http://http://www.cnblogs.com/Content/Site.css" rel="stylesheet" type="text/css" /> </head> <body> <div id="inner"> <div id="top"> <div id="header"> <h1><%= ViewData.Name %></h1> </div> <div id="menu"> <ul> <li><a href="http://Home">Home</a></li> <li> <%= Html.ActionLink("Edit this page", new { controller = "WikiPage", action = "EditPage", pageName = ViewData.Name })%> </ul> </div> </div> <div id="main"> <div id="revisions"> Revision history: <ul> <% int i = 0; foreach (WikiPageVersion version in ViewData.Page.Versions) { %> <li> <a href="http://<%= ViewData.Name %>?version=<%= i %>"> <%= version.CreatedOn %> by <%= version.Creator %> </a> </li> <% ++i; } %> </ul> </div> <div id="maincontent"> <asp:ContentPlaceHolder ID="MainContentPlaceHolder" runat="server"> </asp:ContentPlaceHolder> </div> </div> </div> </body> </html>
另一件需要注意的事是对 Html.ActionLink 的调用。以下是呈现帮助程序的一个示例。各种视图类均具有两种属性,Html 和 Url。每种均有输出 HTML 代码块的有用方法。在本例中,Html.ActionLink 获取一个对象(此处为匿名类型)并通过路由系统将其返回。这将会生成一个 URL,该 URL 将路由至我指定的控制器和操作。这样一来,无论我如何更改路由,“编辑此页面”链接将始终指向正确的位置。
您可能还注意到,我还不得不依靠手动构建链接(到先前页面版本的链接)。遗憾的是,当前 的路由系统在涉及查询字符串时生成 URL 的功能不是十分完善。这应会在框架的后续版本中得到修复。
创建表单和回发
现在,让我们看看控制器上的 EditPage 操作:
复制代码
[ControllerAction]
public void EditPage(string pageName)
{
WikiSpace space = new WikiSpace(Repository);
WikiPage page = space.GetPage(pageName);
RenderView("editpage",
new WikiPageViewData {
Name = pageName,
Page = page });
}
同样,该操作所做的不多—它只是呈现指定页面的视图。视图中的内容变得更加有趣,如图 11 中所示。此文件构建了一个 HTML 表单,但没有出现 Runat="server"。Url.Action helper 用于生成表单回发的 URL。其中还使用了几种不同的 HTML 帮助程序(如 TextBox、TextArea 和 SubmitButton)。它们会出色完成您的预期目标:为各种输入字段生成 HTML。
Figure 11 EditPage.aspx
复制代码
<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="EditPage.aspx.cs" Inherits="MiniWiki.Views.WikiPage.EditPage" %> <%@ Import Namespace="System.Web.Mvc" %> <%@ Import Namespace="MiniWiki.Controllers" %> <asp:Content ID="Content1" ContentPlaceHolderID="MainContentPlaceHolder" runat="server"> <form action="<%= Url.Action( new { controller = "WikiPage", action = "NewVersion", pageName = ViewData.Name })%>" method=post> <% if (ViewContext.TempData.ContainsKey("errors")) { %> <div id="errorlist"> <ul> <% foreach (string error in (string[])ViewContext.TempData["errors"]) { %> <li><%= error%></li> <% } %> </ul> </div> <% } %> Your name: <%= Html.TextBox("Creator", ViewContext.TempData.ContainsKey("creator") ? (string)ViewContext.TempData["creator"] : ViewData.Creator)%> <br /> Please enter your updates here:<br /> <%= Html.TextArea("Body", ViewContext.TempData.ContainsKey("body") ? (string)ViewContext.TempData["body"] : ViewData.Body, 30, 65)%> <br /> Tags: <%= Html.TextBox( "Tags", ViewContext.TempData.ContainsKey("tags") ? (string)ViewContext.TempData["tags"] : ViewData.Tags)%> <br /> <%= Html.SubmitButton("SubmitAction", "OK")%> <%= Html.SubmitButton("SubmitAction", "Cancel")%> </form> </asp:Content>
处理 Web 编程最头疼的事情之一就是表单中的错误。更确切地说,您想要显示错误信息,但同时想要保留原来输入的数据。我们都有过那种经历,在填写一张有 35 个字段的表单时出现一个错误,程序却只是提供一堆错误信息和一张新的空白表单。MVC Framework 使用 TempData 存储以前输入信息,以便可以重新填入表单。这是 ViewState 实际上在 Web 窗体中变得非常简单的原因,因为保存控件的内容几乎是自动的。
我想在 MVC 中如法炮制,因此引入了 TempData。TempData 是一种词典,与非类型化的 ViewData 很相似。不过,TempData 的内容仅针对单一请求存在,随后就会被删除。要了解如何使用此方法,请参阅图 12,NewVersion 操作。
Figure 12 NewVersion Action
复制代码
[ControllerAction] public void NewVersion(string pageName) { NewVersionPostData postData = new NewVersionPostData(); postData.UpdateFrom(Request.Form); if (postData.SubmitAction == "OK") { if (postData.Errors.Length == 0) { WikiSpace space = new WikiSpace(Repository); WikiPage page = space.GetPage(pageName); WikiPageVersion newVersion = new WikiPageVersion( postData.Body, postData.Creator, postData.TagList); page.Add(newVersion); } else { TempData["creator"] = postData.Creator; TempData["body"] = postData.Body; TempData["tags"] = postData.Tags; TempData["errors"] = postData.Errors; RedirectToAction(new { controller = "WikiPage", action = "EditPage", pageName = pageName }); return; } } RedirectToAction(new { controller = "WikiPage", action = "ShowPage", pageName = pageName }); }
首先,它创建一个 NewVersionPostData 对象。这是另一个帮助程序对象,具有存储记入的内容和进行某些验证的属性和方法。为加载 postData 对象,我将使用 MVC 工具包的帮助程序。UpdateFrom 实际上是工具包提供的扩展方法,它使用反射将表单字段的名称与我的对象中属性的名称相对映。最终结果是,所有字段值均载入到我的 postData 对象中。不过,UpdateFrom 使用起来确实有缺点,由于它直接从 HttpRequest 获取表单数据,使单元测试变得更为困难。
NewVersion 检查的第一项是 SubmitAction。如果用户单击“确定”按钮并确实想要发布编辑的页面,则此项检查将通过。如果此处有任何其它值,操作会重定向回 ShowPage,只是重新显示原来的页面。
如果用户确实单击了“确定”,则检查 postData.Errors 属性。这将在记入内容上运行一些简单的验证。如果没有任何错误,我会将新版本的页面重新写入 wiki。不过,如果出现错误,情况会变得饶有趣味。
如果出现错误,我会设置 TempData 词典的各个字段,以便其包含 PostData 的内容。然后,我会重定向回“编辑”页面。现在,由于已设置 TempData,页面将重新显示以用户上次记入的值初始化的表单。
处理记入、验证和 TempData 的这个过程现在变得有些烦琐,并且需要多做一些手动工作。将来发行的版本应包括至少会将一些 TempData 检查自动化的帮助程序方法。关于 TempData 的最后一个注意事项是:TempData 的内容存储在用户的服务器端会话中。如果您关闭会话,TempData 将无法正常工作。
创建控制器
现在,wiki 的基础已在发挥功效,但继续进行之前,我想要明确实现中的以下几个要点。例如,Repository 属性用于分离 wiki 的逻辑与物理存储。您可以提供在文件系统(正如我所做的那样)、数据库或您想要的任何位置中存储内容的存储库。遗憾的是,我需要解决两个问题。
首先,我的控制器类与具体的 FileBasedSpaceRepository 类紧密地连在一起。我需要一个默认值,以便在属性没有设置时,也能合理使用。更糟的是,磁盘上文件的路径在这里也是硬编码的。最起码,它应取自配置。
其次,我的对象必须依赖存储库,否则无法运行。对于良好的设计,存储库实际应为构造函数 参数,而不是属性。但我无法将其添加到构造函数中,因为 MVC Framework 要求控制器上的构造函数不能有参数。
幸运的是,我可以通过一个扩展性挂接摆脱此限制:即控制器工厂。控制器工厂的功能正如其 名称所指:它创建 Controller 实例。您只需要创建一个类实现 IControllerFactory 接口并向 MVC 系统注册即可。您可以为所有控制器或仅为指定的类型注册控制器工厂。图 13 所示为 WikiPageController 的控制器工厂,其现在将存储库作为构造函数参数传递。在这种情况下,实现非常烦琐,但它可以创建能使用更强大工具(特别是依赖关系注入容器)的控制器。 无论如何,现在我拥有了将控制器依赖关系分离到对象中(易于管理和维护)的所有详细信息。
Figure 13 Controller Factory
复制代码
public class WikiPageControllerFactory : IControllerFactory { public IController CreateController(RequestContext context, Type controllerType) { return new WikiPageController( GetConfiguredRepository(context.HttpContext.Request)); } private ISpaceRepository GetConfiguredRepository(IHttpRequest request) { return new FileBasedSpaceRepository(request.MapPath("~/WikiPages")); } }
此工作的最后一步是向框架注册工厂。通过 ControllerBuilder 类可进行此操作,方法是将以下行添加到 Application_Start 方法中的 Global.asax.cs(路由前后均可):
复制代码
ControllerBuilder.Current.SetControllerFactory(
typeof(WikiPageController), typeof(WiliPageControllerFactory));
这将注册 WikiPageController 的工厂。如果此项目中有其他控制器,它们不会使用此工厂,因为此工厂仅针对 WikiPageController 类型进行了注册。如果您想要将工厂设置为供所有控制器使用,还可以调用 SetDefaultControllerFactory。
其他扩展点
控制器工厂只是框架扩展性的起点。本文中无法详述所有的细节,因此我将仅仅说明要点。首 先,如果您想要输出的内容不是 HTML,或想要使用其他模板引擎而不是 Web 窗体,可将控制器的 ViewFactory 设为其他项。您可以实现 IviewFactory 界面,然后即可完全控制如何生成输出。这对于生成 RSS、XML 或图形非常有用。
正如您所见到的,路由系统非常灵活。但路由系统中没有任何内容是 MVC 专用的。每个路由均有一个 RouteHandler 属性;目前为止,我始终将其设为 MvcRouteHandler。但可以实现 IRouteHandler 界面并将路由系统与其他 Web 技术挂接。将来推出的框架将附带 WebFormsRouteHandler,并且其他技术也会在将来利用通用路由系统的优势。
控制器并非必须从 System.Web.Mvc.Controller 衍生。控制器需要做的仅仅是实现 IController 界面,该界面只有称为 Execute 的一种方法。您可以从中进行任何操作。另一方面,如果您想将 Controller 基类的几种行为组合在一起,您可以覆盖 Controller 的许多虚拟函数:
- OnPreAction、OnPostAction 和 OnError 可让您将每个已执行操作上的预处理和后处理连接起来。OnError 为您提供在控制器内处理错误的机制。
- 当 URL 路由到控制器但控制器没有实现路由中请求的操作时,会调用 HandleUnknownAction。默认情况下,此方法会抛出一个异常,但您可以用所需的操作覆盖默认值。
- InvokeAction 是一种方法,它负责解决调用何种操作方法并进行调用。如果您想要自定义过程(例如,除去 [ControllerAction] 属性的要求),应使用该方法。
还有其他几种针对 Controller 的虚拟方法,但这些方法主要是测试挂接而不是作为扩展点。例如,RedirectToAction 是虚拟的,因此您可以创建实际并不进行重定向的衍生类。这样,您不需要完全运行 Web 服务器就能测试重定向操作。
要告别 Web 窗体吗?
现在您可能在想:“Web 窗体会面临怎样的命运?MVC 会取代它吗?”答案是否定的!Web 窗体是一种普及技术,Microsoft 将继续支持并改进它。它在许多应用程序中发挥着重要的作用;例如,可使用 Web 窗体创建典型的 Intranet 数据库报表应用程序,所花的时间比使用 MVC 编写短得多。此外,Web 窗体支持大量的控件,许多控件均具备非常先进的功能,可以大大提高效率。
那么,什么时候应该选择 MVC 呢?这主要取决于您的要求和喜好。您是否正在为获得想要的 URL 格式而烦恼?您是否想要对 UI 进行单元测试?以上情况均需要依靠 MVC。反之,如果您要显示许多数据,提供可编辑的网格和优良的树形视图控件?那么,您暂时最好还是使用 Web 窗体。
今后,MVC Framework 很可能在 UI 控制部分有所改进,但在便利性上,它可能始终不及 Web 窗体,因为后者具备大量拖曳功能。同时,ASP.NET MVC Framework 为 Web 开发人员提供了一种在 Microsoft .NET Framework 中构建 Web 应用程序的新方法。Framework 针对可测试性设计、推倡使用 HTTP 并且几乎在每个点均可扩展。对于那些想要完全控制其 Web 应用程序的开发人员来说,这是一个对 Web 窗体的诱人补充。
Chris Tavares 是 Microsoft 模式和实施方案小组的一名开发人员,他致力于帮助开发社区了解在 Microsoft 平台上构建系统的最佳实践。他还是 ASP.NET MVC 小组的虚拟成员,帮助您设计新的框架。可以通过 cct@tavaresstudios.com 与 Chris 取得联系。