ASP.NET MVC 模型绑定的功能和问题
ASP.NET MVC 模型绑定通过引入自动填充控制器操作参数的抽象层、处理通常与使用 ASP.NET 请求数据有关的普通属性映射和类型转换代码来简化控制器操作。 虽然模型绑定看起来很简单,但实际上是一个相对较复杂的框架,由许多共同创建和填充控制器操作所需对象的部件组成。
本文将与你深入探究 ASP.NET MVC 模型绑定子系统的核心部分,展示模型绑定框架的每一层并提供扩展模型绑定逻辑以满足应用程序需求的各种方法。 同时,你还会看到一些经常被忽视的模型绑定技术,并了解如何避免一些最常见的模型绑定错误。
模型绑定基础知识
为了了解什么是模型绑定,让我们首先看看从 ASP.NET 应用程序请求值填充对象的传统方法,如图 1 所示。
图 1 从请求直接检索值
public ActionResult Create()
{
var product = new Product() {
AvailabilityDate = DateTime.Parse(Request["availabilityDate"]),
CategoryId = Int32.Parse(Request["categoryId"]),
Description = Request["description"],
Kind = (ProductKind)Enum.Parse(typeof(ProductKind),
Request["kind"]),
Name = Request["name"],
UnitPrice = Decimal.Parse(Request["unitPrice"]),
UnitsInStock = Int32.Parse(Request["unitsInStock"]),
};
// ...
}
然后将图 1 和图 2 中的操作进行对比,图 2 利用模型绑定生成相同的结果。
图 2 原始值的模型绑定
public ActionResult Create(
DateTime availabilityDate, int categoryId,
string description, ProductKind kind, string name,
decimal unitPrice, int unitsInStock
)
{
var product = new Product() {
AvailabilityDate = availabilityDate,
CategoryId = categoryId,
Description = description,
Kind = kind,
Name = name,
UnitPrice = unitPrice,
UnitsInStock = unitsInStock,
};
// ...
}
尽管两个示例都实现了相同目的(即填充 Product 实例),图 2 中的代码依靠 ASP.NET MVC 将请求中的值转换为强类型化的值。 使用模型绑定,控制器操作可以专注于提供业务值,并避免在普通请求映射和解析上浪费时间。
复杂对象的绑定
即使是简单、原始类型的模型绑定也可以产生深远影响,但许多控制器操作都不仅仅依靠几个参数。 幸运的是,ASP.NET MVC 可以处理原始类型和复杂类型。
下面的代码在 Create 操作中多执行了一次传递,跳过原始值并直接绑定到 Product 类:
public ActionResult Create(Product product)
{
// ...
}
同样,该代码与图 1 和图 2 中的操作生成相同的结果,但这次根本没有调用代码,复杂的 ASP.NET MVC 模型绑定消除了创建和填充新 Product 实例所需的全部样板代码。 该代码说明了模型绑定的实际强大功能。
分解模型绑定
既然你已经了解操作中的模型绑定,现在是时候分解构成模型绑定框架的组件了。
模型绑定可以分解为两个不同步骤: 从请求收集值并使用这些值填充模型。 这些步骤分别由值提供程序和模型绑定程序来完成。
值提供程序
ASP.NET MVC 包括值提供程序的实现,这些实现涵盖了大多数常见请求值源,例如查询字符串参数、表单字段和路由数据。 在运行时,ASP.NET MVC 使用 ValueProviderFactories 类中注册的值提供程序计算模型绑定程序可以使用的请求值。
默认情况下,值提供程序集合按下面的顺序计算来自各种源的值:
- 以前绑定的操作参数(当该操作为子操作时)
- 表单字段 (Request.Form)
- JSON 请求主体中的属性值 (Request.InputStream),但仅当该请求为 AJAX 请求时
- 路由数据 (RouteData.Values)
- 查询字符串参数 (Request.QueryString)
- 已发布文件 (Request.Files)
值提供程序集合如同 Request 对象一样,实际上只不过是一个所谓的字典,即模型绑定程序可以使用且无需知道数据来源的键/值对的抽象层。 然而同 Request 字典相比,值提供程序框架进一步实现了这种抽象,它允许你完全控制模型绑定框架获取其数据的方式及位置。 你甚至可以创建自己的自定义值提供程序。
自定义值提供程序
创建自定义值提供程序的最低要求非常简单: 创建实现 System.Web.Mvc.ValueProviderFactory 接口的新类。
例如,图 3 演示了从用户的 cookie 中检索值的自定义值提供程序。
图 3 检查 Cookie 值的自定义值提供程序工厂
public class CookieValueProviderFactory : ValueProviderFactory
{
public override IValueProvider GetValueProvider
(
ControllerContext controllerContext
)
{
var cookies = controllerContext.HttpContext.Request.Cookies;
var cookieValues = new NameValueCollection();
foreach (var key in cookies.AllKeys)
{
cookieValues.Add(key, cookies[key].Value);
}
return new NameValueCollectionValueProvider(
cookieValues, CultureInfo.CurrentCulture);
}
}
请注意 CookieValueProviderFactory 非常简单。 CookieValueProviderFactory 简单地检索用户的 cookie 并利用 NameValueCollectionValueProvider 将这些值向模型绑定框架公开,而不是从头构建一个全新的值提供程序。
在创建自定义值提供程序之后,你需要通过 ValueProviderFactories.Factories 集合将其添加到值提供程序的列表中:
var factory = new CookieValueProviderFactory();
ValueProviderFactories.Factories.Add(factory);
创建自定义值提供程序非常简单,但执行此操作时请务必小心。 ASP.NET MVC 提供的现有值提供程序集能够很好地公开 HttpRequest 中大多数可用数据(可能 cookie 除外),并且通常提供足够的数据以满足大多数情况的需要。
要确定新建值提供程序对于特定情况来说是否是正确的,请回答以下问题: 现有值提供程序提供的信息集是否包括我需要的所有数据(但可能采用错误格式)?
如果不包括,添加自定义值提供程序可能是弥补缺少部分的正确方法。 但是,如果包括(通常情况),请考虑如何通过自定义模型绑定行为访问值提供程序提供的数据来弥补缺少部分。 本文的其余部分介绍如何执行此操作。
负责使用值提供程序提供的值创建和填充模型的 ASP.NET MVC 模型绑定框架主要组件被称为模型绑定程序。
默认模型绑定程序
ASP.NET MVC 框架包括名为 DefaultModelBinder 的默认模型绑定程序实现,其旨在高效绑定大多数模型类型。 它通过对目标模型的各个属性使用相对较简单的递归逻辑来实现该目的:
- 检查值提供程序,以便通过查看属性名称是否注册为前缀来确定该属性是作为简单类型还是复杂类型发现。 前缀仅仅是 HTML 表单字段名“点表示法”,用于表示值是否是复杂对象的属性。 前缀模式为 [ParentProperty].[Property]。 例如,名称为 UnitPrice.Amount 的表单字段包含 UnitPrice 属性的 Amount 字段的值。
- 从属性名称的注册值提供程序获取 ValueProviderResult。
- 如果值为简单类型,请尝试将其转换为目标类型。 默认的转换逻辑利用属性的 TypeConverter 从字符串类型的源值转换为目标类型。
- 但如果属性为复杂类型,则执行递归绑定。
递归模型绑定
递归模型绑定高效地重复启动整个模型绑定过程,但使用目标属性的名称作为新前缀。 使用此方法,DefaultModelBinder 能够遍历整个复杂对象图表,甚至填充深度嵌套的属性值。
要在操作中查看递归绑定,请将 Product.UnitPrice 从简单的小数类型更改为自定义类型 Currency。 图 4 显示两个类。
图 4 带复杂 Unitprice 属性的 Product 类
public class Product
{
public DateTime AvailabilityDate { get; set; }
public int CategoryId { get; set; }
public string Description { get; set; }
public ProductKind Kind { get; set; }
public string Name { get; set; }
public Currency UnitPrice { get; set; }
public int UnitsInStock { get; set; }
}
public class Currency
{
public float Amount { get; set; }
public string Code { get; set; }
}
执行此更新时,模型绑定程序将查找名为 UnitPrice.Amount 和 UnitPrice.Code 的值以便填充复杂的 Product.UnitPrice 属性。
DefaultModelBinder 递归绑定逻辑甚至可以高效地填充最复杂的对象图表。 到目前为止,你见过了位于对象层次结构的一个层级深度的复杂对象,DefaultModelBinder 可以轻松处理此对象。 要演示递归模型绑定的实际强大功能,请将名为 Child 的新属性添加到相同类型的 Product:
public class Product {
public Product Child { get; set; }
// ...
}
然后,将新字段添加到表单(应用点表示法指示每一层),根据所需层数创建层。 例如:
<input type="text" name="Child.Child.Child.Child.Child.Child.Name"/>
该表单字段将生成包含六个层的 Product! 对于每个层,DefaultModelBinder 都会忠实地创建一个新的 Product 实例并直接绑定其值。 当绑定程序完成所有工作后,它将创建一个与图 5 中代码相似的对象图表。
图 5 从递归模型绑定创建的对象图表
new Product {
Child = new Product {
Child = new Product {
Child = new Product {
Child = new Product {
Child = new Product {
Child = new Product {
Name = "MADNESS!"
}
}
}
}
}
}
}
尽管精心设计的上述示例仅设置了一个属性的值,但却很好地演示了 DefaultModelBinder 递归模型绑定功能如何允许它支持一些非常复杂的现有对象图表。 如果你可以创建表单字段名表示要填充的值,通过使用递归模型绑定,不管该值位于对象层次结构中的哪个位置,模型绑定程序都会找到并绑定它。
模型绑定不适用的情况
实际情况: 存在一些 DefaultModelBinder 无法绑定的模型。 然而,还存在默认模型绑定逻辑看似无法运行、但实际上只要使用得当仍可正常运行的情况,并且这种情况相当普遍。
下面提供了开发人员往往认为 DefaultModelBinder 无法处理的一些最常见的情况,并说明了如何仅使用 DefaultModelBinder 来实现这些情况。
复杂集合 现有的 ASP.NET MVC 值提供程序将所有请求字段名称视为表单发布值对待。 例如,表单发布中的原始值集合,其中每个值都需要其自己的唯一索引(添加了空格以增强可读性):
MyCollection[0]=one &
MyCollection[1]=two &
MyCollection[2]=three
相同方法还可应用于复杂对象集合。 要演示此功能,请通过将 UnitPrice 属性更改为 Currency 对象集合来更新 Product 类以便支持多种货币:
public class Product : IProduct
{
public IEnumerable<Currency> UnitPrice { get; set; }
// ...
}
更改之后,需要下面的请求参数来填充更新后的 UnitPrice 属性:
UnitPrice[0].Code=USD &
UnitPrice[0].Amount=100.00 &
UnitPrice[1].Code=EUR &
UnitPrice[1].Amount=73.64
仔细观察绑定复杂对象集合所需的请求参数的命名语法。 请注意,该区域中用于标识各个唯一项的索引器和每个实例的每个属性都必须包含该实例已编制索引的完整引用。 请记住,模型绑定程序要求属性名称遵循表单发布命名语法,而不管请求是 GET 还是 POST。
尽管有些不合逻辑,但是 JSON 请求具有相同要求,它们也必须遵循表单发布命名语法。 例如,前面的 UnitPrice 集合的 JSON 负载。 用于该数据的纯 JSON 数组语法应表示为:
[
{ "Code": "USD", "Amount": 100.00 },
{ "Code": "EUR", "Amount": 73.64 }
]
但是,默认值提供程序和模型绑定程序要求将数据表示为 JSON 表单发布:
{
"UnitPrice[0].Code": "USD",
"UnitPrice[0].Amount": 100.00,
"UnitPrice[1].Code": "EUR",
"UnitPrice[1].Amount": 73.64
}
复杂对象集合情况可能是开发人员遇到的问题最多的情况之一,因为不是所有开发人员都必须了解该语法。 然而,在你了解了相对较简单的语法来发布复杂集合之后,处理这些情况要容易得多。
通用自定义模型绑定器尽管 DefaultModelBinder 的功能强大到几乎能够处理你要处理的所有事情,但是它有时候也不能满足你的需求。 发生这些情况时,许多开发人员抓住机会使用模型绑定框架的可扩展性模型,并构建其自己的自定义模型绑定程序。
例如,即使 Microsoft .NET Framework 为面向对象的原则提供卓越的支持,但是 DefaultModelBinder 仍不支持绑定到抽象基类和接口。 要演示这种缺陷,请重构 Product 类以使其派生自包含只读属性的名为 IProduct 的接口。 同样,更新 Create 控制器操作以使其接受新的 IProduct 接口,而不是具体的 Product 实现,如图 6 所示。
图 6 绑定到接口
public interface IProduct
{
DateTime AvailabilityDate { get; }
int CategoryId { get; }
string Description { get; }
ProductKind Kind { get; }
string Name { get; }
decimal UnitPrice { get; }
int UnitsInStock { get; }
}
public ActionResult Create(IProduct product)
{
// ...
}
尽管图 6 中显示的更新后的 Create 操作是完全合法的 C# 代码,但会导致 DefaultModelBinder 引发异常: “无法创建接口的实例。”由于 DefaultModelBinder 无法得知要创建的 IProduct 的具体类型,因此模型绑定程序引发此异常完全合乎情理。
解决该问题的最简单的方法是创建实现 IModelBinder 接口的自定义模型绑定程序。 图 7 显示了 ProductModelBinder,即知道如何创建和绑定 IProduct 接口实例的自定义模型绑定程序。
图 7 ProductModelBinder(紧密耦合的自定义模型绑定程序)
public class ProductModelBinder : IModelBinder
{
public object BindModel
(
ControllerContext controllerContext,
ModelBindingContext bindingContext
)
{
var product = new Product() {
Description = GetValue(bindingContext, "Description"),
Name = GetValue(bindingContext, "Name"),
};
string availabilityDateValue =
GetValue(bindingContext, "AvailabilityDate");
if(availabilityDateValue != null)
{
DateTime date;
if (DateTime.TryParse(availabilityDateValue, out date))
product.AvailabilityDate = date;
}
string categoryIdValue =
GetValue(bindingContext, "CategoryId");
if (categoryIdValue != null)
{
int categoryId;
if (Int32.TryParse(categoryIdValue, out categoryId))
product.CategoryId = categoryId;
}
// Repeat custom binding code for every property
// ...
return product;
}
private string GetValue(
ModelBindingContext bindingContext, string key)
{
var result = bindingContext.ValueProvider.GetValue(key);
return (result == null) ?
null : result.AttemptedValue;
}
}
创建直接实现 IModelBinder 接口的自定义模型绑定程序的缺点是,这些绑定程序经常仅为了修改几个逻辑区域而复制大量 DefaultModelBinder。 此外,这些自定义绑定程序的常见问题还包括:侧重于特定模型类、在框架和业务层之间创建紧密耦合,以及限制重复使用以支持其他模型类型。
要在你的自定义模型绑定程序中避免所有这些问题,请考虑从 DefaultModelBinder 派生并覆盖特定行为以满足你的需求。 此方法通常可以为两个领域的提供最佳功能。
抽象模型绑定程序尝试使用 DefaultModelBinder 将模型绑定应用到接口的唯一问题是它不知道如何确定具体的模型类型。 请考虑更高级别的目标: 针对非具体类型开发控制器操作并动态确定每个请求的具体类型的功能。
通过从 DefaultModelBinder 派生并仅覆盖确定目标模型类型的逻辑,你不仅可以满足特定 IProduct 方案的需求,还可以实际创建可处理大多数其他接口层次结构的通用模型绑定程序。 图 8 显示通用模型抽象模型绑定程序的示例。
图 8 通用抽象模型绑定程序
public class AbstractModelBinder : DefaultModelBinder
{
private readonly string _typeNameKey;
public AbstractModelBinder(string typeNameKey = null)
{
_typeNameKey = typeNameKey ?? "
__type__";
}
public override object BindModel
(
ControllerContext controllerContext,
ModelBindingContext bindingContext
)
{
var providerResult =
bindingContext.ValueProvider.GetValue(_typeNameKey);
if (providerResult != null)
{
var modelTypeName = providerResult.AttemptedValue;
var modelType =
BuildManager.GetReferencedAssemblies()
.Cast<Assembly>()
.SelectMany(x => x.GetExportedTypes())
.Where(type => !type.IsInterface)
.Where(type => !type.IsAbstract)
.Where(bindingContext.ModelType.IsAssignableFrom)
.FirstOrDefault(type =>
string.Equals(type.Name, modelTypeName,
StringComparison.OrdinalIgnoreCase));
if (modelType != null)
{
var metaData =
ModelMetadataProviders.Current
.GetMetadataForType(null, modelType);
bindingContext.ModelMetadata = metaData;
}
}
// Fall back to default model binding behavior
return base.BindModel(controllerContext, bindingContext);
}
}
要支持接口的模型绑定,模型绑定程序必须首先将接口转换为具体类型。 为此,AbstractModelBinder 从请求的值提供程序请求“__type__”键。 对此类型的数据使用值提供程序可以在定义“__type__”值的范围内提供灵活性。 例如,该键可以定义为路由的一部分(在路由数据中),指定为查询字符串参数,或者甚至表示为表单发布数据中的字段。
接下来,AbstractModelBinder 使用具体类型名称生成一组新的元数据,以描述具体类的详细信息。 AbstractModelBinder 使用该新的元数据取代描述初始抽象模型类型的现有 ModelMetadata 属性,有效地导致模型绑定程序忘记它在一开始曾绑定到非具体类型。
在 AbstractModelBinder 使用绑定到正确模型所需的所有信息取代模型元数据后,它会完全将控制权交还给基本的 DefaultModelBinder 逻辑以使其处理其余工作。
AbstractModelBinder 是一个很好的示例,演示了如何通过直接从 IModelBinder 接口派生,使用你自己的自定义逻辑扩展默认绑定逻辑,而不需要重复进行基本的工作。
模型绑定程序选择
创建自定义模型绑定程序仅仅是第一步。 要在你的应用程序中应用自定义模型绑定逻辑,你还必须注册自定义模型绑定程序。 大多数教程都向你演示了两种注册自定义模型绑定程序的方法。
全局 ModelBinders 集合覆盖特定类型的模型绑定程序的一般推荐方法是将类型到绑定程序的映射注册到 ModelBinders.Binders 字典。
下面的代码段通知框架使用 AbstractModelBinder 绑定 Currency 模型:
- ModelBinders.Binders.Add(typeof(Currency), new AbstractModelBinder());
覆盖默认模型绑定程序或者,你也可以通过将模型绑定程序分配给 ModelBinders.Binders.DefaultBinder 属性来替换全局默认处理程序。 例如:
ModelBinders.Binders.DefaultBinder = new AbstractModelBinder();
尽管这两种方法适用于许多情况,但是 ASP.NET MVC 还允许你使用其他方法为类型注册模型绑定程序: 属性和提供程序。
使用自定义属性修饰模型
除将类型映射添加到 ModelBinders 字典以外,ASP.NET MVC 框架还提供抽象 System.Web.Mvc.CustomModelBinderAttribute,该属性允许你为应用该属性 (attribute) 的每个类或属性 (property) 动态创建模型绑定程序。 图 9 显示创建 AbstractModelBinder 的 CustomModelBinderAttribute 实现。
图 9 CustomModelBinderAttribute 实现
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Enum |
AttributeTargets.Interface | AttributeTargets.Parameter |
AttributeTargets.Struct | AttributeTargets.Property,
AllowMultiple = false, Inherited = false
)]
public class AbstractModelBinderAttribute : CustomModelBinderAttribute
{
public override IModelBinder GetBinder()
{
return new AbstractModelBinder();
}
}
然后,你可以将 AbstractModelBinderAttribute 应用到任何模型类或属性,例如:
public class Product
{
[AbstractModelBinder]
public IEnumerable<CurrencyRequest> UnitPrice { get; set; }
// ...
}
现在,当模型绑定程序尝试为 Product.UnitPrice 查找合适的绑定程序时,它将发现 AbstractModelBinderAttribute 并使用 AbstractModelBinder 绑定 Product.UnitPrice 属性。
利用自定义模型绑定程序属性为使用更具声明性的方法配置模型绑定程序,同时简化全局模型绑定程序集合提供了一种绝佳方法。 此外,自定义模型绑定程序属性可以应用于所有类和单个属性的事实意味着你可以精确控制模型绑定过程。
问绑定程序!
模型绑定程序提供程序提供实时执行任意代码以确定指定类型的最佳可能模型绑定程序的功能。 因此,他们在用于单个模型类型的显式模型绑定程序注册、基于属性的静态注册和用于所有类型的已设定默认模型绑定程序之间提供了绝佳的中间地带。
下面的代码演示了如何创建为所有端口和抽象类型提供 AbstractModelBinder 的 IModelBinderProvider:
public class AbstractModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(Type modelType)
{
if (modelType.IsAbstract || modelType.IsInterface)
return new AbstractModelBinder();
return null;
}
}
指示是否将 AbstractModelBinder 应用到指定模型类型的逻辑则相对较简单: 该类型是否为非具体类型? 如果是,AbstractModelBinder 则是适用于该类型的模型绑定程序,因此请实例化模型绑定程序并将其返回。 如果该类型是一个具体类型,AbstractModelBinder 不适用;请返回空值以指示模型绑定程序与该类型不匹配。
实现 .GetBinder 逻辑时需要记住的重要一点是将针对作为模型绑定候选项的所有属性执行该逻辑,所以请务必精简该逻辑,否则很容易为你的应用程序带来性能问题。
为了开始使用模型绑定程序提供程序,将其添加到在 ModelBinderProviders.BinderProviders 集合中维护的提供程序列表。 例如,按如下方式注册 AbstractModelBinder:
var provider = new AbstractModelBinderProvider();
ModelBinderProviders.BinderProviders.Add(provider);
这非常简单,你已经在整个应用程序中为非具体类型添加了模型绑定支持。
模型绑定方法将确定适当的模型绑定程序的任务从框架转移到最合适的位置 — 模型绑定程序本身,从而使模型绑定选择 更具有动态性。
关键扩展点
与任何其他方法一样,ASP.NET MVC 模型绑定允许控制器操作接受复杂的对象类型作为参数。 此外,模型绑定分离填充对象的逻辑和使用填充对象的逻辑,从而有助于更好地分离问题。
我已探究了模型绑定框架中一些关键扩展点,可以帮助你充分利用该框架。 花时间了解 ASP.NET MVC 模型绑定以及如何正确使用它可以对所有应用程序(甚至是最简单的应用程序)带来重大影响。
Jess Chadwick 是一名专攻 Web 技术的独立软件顾问。 他具有 10 多年的开发经验,其涉及范围从新兴企业的嵌入式设备到财富 500 强公司的企业级 Web 场。 他是一名 ASP 专家、微软 ASP.NET 最佳职员,以及书籍和杂志作者。 他积极参与开发社团,经常在用户组和会议中发言,并领导 NJDOTNET 新泽西州中部 .NET 用户组。
衷心感谢以下技术专家对本文进行了审阅:Phil Haack