此文章由机器翻译。

MVC

使用 ASP.NET Web API 将 ASP.NET Web 窗体迁移到 MVC 模式

Peter Vogel

 

尽管 ASP.NET MVC 近日来万众瞩目,但 ASP.NET Web 窗体及其相关控件让开发人员可以在短期内生成强大的交互式 UI,这也是为何如此多的 ASP.NET Web 窗体应用程序普遍使用的原因。 ASP.NET Web 窗体并不支持模型-视图-控制器 (MVC) 和模型-视图-视图模型 (MVVM) 模式的实现,因而也无法支持测试驱动开发 (TDD)。

ASP.NET Web API(以下简称为“Web API”)提供了一种方式,可通过从代码隐藏文件将代码移到 Web API 控制器,将 ASP.NET Web 窗体应用程序构建或重构为 MVC 模式。 此外,该过程使 ASP.NET 应用程序可以利用异步 JavaScript 和 XML (AJAX),该功能通过将逻辑移到客户端并减少与服务器的通信,可用于创建响应更快的 UI 并提高应用程序的可伸缩性。 之所以可以做到这一点是因为 Web API 采用 HTTP 协议和(通过按约定编码)自动处理一些底层任务。 本文所提出的面向 ASP.NET 的 Web API 模式旨在让 ASP.NET 生成初始标记集并发送给浏览器,而通过对独立可测试控制器的 AJAX 调用来处理所有用户交互。

为此,需要建立一个基础结构,使 Web 窗体应用程序通过一组 AJAX 调用与服务器交互,这一过程并不难。 但我不想对您有误导: 这需要重构 Web 窗体应用程序代码文件中的代码以用于 Web API 控制器,这可能并不轻松。 您必须放弃由控件、自动生成服务器端验证和 ViewState 触发的各种事件。 不过,正如您将看到的,可采取一些变通的办法,如去掉一些功能,来降低实现的难度。

添加 Web API 基础结构

若要在 ASP.NET 项目中使用 Web API,只需(在添加 NuGet Microsoft ASP.NET Web API 程序包后)右键单击并选择“添加”|“新建项”|“Web API 控制器类”即可。 如果无法在对话框中看到 Web API 控制器类,确保安装了 NuGet Microsoft ASP.NET Web API 程序包,并且在所需编程语言下的项目中选择了“Web”。 不过,这样添加控制器会用许多默认代码创建类,您必须在以后将这些代码删除。 您可能更愿意只添加一个普通类文件,然后让其从 System.Web.Http.ApiController 类继承。 要使用 ASP.NET 路由基础结构,该类名必须以字符串“Controller”结尾。

以下示例将创建一个名为 Customer 的 Web API 控制器:

