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 上的值:
- Request.Form,HTML form 元素提供的值。
- RouteData.Values,通過應用程式路由提供的值。
- Request.QueryString,所請求 URL 的 query string 值。
- Request.Files,用戶端上傳的檔案。
DefaultModelBinder 按照該順序來查找需要的值。如對于上面的例子,DefaultModelBinder 會按照如下順序為 id 參數查找值:
- Request.Form["id"]
- RouteData.Values["id"]
- Request.QueryString["id"]
- 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》