[ASP.NET MVC 小牛之路]15 - Model Binding

Liam Wang發表於2013-11-21

Model Binding(模型繫結)是 MVC 框架根據 HTTP 請求資料建立 .NET 物件的一個過程我們之前所有示例中傳遞給 Action 方法引數的物件都是在 Model Binding 中建立的。本文將介紹 Model Binding 如何工作,及如何使用 Model Binding,最後將演示如何自定義一個 Model Binding 以滿足一些高階的需求。

本文目錄

理解 Model Binding

在閱讀本節之前,讀者最好對 URL 路由和 ControllerActionInvoker 有一定的瞭解,可閱讀本系列的 [ASP.NET MVC 小牛之路]07 - URL Routing 和 [ASP.NET MVC 小牛之路]10 - Controller 和 Action (2) 兩篇文章。 

Model Binding(模型繫結) 是 HTTP 請求和 Action 方法之間的橋樑,它根據 Action 方法中的 Model 型別建立 .NET 物件,並將 HTTP 請求資料經過轉換賦給該物件。

為了理解 Model Binding 如何工作,我們來做個簡單的Demo,像往常一樣建立一個 MVC 應用程式,新增一個 HomeController,修改其中的 Index 方法如下:

public ActionResult Index(int id = 0) {
    return View((object)new[] { "Apple", "Orange", "Peach" }[id > 2 ? 0 : id]);
}

新增 Index.cshtml 檢視,修改程式碼如下:

@{
    ViewBag.Title = "Index";
}

<h2>Change the last segment of the Url to request for one fruit. </h2>
<h4>You have requested for a(an): @Model</h4>

執行應用程式,定位到 /Home/Index/1,顯示如下:

MVC 框架經過路由系統將 Url 的最後一個片段 /1 解析出來,將它作為 Index action 方法的引數來響應使用者的請求。這裡的 Url 片段值被轉換成 int 型別的引數就是一個簡單的 Model Binding 的例子,這裡的 int 型別就是“Model Binding”中的“Model”。

Model Binding 過程是從路由引擎接收和處理請求後開始的,這個示例使用的是應用程式預設的路由例項,如下:

public static void RegisterRoutes(RouteCollection routes) { 
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); 
    routes.MapRoute( 
        name: "Default", 
        url: "{controller}/{action}/{id}", 
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } 
    );
}

當我們請求 /Home/Index/1 URL 時,路由系統便將最後一個片段值 1 賦給了 id 變數。action invoker 通過路由資訊知道當前的請求需要 Index action 方法來處理,但它呼叫 Index action 方法之前必須先拿到該方法引數的值。在本系列前面文章中我們知道,Action 方法是由預設的 Action Invoker(即 ControllerActionInvoker 類) 來呼叫的。Action Invoker 依靠 Model Binder(模型繫結器) 來建立呼叫 Action 方法需要的資料物件。我們可以通過 Model Binder 實現的介面來了解它的功能,該介面是 IModelBinder,定義如下:

namespace System.Web.Mvc { 
    public interface IModelBinder { 
        object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext); 
    } 
}

在一個 MVC 中可以有多個 Model Binder,每個 Binder 都負責繫結一種或多或型別的 Model。當 action invoker 需要呼叫一個 action 方法時,它先看這個 action 方法需要的引數,然後為每個引數找到和引數的型別對應的 Model Binder。對於我們這個簡單示例,Action Invoker 會先檢查 Index action 方法,發現它有一個 int 型別的引數,然後它會定位到負責給 int 型別提供值的 Binder,並呼叫該 Binder 的 BindModel 方法。該方法再根據 Action 方法引數名稱從路由資訊中獲取 id 的值,最後把該值提供給 Action Invoker。

Model Binder 的執行機制

Model Binder(模型繫結器),顧名思義,可以形象的理解為將資料繫結到一個 Model 的工具。這個 Model 是 Action 方法需要用到的某個型別(既可以是方法引數的型別也可以是方法內部物件的型別),要繫結到它上面的值可以來自於多種資料來源。

