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

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(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》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章