public class CustomerController : ApiController
{

Web API 控制器支持许多按约定编码。 例如,要创建一个每次将窗体回发到服务器时都会被调用的方法,只需创建名为“Post”或以“Post”开头的方法即可(实际上,回发到服务器的页面将使用 HTTP POST 谓词进行发送;Web API 根据请求的 HTTP 谓词来选取方法)。 如果该方法名违反了组织的编码约定,您可以使用 HttpPost 属性对其进行标记,以便在将数据发布到服务器时使用该方法。 以下代码将在 Customer 控制器中创建一个名为 UpdateCustomer 的方法来处理 HTTP 发布:

public class CustomerController : ApiController
{
  [HttpPost]
  public void UpdateCustomer()
  {

Post 方法最多接受一个参数(具有多个参数的 post 方法会被忽略)。 可发送给 post 方法的最简单数据是 post 主体中以等号为前缀的单个值(例如“=ALFKI”)。 Web API 会自动将该数据映射到 post 方法的单个参数,前提是该参数使用 FromBody 属性进行修饰,如以下示例所示:

[HttpPost]
public HttpResponseMessage UpdateCustomer([FromBody] string CustID)
{

当然,这几乎没什么用处。 如果要回发多个值(例如,Web 窗体中的数据),您需要定义一个类来存放 Web 窗体中的值,该类就是: 数据传输对象 (DTO)。 在这里,Web API 编码约定标准将助一臂之力。 您只需定义一个其属性名与 Web 窗体中控件的关联名称相匹配的类,Web API 即可自动用 Web 窗体中的数据填充 DTO 属性。

作为可回发至 Web API 控制器的数据示例,图 1 中所示的(的确很简单)示例 Web 窗体仅有三个 TextBox、一个 RequiredFieldValidator 和一个 Button。

图 1:基本的示例 Web 窗体

<form id="form1" runat="server">
<p>
  Company Id: <asp:TextBox ID="CustomerID"
    ClientIDMode="Static" runat="server">
    </asp:TextBox> <br/>
  Company Name: <asp:TextBox ID="CompanyName"
    ClientIDMode="Static" runat="server">
    </asp:TextBox>
  <asp:RequiredFieldValidator ID="RequiredFieldValidator1"
    runat="server" ControlToValidate="CompanyName"
    Display="Dynamic"
    ErrorMessage="Company Name must be provided">
  </asp:RequiredFieldValidator><br/>
  City: <asp:TextBox ID="City"
    ClientIDMode="Static" runat="server"> 
    </asp:TextBox><br/>
</p>
<p>
  <asp:Button ID="PostButton" runat="server" Text="Update" />
</p>
</form>

要让 post 方法接受来自此 Web 窗体的 TextBox 中的数据,您将需要创建一个类,该类的属性名与 TextBox 的 ID 属性相匹配,如同下面这个类所示(Web API 将忽略 Web 窗体中没有匹配属性的所有控件):

public class CustomerDTO
{
  public string CustomerID { get; set; }
  public string CompanyName { get; set; }
  public string City { get; set; }
}

比较复杂的 Web 窗体可能需要您无法架驭(或超出所绑定到的 Web API 的能力)的 DTO。 如果是这样,您可以创建自己的模型绑定程序,以将数据从 Web 窗体控件映射到 DTO 属性。 在重构方案中,Web 窗体中的代码已在使用 ASP.NET 控件的名称 — 与 DTO 上的属性同名可减少将代码移到 Web API 控制器时所需的工作量。

路由 Web 窗体

将 Web API 集成到 ASPX Web 窗体处理循环的下一步是,在应用程序的 Global.asax 文件的 Application_Start 事件中提供一个路由规则,该规则用于将窗体的回发定向到控制器。 路由规则提供一个模板,该模板指定要对其应用该规则的 URL 以及要处理请求的控制器。 该模板还指定 URL 中的具体位置,查找要由 Web API 使用的值(包括要传递给控制器中的方法的值)。

在这里,有些标准做法可以忽略。 标准路由规则几乎可匹配任何 URL,这可能会导致在将规则应用于您不打算使用该规则的 URL 时出现意外结果。 为避免这种情况,Microsoft 最佳做法是让 URL 与以字符串“api”开头的 Web API 相关联,以防止与在应用程序别处使用的 URL 发生冲突。 该“api”没有其他作用,只是用来填充所有 URL。

总而言之,您最后会在 Application_Start 事件中得到一个通用的路由规则,如下所示(您需要将 System.Web.Routing 和 System.Web.Http 的 using 语句添加到 Global.asax 以支持此代码):

RouteTable.Routes.MapHttpRoute(
  "API Default",
  "api/{controller}/{id}",
  new { id = RouteParameter.Optional })
);

此路由从模板中的第二个参数提取控制器名称,因此 URL 紧密耦合到控制器。 如果重命名控制器,则使用 URL 的所有客户端都会停止工作。 (我还喜欢由模板映射在 URL 中的任何参数具有比“id”更有意义的名称。)我已选择了更加具体的路由规则,它们不需要模板中的控制器名称,而是指定默认设置中通过第三个参数传递给 MapHttpRoute 方法的控制器名称。 通过使路由规则中的模板更加具体,我还忽略了用于 Web API 控制器的 URL 的特殊前缀,因此我预料到了我的路由规则的结果。

我的路由规则如以下代码所示,这会创建一个名为 CustomerManagementPost 的路由,该路由仅应用于以“CustomerManagement”开头的 URL(后跟服务器和站点名称):

RouteTable.Routes.MapHttpRoute(
  "CustomerManagementPost",
  "CustomerManagement",
  new { Controller = "Customer" },
  new { httpMethod = new HttpMethodConstraint("Post") }
);

例如,此规则将仅适用于 www.phivs.com/CustomerManagement 这样的 URL。 在默认设置中,将此 URL 绑定到 Customer 控制器。 就为了确保该路由仅在我需要时使用,我使用了第四个参数来指定此路由仅当数据以 HTTP POST 发回时才使用。

重构到控制器

如果您要重构现有 Web 窗体,那么下一步就是让该 Web 窗体将其数据发布到这一新定义的路由而不是返回给自身。 这是对现有代码所做的第一项更改,至此,所有其他改动都是添加代码,而现有处理则保持不变。 修改的窗体标记应如下所示:

<form id="form1" runat="server" action="CustomerManagement"
   method="post" enctype="application/x-www-form-urlencoded">

这里的关键更改是将窗体标记的操作属性设置为使用该路由(“CustomerManagement”)中指定的 URL。 该方法和 enctype 属性可帮助确保跨浏览器兼容性。 当页面回发到控制器时,Web API 将自动调用 post 方法,实例化传递给该方法的类并将数据从 Web 窗体映射到 DTO 上的属性,然后再将 DTO 传递给 post 方法。

一切准备就绪后,现在便可在控制器的 post 方法中编写代码以使用 DTO 中的数据。 以下代码使用从 Web 窗体传递的数据,为基于 Northwind 数据库的模型更新所匹配的 Entity Framework 实体对象:

[HttpPost]
public void UpdateCustomer(CustomerDTO custDTO)
{
  Northwind ne = new Northwind();
  Customer cust = (from c in ne.Customers
                   where c.CustomerID == custDTO.CustomerID
                   select c).SingleOrDefault();
  if (cust != null)
  {
    cust.CompanyName = custDTO.CompanyName;
  }
  ne.SaveChanges();

在完成处理后,某些内容应当发回客户端。 最初,我仅返回的 HttpResponseMessage 对象,该对象配置为将用户重定向到站点中的另一个 ASPX 页(稍后重构将会改进这一点)。 首先,我需要修改 post 方法以返回 HttpResponseMessage:

[HttpPost]
public HttpResponseMessage UpdateCustomer(CustomerDTO custDTO)

然后,需要向该方法的末尾添加代码,以将重定向响应返回给客户端:

HttpResponseMessage rsp = new HttpResponseMessage();
  rsp.StatusCode = HttpStatusCode.Redirect;
  rsp.Headers.Location = new Uri("RecordSaved.aspx", UriKind.Relative);
  return rsp;
}

现在将开始进行实际工作,其中包括:

  • 将 ASPX 代码文件中的任何代码移到新的控制器方法
  • 添加由验证控件执行的任何服务器验证
  • 将代码与页面触发的事件分离

这些任务并不轻松。 不过,我们将会演示一些选项,通过延续到启用 AJAX 的页面,使上述过程将得以简化。 实际上,如果您无法移到控制器(或者要稍后移动),可以使用其中一个选项保留 Web 窗体中的代码。

至此,在重构现有 Web 窗体的过程中,您已移到 MVC 模式,但尚未移到 AJAX 模式。 页面仍使用传统的请求/响应循环,并未消除页面的回发。 下一步是创建真正启用 AJAX 的页面。

移到 AJAX

若要消除请求/响应循环,第一步是将按钮的 OnClientClick 属性设置为调用一个客户端函数,从而向响应过程中插入一些 JavaScript。 以下示例让按钮调用名为 UpdateCustomer 的 JavaScript 函数:

<asp:Button ID="PostButton" runat="server" Text="Update"
  OnClientClick="return UpdateCustomer();" />

在此按钮中,您将拦截用户单击按钮时触发的回发,并将其替换为对服务的方法的 AJAX 调用。 通过在 OnClientClick 中使用 return 关键字并让 UpdateCustomer 返回 false,将会禁止按钮触发的回调。 拦截函数还应通过调用 ASP.NET 提供的 Page_ClientValidate 函数,来调用 ASP.NET 验证控件生成的任何客户端验证代码(在重构过程中,调用验证程序的客户端代码可使您无需重新创建验证程序的服务器端验证)。

如果您要重构现有 Web 窗体,可以在使用路由的窗体标记上删除操作属性。 通过删除操作属性,可以实现混合/分阶段方法来将 Web 窗体的代码移到 Web API 控制器。 对于您(暂时)不希望移到控制器的代码,可以继续让 Web 窗体回发到自身。 例如,在拦截函数中,可以检查 Web 窗体中是否发生哪些更改,并从拦截函数返回 true 以便让回发继续执行。 如果页面上有多个触发回调的控件,您可以选择要在 Web API 控制器中处理哪些控件并只为这些控件编写拦截函数。 这样,您可以在重构时采用混合方法(在 Web 窗体中保留一些代码)或分阶段方法(按时间迁移代码)。

UpdateMethod 方法现在需要调用此前接收页面回发的 Web API 服务。 通过将 jQuery 添加到项目和页面(我使用了 jQuery 1.8.3)),可以使用其 post 函数来调用 Web API 服务。 jQuery 序列化函数会将窗体转换为一组名称/值对,Web API 会将它们映射到 CustomerDTO 对象上的属性名。 将此调用集成到 UpdateCustomer 函数(以便未发现客户端错误时才执行发布)的过程如以下代码所示:

function UpdateCustomer() {
  if (Page_ClientValidate()){
    $.post('CustomerManagement', $('#form1').serialize())
    .success(function () {
      // Do something to tell the user that all went well.
})
    .error(function (data, msg, detail) {
      alert(data + '\n' + msg + '\n' + detail)
    });
  }
  return false;
}

序列化窗体会向控制器发送许多数据,但这些数据并非都是必要的(例如,ViewState)。 我将在下文中演练一下如何仅发送必要的数据。

最后一步(至少对于此简单示例来说)是重新编写控制器中 post 方法的末尾,使用户停留在当前页面上。 以下形式的 post 方法通过使用 HttpResponseMessage 类,仅返回 HTTP OK 状态:

...
ne.SaveChanges();
  HttpResponseMessage rsp = new HttpResponseMessage();
  rsp.StatusCode = HttpStatusCode.OK;
  return rsp;
}

工作流处理

现在,您必须确定完成进一步处理的任务应放置在何处。 如前面所示,如果用户将发送到其他页面,您可以在控制器中处理该操作。 不过,如果控制器现在仅向客户端返回 OK 消息,您可能希望在客户端中执行一些额外的处理。 例如,向 Web 窗体中添加一个标签以显示服务器端处理的结果,这可以作为一个良好的开端:

更新状态: <asp:Label ID="Messages" runat="server" Text=""></asp:Label>

在 AJAX 调用的 success 方法中,将使用 AJAX 调用的状态更新该标签:

success: function (data, status) {
  $("#Messages").text(status); 
},

在处理已发布页面的过程中,经常需要让 Web 窗体的服务器端代码先更新页面上的控件,再将该页面返回给用户。 为此,您需要将数据通过服务的 post 方法返回数据并通过 JavaScript 函数更新该页面。

该过程的第一步是设置 HttpResponseMessage 对象的 Content 属性,用以存放要返回的数据。 由于所创建的 DTO 可将数据从窗体给 post 方法,因此最好使用该 DTO 将数据发回客户端。 但您无需使用 Serializable 属性标记 DTO 类,即可将其用于 Web API。 (实际上,如果确实使用 Serializable 属性标记 DTO,则 DTO 属性的支持字段将进行序列化并发送到客户端,这会导致用于客户端形式的 DTO 的古怪名称。)

以下代码更新 DTO City 属性并将其移到格式化为 JSON 对象的 HttpResponseMessage Content 属性(您需要将 System.Net.Http.Headers 的 using 语句添加到控制器中,才能使此代码生效):

HttpResponseMessage rsp = new HttpResponseMessage();
rsp.StatusCode = HttpStatusCode.OK;
custDTO.City = cust.City;
rsp.Content = new ObjectContent<CustomerDTO>(custDTO,
              new JsonMediaTypeFormatter(),
              new MediaTypeWithQualityHeaderValue("application/json"));

最后一步是改进拦截函数,让其 success 方法将数据移到窗体中:

.success(function (data, status) {
  $("#Messages").text(status);
  if (status == "success") {
    $("#City").val(data.City);
  }
})

当然,上述代码不会更新 ASP.NET ViewState。 如果页面稍后以常规 ASP.NET 模式回发,则 City TextBox 将会触发 TextChanged 事件。 如果 Web 窗体服务器端代码中有绑定到该事件的代码,您可能会得到意外的结果。 如果要执行分阶段迁移或使用混合方法,则需要对此进行测试。 在完全实现的模式版本中,初始显示之后 Web 窗体将不回发到服务器,这不会构成问题。

替换事件

我在前面部分中介绍过,您必须应对没有 ASP.NET 服务器端事件的情况。 不过,您可以改为捕获用于触发回发到服务器的等效 JavaScript 事件,然后在服务上调用与服务器端事件中的代码等效的方法。 分阶段重构过程会利用这一点,让您在有时间(或感觉需要)时迁移这些事件。

例如,如果页面有一个用于删除当前显示的 Customer 的删除按钮,您可以在最初迁移过程中在页面的代码文件中保留该功能,让删除按钮将页面回发到服务器。 当您准备迁移删除功能时,可首先添加一个函数来拦截删除按钮的客户端 onclick 事件。 在以下示例中,我选择了在 JavaScript 中绑定该事件,这是一个适用于任何客户端事件的好办法:

<asp:Button ID="DeleteButton" runat="server" Text="Delete"  />
<script type="text/javascript">
  $(function () {
    $("#DeleteButton").click(function () { return DeleteCustomer() });
  })

在 DeleteCustomer 函数中,我并没有序列化整个页面,而是仅发送服务器端删除方法所需的数据: CustomerID。 由于我可以将该单个参数嵌入用于请求服务的 URL 中,这让我可以使用另一个标准 HTTP 谓词来选择适当的控制器方法: 该谓词就是 DELETE(有关 HTTP 谓词的信息,请参见 bit.ly/92iEnV)。

通过使用 jQuery ajax 功能,我可以向控制器发出请求,使用页面中的数据构建 URL,并指定将 HTTP delete 谓词作为请求的类型(见图 2)。

图 2:使用 jQuery AJAX 功能发出控制器请求

function DeleteCustomer() {               
  $.ajax({
    url: 'CustomerManagement/' + $("#CustomerID").val(),
    type: 'delete',
    success: function (data, status) {
      $("#Messages").text(status);                       
  },
  error: function (data, msg, detail) {
    alert(data + '\n' + msg + '\n' + detail)
    }
  });
  return false;
}

下一步是创建一个路由规则,该规则将确定 URL 的哪个部分包含 CustomerID 并将该值赋予一个参数(在此例中,参数名为 CustID):

 

RouteTable.Routes.MapHttpRoute(
  "CustomerManagementDelete",
  "CustomerManagement/{CustID}",
  new { Controller = "Customer" },
  new { httpMethod = new HttpMethodConstraint("Delete") }
);

与发布一样,Web API 会自动将 HTTP DELETE 请求路由到控制器中名为“Delete”或以之开头的方法,或者用 HttpDelete 属性标记的方法。 而且,和以前一样,Web API 会自动将从 URL 中提取的任何数据映射到该方法中与模板中的名称相匹配的参数:

[HttpDelete]
public HttpResponseMessage FlagCustomerAsDeleted(string CustID)
{
  //...
Code to update the Customer object ...
HttpResponseMessage rsp = new HttpResponseMessage();
  rsp.StatusCode = HttpStatusCode.OK;
  return rsp;
}

冲破 HTTP 谓词的束缚

大多数 ASP.NET 页面在设计时并未考虑到 HTTP 谓词;而是在定义代码的原始版本中通常使用“事件性”方法。 这可能很难将页面的功能绑定到某一 HTTP 谓词(也可能强制您创建一个复杂的 post 方法来应对各种处理)。

要处理任何面向事务的功能,您可以按名称而不是 HTTP 类型在控制器上添加一个指定方法的路由(在路由术语中称为“操作”)。 以下示例定义一个 URL 以将请求路由到名为 Assign­CustomerToOrder 的方法,然后从该 URL 中提取 CustID 和 OrderID(与 post 方法不同,与其他 HTTP 谓词关联的方法可接受多个参数):

RouteTable.Routes.MapHttpRoute(
  "CustomerManagementAssign",
  "CustomerManagement/Assign/{CustID}/{OrderID}",
  new { Controller = "Customer", Action="AssignCustomerToOrder" },
  new { httpMethod = new HttpMethodConstraint("Get") }
  );

该方法的以下声明选取从 URL 中提取的参数:

[HttpGet]
public HttpResponseMessage AssignCustomerToOrder(
  string CustID, string OrderID)
{

绑定到适当客户端事件的拦截函数使用 jQuery get 函数向适当的组件传递 URL:

function AssignOrder() {
  $.get('CustomerManagement/Assign/' + 
    $("#CustomerID").val() + "/" + "A123",
    function (data, status) {
      $("#Messages").text(status);
      });
  return false;
}

总的来说,将传统 ASPX 页面的代码文件重构到 Web API 控制器并不轻松。不过,ASP.NET Web API 十分灵活、它能够将 HTTP 数据绑定到 .NET 对象并利用 HTTP 标准,从而可实现将现有应用程序迁移到 MVC/TDD 模型,并通过 AJAX 支持页面提高可伸缩性。此外,它还提供了一种创建新 ASP.NET 应用程序的模式,这种模式同时利用了 Web 窗体的高效性和 ASP.NET Web API 的强大功能。

Peter Vogel 是 PH&V Information Services 的负责人,专门从事 ASP.NET 开发,擅长领域为面向服务的架构、XML、数据库和 UI 设计。

衷心感谢以下技术专家对本文的审阅: Christopher Bennage 和 Daniel Roth
Christopher Bennage 是 Microsoft 的模式和实施方案团队的开发人员。他在 Microsoft 的工作是发现、收集和支持那些令开发者赏心悦目的实施方案。他最近感兴趣的技术领域包括 JavaScript 和(偶尔)游戏开发。他的博客地址是 http://dev.bennage.com

Daniel Roth 是 Windows Azure Application Platform 团队的高级项目经理,目前负责 ASP.NET Web API 方面的工作。在负责 ASP.NET 之前,他自 WCF 最初随 .NET Framework 3.0 推出时便开始负责 WCF。他热衷于让框架变得简单易用,从而为客户带来快乐。