MVC 框架內建預設的 Model Binder 是 DefaultModelBinder 類。當 Action Invoker 沒找到自定義的 Binder 時,則預設使用 DefaultModelBinder。預設情況下,DefaultModelBinder 從如下 4 種途徑查詢要繫結到 Model 上的值:

  1. Request.Form,HTML form 元素提供的值。
  2. RouteData.Values,通過應用程式路由提供的值。
  3. Request.QueryString,所請求 URL 的 query string 值。
  4. Request.Files,客戶端上傳的檔案。

DefaultModelBinder 按照該順序來查詢需要的值。如對於上面的例子,DefaultModelBinder 會按照如下順序為 id 引數查詢值:

  1. Request.Form["id"]
  2. RouteData.Values["id"]
  3. Request.QueryString["id"]
  4. Request.Files["id"]

一旦找到則停止查詢。在我們的例子中,走到第 2 步在路由變數中找到了 id 的值後便不會再往下查詢。

如果請求 Url 的 id 片段是一個字串型別的值(如“abc”),DefaultModelBinder 會怎麼處理呢?

對於簡單型別,DefaultModelBinder 會通過 System.ComponentModel 名稱空間下的 TypeDescriptor 類將其轉換成和引數相同的型別。如果轉換失敗,DefaultModelBinder 則不會把值繫結到引數 Model 上。有一點需要注意,對於值型別,大家應儘量使用可空型別或可選引數的 action 方法([ASP.NET MVC 小牛之路]02 - C#知識點提要 中有介紹),否則當值型別的引數沒有繫結到值時程式會報錯。

另外,DefaultModelBinder 是根據當前區域來型別轉換的,時間型別最容易出現問題,如果日期格式不正確則會轉換失敗。.NET 中通用的時間格式是 yyyy-MM-dd,所以我們最好確保在URL中的時間格式是通用格式(universal format)。

繫結到複合型別

所謂的複合型別是指任何不能被 TypeConverter 類轉換的型別(大多指自定義型別),否則稱為簡單型別。對於複合型別,DefaultModelBinder 類通過反射獲取該型別的所有公開屬性,然後依次進行繫結。

舉個例子來說明。如對於下面這個Person 類:

public class Person { 
    public int PersonId { get; set; } 
    public string FirstName { get; set; } 
    public string LastName { get; set; } 
    public Address HomeAddress { get; set; } 
}
public class Address { 
    public string City { get; set; } 
    public string Country { get; set; } 
}

有這麼一個 action 方法:

public ActionResult CreatePerson(Person model) { 
      return View(model);  
}

預設的 model binder 發現 action 方法需要一個 Person 物件的引數,會依次處理 Person 的每個屬性。對於每個簡單型別的屬性,它和前面的例子一樣去請求的資料中查詢需要的值。例如,對於 PersonId 屬性,對於像下面這樣提交上來的表單:

@using(Html.BeginForm()) { 
    <div>@Html.LabelFor(m => m.PersonId)@Html.EditorFor(m=>m.PersonId)</div> 

Binder 將會在 Request.Form["PersonId"] 中找到它需要的值。

如果一個複合型別的屬性也是個複合型別,如 Person 類的 HomeAddress 屬性。該屬性是一個 Address 型別,它的 Country 屬性在 View 中的使用是:

@using(Html.BeginForm()) { 
    <div>@Html.LabelFor(m => m.PersonId)@Html.EditorFor(m=>m.PersonId)</div> 
    <div> 
        @Html.LabelFor(m => m.HomeAddress.Country)
        @Html.EditorFor(m=> m.HomeAddress.Country)
    </div>
...

@Html.EditorFor(m=> m.HomeAddress.Country) 生成的 Html 程式碼是:

<input class="text-box single-line" id="HomeAddress_Country"name="HomeAddress.Country" type="text" value="" />

表單提交後,model binder 會在 Request.Form["HomeAddress.Country"] 中查詢到 Person.HomeAddress 的 Country 屬性的值。當Model binder 檢查到 Person 型別引數的 HomeAddress 屬性是一個複合型別,它會重複之前的查詢工作,為 HomeAddress 的每個屬性查詢值,唯一不同的是,查詢的時候用的名稱不一樣。

應用 Bind 特性

有時候我們還會遇到這樣的情況,某個 action 方法的引數型別是某個物件的屬性的型別,如下面這個 DisplayAddress action 方法:

public ActionResult DisplayAddress(Address address) { 
    return View(address); 
}

它的引數是 Address 型別,是 Person 物件的 HomeAddress 屬性的型別。若我們現在的 Index.cshtml View 中的 Model 是 Person 型別,其中有如下這樣的 form 表單:

@model MvcApplication1.Models.Person 
...
@using(Html.BeginForm("DisplayAddress", "Home")) { 
    <div>@Html.LabelFor(m => m.PersonId)@Html.EditorFor(m=>m.PersonId)</div> 
    <div> 
        @Html.LabelFor(m => m.HomeAddress.City) 
        @Html.EditorFor(m=> m.HomeAddress.City)
    </div> 
    <div> 
        @Html.LabelFor(m => m.HomeAddress.Country) 
        @Html.EditorFor(m=> m.HomeAddress.Country)
    </div> 
    <button type="submit">Submit</button> 
}

那麼我們如何把 Person 型別的物件傳遞給 DisplayAddress(Address address) 方法呢?點提交按鈕後,Binder 能為 Address 型別的引數繫結 Person 物件中的 HomeAddress 屬性值嗎?我們不妨建立一個 DisplayAddress.cshtml 檢視來驗證一下:

@model MvcApplication1.Models.Address

@{
    ViewBag.Title = "Address";
}
<h2>Address Summary</h2>
<div><label>City:</label>@Html.DisplayFor(m => m.City)</div>
<div><label>Country:</label>@Html.DisplayFor(m => m.Country)</div> 

執行程式,點提交按鈕,效果如下:

 

Address 兩個屬性的值沒有顯示出來,說明 Address 型別的引數沒有繫結到值。問題在於生成 form 表單的 name 屬性有 HomeAddress 字首(name="HomeAddress.Country"),它不是 Model Binder 在繫結 Address 這個 Mdoel 的時候要匹配的名稱。要解決這個問題可以對 action 方法的引數型別應用 Bind 特性,它告訴 Binder 只查詢特定字首的名稱。使用如下:

public ActionResult DisplayAddress([Bind(Prefix="HomeAddress")]Address address) {
    return View(address);
}

再執行程式,點提交按鈕,效果如下:

 

這種用法雖然有點怪,但是非常有用。更有用的地方在於:DisplayAddress action 方法的引數型別 Address 不一定必須是 Person 的 HomeAddress 屬性的型別,它可以是其他型別,只要該型別中含有和 City

或 Country 同名的屬性就都會被繫結到。

不過,要注意的是,使用 Bind 特性指定了字首後,需要提交的表單元素的 name 屬性必須有該字首才能被繫結。

Bind 特性還有兩個屬性,Exclude 和 Include。它們可以指定在 Mdoel 的屬性中,Binder 不查詢或只查詢某個屬性,即在查詢時要麼只包含這個屬性要麼不包含這個屬性。如下面的 action 方法:

public ActionResult DisplayAddress([Bind(Prefix = "HomeAddress", Exclude = "Country")]Address address) {
    return View(address);
}

這時 Binder 在繫結時不會對 Address 這個 Model 的 Country 屬性繫結值。

上面 Bind 特性的應用只對當前 Action 有效。如果要使得 Bind 特性對 Model 的影響在整個應用程式都有效,可以把它放在該 Model 的定義處,如:

[Bind(Include = "Country")]
public class Address {
    public string City { get; set; }
    public string Country { get; set; }
}

對 Address 類應用了 [Bind(Include = "Country")] 特性以後,Binder 在給 Address 模型繫結時只會給 Country 屬性繫結值。

繫結到陣列

Model Binder 把請求提交的資料繫結到陣列和集合模型上有非常好的支援,下面先來演示MVC如何支援對陣列模型的繫結。

先看一個帶有陣列引數的 action 方法:

public class HomeController : Controller {
    public ActionResult Names(string[] names) {
        names = names ?? new string[0];
        return View(names);
    }
}

Names action方法有一個名為 names 的陣列引數,Model Binder 將查詢所有名稱為 names 的條目的值,並建立一個 Array 物件儲存它們。

接著我們再來為Names action建立View:Names.cshtml,View 中包含若干名稱為 names 的表單元素:

@model string[]
@{
    ViewBag.Title = "Names";
}

<h2>Names</h2>
@if (Model.Length == 0) {
    using (Html.BeginForm()) {
        for (int i = 0; i < 3; i++) {
            <div><label>@(i + 1):</label>@Html.TextBox("names")</div>
        }
        <button type="submit">Submit</button>
    }
}
else {
    foreach (string str in Model) {
        <p>@str</p>
    }
    @Html.ActionLink("Back", "Names");
}

當 View 的 Model 中沒有資料時,View 生成的表單部分的 Html 程式碼如下:

<form action="/Home/Names" method="post"> 
    <div><label>1:</label><input id="names" name="names" type="text" value="" /></div> 
    <div><label>2:</label><input id="names" name="names" type="text" value="" /></div> 
    <div><label>3:</label><input id="names" name="names" type="text" value="" /></div> 
    <button type="submit">Submit</button> 
</form> 

當我們提交表單後,Model Binder 檢視 action 方法需要一個 string 型別的陣列,它便從提交的資料中查詢所有和引數名相同的條目的值組裝成一個陣列。執行程式,可以看到如下效果:

 

繫結到集合

簡單型別的集合(如 IList<string>)的繫結和陣列是一樣的。大家可以把上面例子的 action 方法引數型別和 View 的 Model 型別換成 IList<string> 看下效果,這裡就不演示了。我們來看看 Model Binder 是如何支援複合型別集合的繫結的。

先建立一個帶有 IList<Address> 引數的 action 方法:

public ActionResult Address(IList<Address> addresses) {
    addresses = addresses ?? new List<Address>();
    return View(addresses);
}

對於複合型別的集合引數,在 View 中表單元素的 name 屬性應該怎樣命名才能被 Model Binder 識別為集合呢?下面為Address action 新增一個檢視,注意看表單部分,如下:

@using MvcApplication1.Models
@model IList<Address>
@{
    ViewBag.Title = "Address";
}

<h2>Addresses</h2>
@if (Model.Count() == 0) {
    using (Html.BeginForm()) {
        for (int i = 0; i < 2; i++) {
            <fieldset>
                <legend>Address @(i + 1)</legend>
                <div><label>City:</label>@Html.Editor("[" + i + "].City")</div>
                <div><label>Country:</label>@Html.Editor("[" + i + "].Country")</div>
            </fieldset>
        }
        <button type="submit">Submit</button>
    }
}
else {
    foreach (Address str in Model) {
        <p>@str.City, @str.Country</p>
    }
    @Html.ActionLink("Back", "Address");
}

如果是“編輯”狀態(即 View Model 有值的時候)還可以這樣寫:

...
<div><label>City:</label>@Html.EditorFor(m => m[i].City)</div>
<div><label>Country:</label>@Html.EditorFor(m => m[i].Country)</div>
...

這樣寫的目的是為了生成如下 name 屬性值: 

<fieldset> 
    <legend>Address 1</legend> 
    <div> 
        <label>City:</label> 
        <input class="text-box single-line" name="[0].City" type="text" value="" /> 
    </div> 
    <div> 
        <label>Country:</label> 
        <input class="text-box single-line" name="[0].Country" type="text" value="" /> 
    </div> 
</fieldset>
...

當 Model Binder 發現 Address action 方法需要一個 Address 集合作為引數時,它便從提交的資料中從索引 [0] 開始查詢和 Address 的屬性名稱相同的資料值,Model Binder 將建立一個 IList<Address> 集合來儲存這些值。執行程式,Url 定位到 /Home/Address,點提交按鈕後,效果如下:

 

手動呼叫 Model Binding

當 action 方法定義了引數時,Model Binding 的過程是自動的。我們也可以對Binding的過程進行手動控制,如控制 model 物件如何被例項化、從哪裡獲取資料及傳遞了錯誤的資料時如何處理。

下面修改 Address action 方法來演示瞭如何手動呼叫 Model Binding,如下:

public ActionResult Address() {
    IList<Address> addresses = new List<Address>();
    UpdateModel(addresses);
    return View(addresses);
}

功能上和前一個示例是一樣的。這裡的  UpdateModel 方法接收一個model 物件作為引數,預設的 Model Binder 將為該 model 物件的所有公開屬性進行繫結處理。

在前面我們講到 Model Binding 從 Request.Form、RouteData.Values、Request.QueryString 和 Request.Files四個地方獲取資料。當我們手動呼叫 Binding 的時候,可以指定只從某一個來源獲取資料,如下是隻從 Request.Form 中獲取資料的例子:

public ActionResult Address() {
    IList<Address> addresses = new List<Address>();
    UpdateModel(addresses, new FormValueProvider(ControllerContext));
    return View(addresses);
}

UpdateModel 方法指定了第二個引數是一個 FormValueProvider 的例項,它將使用 Model Binder 從只從 Request.Form 中查詢需要的資料。FormValueProvider 類是 IValueProvider 介面的實現,是 Value Provider 中的一種,相應的,RouteData.Values、Request.QueryString 和 Request.Files 的 Value Provider 分別是 RouteDataValueProvider、QueryStringValueProvider和HttpFileCollectionValueProvider。

另外,還有一種限制 Model Binder 數來源的方法,如下所示:

public ActionResult Address(FormCollection formData) {
    IList<Address> addresses = new List<Address>();
    UpdateModel(addresses, formData);
    return View(addresses);
}

它是用 Action 方法的某個集合型別的引數來指定並儲存從某一個來源獲取的資料,這個集合型別(示例的 FormCollection) 也是 IValueProvider 介面的一個實現。

有時候使用者會提交一些 和 model 物件的屬性不匹配的資料,如不合法的日期格式或給數值型別提供文字值,這時候繫結會出現錯誤,Model Binder 會用 InvalidOperationException 來表示。可以通過 Controller.ModelState 屬性找到具體的錯誤資訊,然後反饋給使用者:

public ActionResult Address(FormCollection formData) {
    IList<Address> addresses = new List<Address>();
    try {
        UpdateModel(addresses, formData);
    }
    catch (InvalidOperationException ex) {
        var allErrors = ModelState.Values.SelectMany(v => v.Errors);
        // do something with allErrors and provide feedback to user 
    }
    return View(addresses);
}

也可以使用 TryUpdateModel 方法:

public ActionResult Address(FormCollection formData) {
    IList<Address> addresses = new List<Address>();
    if (TryUpdateModel(addresses, formData)) {
        // proceed as normal 
    }
    else {
        // provide feedback to user 
    }
    return View(addresses); 
}

注意,當手動呼叫 Model Binding 時,這種繫結錯誤不會被識別為異常,我們可以用 ModelState.IsValid 屬性來檢查提交的資料是否合法。

自定義 Value Provider

通過自定義 Value Provider 我們可以為 Model Binding 新增自己的資料來源。前面我們講到了四種內建 Value Provider 實現的介面是 IValueProvider,我們可以實現這個介面來自定義一個 Value Provider。先來看這個介面的定義:

namespace System.Web.Mvc { 
    public interface IValueProvider { 
        bool ContainsPrefix(string prefix); 
        ValueProviderResult GetValue(string key); 
    } 
}

ContainsPrefix 方法是 Model Binder 根據給定的字首用來判斷是否要解析所給資料。GetValue 方法根據資料的key返回所需要值。下面我們新增一個 Infrastructure 資料夾,建立一個名為 CountryValueProvider 的類來實現這個介面,程式碼如下:

public class CountryValueProvider : IValueProvider {
    public bool ContainsPrefix(string prefix) {
        return prefix.ToLower().IndexOf("country") > -1;
    }
    public ValueProviderResult GetValue(string key) {
        if (ContainsPrefix(key))
            return new ValueProviderResult("China", "China", CultureInfo.InvariantCulture);
        else
            return null;
    }
}

這就自定義好了一個 Value Provider,當需要一個 Country 的值時,它始終返回"China",其它返回 null。ValueProviderResult 類的構造器有三個引數,第一個引數是原始值物件,第二個引數是原始物件的字串表示,最後一個是轉換這個值所關聯的 culture 資訊。

為了讓 Model Binder 呼叫這個 Value Provider,我們需要建立一個能實現化它的類,這個類需要繼承  ValueProviderFactory 抽象類。如下我們建立一個這樣的類,名為 CustomValueProviderFactory:

public class CustomValueProviderFactory : ValueProviderFactory {
    public override IValueProvider GetValueProvider(ControllerContext controllerContext) {
        return new CountryValueProvider();
    }
}

當 model binder 在繫結的過程中需要獲取值時會呼叫這裡的 GetValueProvider 方法。這裡我們沒有做別的處理,直接返回了一個 CountryValueProvider 例項。

最後我們需要在 Global.asax 檔案中的 Application_Start 方法中進行註冊,如下:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    ValueProviderFactories.Factories.Insert(0, new CustomValueProviderFactory());
...

通過 ValueProviderFactories.Factories 靜態集合的 Insert 方法註冊了我們的 CustomValueProviderFactory 類。Insert 方法中的 0 引數保證 Binder 將首先使用自定義的類來提供值。如果我們想在其他 value provider 不能提供值的時候使用,那麼我們可以使用 Add 方法,如下:

... 
ValueProviderFactories.Factories.Add(new CustomValueProviderFactory()); 
... 

執行程式,URL 定位到 /Home/Address,看到的效果如下:

 

自定義 Model Binder

我們也可以為特定的 Model 自定義 Model Binder。前面講了預設的 Model Binder 實現的介面是 IModelBinder(前文列出了它的定義),自定義的 Binder 自然也需要實現該介面。下面我們在 Infrastructure 資料夾中新增一個實現了該介面的名為  AddressBinder 類,程式碼如下:

public class AddressBinder : IModelBinder {
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
        Address model = (Address)bindingContext.Model ?? new Address();
        model.City = GetValue(bindingContext, "City");
        model.Country = GetValue(bindingContext, "Country");
        return model;
    }

    private string GetValue(ModelBindingContext context, string name) {
        name = (context.ModelName == "" ? "" : context.ModelName + ".") + name;
        ValueProviderResult result = context.ValueProvider.GetValue(name);
        if (result == null || result.AttemptedValue == "") 
            return "<Not Specified>";
        else 
            return (string)result.AttemptedValue;
    }
}

當 MVC 框架需要一個 model 型別的實現時,則呼叫 BindModel 方法。它的 ControllerContext 型別引數提供請求相關的上下文資訊,ModelBindingContext 型別引數提供 model 物件相關的上下文資訊。ModelBindingContext 常用的屬性有Model、ModelName、ModelType 和 ValueProvider。這裡的 GetValue 方法用到的 context.ModelName 屬性可以告訴我們,如果有字首(一般指複合型別名),則需要把它加在屬性名的前面,這樣 MVC 才能獲取到以 [0].City、[0].Country 名稱傳遞的值。

然後我們需要在 Global.asax 的 Application_Start 方法中對自定義的 Model Binder 進行註冊,如下所示:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    //ValueProviderFactories.Factories.Insert(0, new CustomValueProviderFactory());
    ModelBinders.Binders.Add(typeof(Address), new AddressBinder());
...

我們通過 ModelBinders.Binders.Add 方法對自定義的 Model Binder 進行註冊,引數中指定了應用該 Binder 的 Model 型別和自定義的 Binder 例項。執行程式,URL 定位到 /Home/Address,效果如下:

 

 


參考:Pro ASP.NET MVC 4 4th Edition

相關文章