ASP.NET MVC

The Features and Foibles of ASP.NET MVC Model Binding

Jess Chadwick

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 類中註冊的值提供程式計算模型綁定程式可以使用的請求值。

預設情況下,值提供程式集合按下麵的順序計算來自各種源的值:

  1. 以前綁定的指令引數(當該操作為子操作時)
  2. 表單欄位 (Request.Form)
  3. JSON 請求主體中的屬性值 (Request.InputStream),但僅當該請求為 AJAX 請求時
  4. 路由資料 (RouteData.Values)
  5. 查詢字串參數 (Request.QueryString)
  6. 已發佈檔 (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 的預設模型綁定程式實現,其旨在高效綁定大多數模型類型。 它通過對目標模型的各個屬性使用相對較簡單的遞迴邏輯來實現該目的:

  1. 檢查值提供程式,以便通過查看屬性名稱是否註冊為首碼來確定該屬性是作為簡單類型還是複雜類型發現。 首碼僅僅是 HTML 表單欄位名“點標記法”,用於表示值是否是複雜物件的屬性。 首碼模式為 [ParentProperty].[Property]。 例如,名稱為 UnitPrice.Amount 的表單欄位包含 UnitPrice 屬性的 Amount 欄位的值。
  2. 從屬性名稱的註冊值提供程式獲取 ValueProviderResult。
  3. 如果值為簡單類型,請嘗試將其轉換為目標類型。 預設的轉換邏輯利用屬性的 TypeConverter 從字串類型的源值轉換為目標類型。
  4. 但如果屬性為複雜類型,則執行遞迴綁定。

遞迴模型綁定

遞迴模型綁定高效地重複啟動整個模型綁定過程,但使用目標屬性的名稱作為新首碼。 使用此方法,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; }
  // ...
}

然後,將新欄位添加到表單(應用點標記法指示每一層),根據所需層數創建層。 For example:

<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 屬性來替換全域預設處理常式。 For example:

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