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 上的值:
- 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》
作者:Liam Wang