本文章是由機器翻譯。

ASP.NET

使用 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 控制器類」即可。 If you cannot see the Web API Controller Class in the dialog, ensure that you have the NuGet Microsoft ASP.NET Web API package installed, and that you select Web from the items under the desired programming language. 不過,這樣添加控制器會用許多預設代碼創建類,您必須在以後將這些代碼刪除。 您可能更願意只添加一個普通類檔,然後讓其從 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 頁(稍後重構將會改進這一點)。 First, I need to modify the post method to return an 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 的頁面。

Moving to 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 函數中,我並沒有序列化整個頁面,而是僅發送伺服器端刪除方法所需的資料: the 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 and 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。他熱衷於讓框架變得簡單易用,從而為客戶帶來快樂。