使用 ASP.NET MVC 的人應該知道 模型繫結 (Model Binding) 是個功能十分強大的設計,早在 ASP.NET MVC 1 就有了 Model Binder 的設計,不過從 ASP.NET MVC 2 開始新增了一個 Value Provider 設計,這部分一直都不太有人提及,今天我就來說說這兩者之間的差異與實際的運作方式。
提到 ASP.NET MVC 的執行生命週期,一個最簡單的解釋為:
- 由 路由 (Routing) 解析網址路徑
- 執行 模型繫結 (Model Binding) 將瀏覽器傳入的資訊綁定到 控制器 (Controller) 動作方法 (Action Method) 的參數列中
- 執行控制器動作方法,並回傳 動作結果 (ActionResult)
- 執行 動作結果 (如果為 ViewResult 就會去執行 View )
上述第 2 步驟的「模型繫結」其實在內部做了很多事情,最主要的功能就是將瀏覽器傳到 控制器 的各種資料 (表單資料、JSON、路由參數、查詢字串、…) 轉換成 強型別 物件。
例如說,我們有個 ViewModel 類別如下:
public class Test2ViewModel
{
public int id { get; set; }
public string Name { get; set; }
}
然後我們有個控制器的動作方法如下:
public ActionResult Test2(Test2ViewModel data)
{
return View(data);
}
當瀏覽器送出 HTTP 要求到上述的 Test2 動作方法 (Action method) 時,由於該動作方法所宣告的 Test2ViewModel 是一個強型別模型,因此 MvcHandler 在執行這個動作方法前,就會設法執行「模型繫結」動作,試圖建立一個 Test2ViewModel 型別的 data 物件 (其 data 為該動作方法的參數名稱)。
我想大部分的 ASP.NET MVC 開發人員多少都可以說出「只要傳入的表單欄位名稱等於 Test2ViewModel 的其中一個屬性名稱,該屬性的值就會自動被填入」這樣的說法。這樣的說法是沒有錯,但我今天要在講得深入一點。
事實上 ASP.NET MVC 在執行「模型繫結」動作的時候,其實是先透過內建的 DefaultModelBinder 來將瀏覽器傳來的資料轉換為強型別物件。在我們上述這個簡單的例子,就是要盡可能的滿足 Test2 動作方法的 data 參數要求 (將資料填入),該參數為 Test2ViewModel 型別,因此 DefaultModelBinder 會試圖找出所有可以填滿 Test2ViewModel 類別中所有的屬性,但重點是,這些資料要怎麼取得呢?是的,取得資料的動作,就是靠 ValueProvider 來處理。
你可以查看 ASP.NET MVC 開源專案的 ValueProviderFactories.cs 原始碼內容,這裡定義了一個 ValueProviderFactoryCollection 預設包含了 6 個不同的 ValueProviderFactory,分別用來取得各自不同的「資料來源」:
namespace System.Web.Mvc
{
public static class ValueProviderFactories
{
private static readonly ValueProviderFactoryCollection _factories = new ValueProviderFactoryCollection()
{
new ChildActionValueProviderFactory(),
new FormValueProviderFactory(),
new JsonValueProviderFactory(),
new RouteDataValueProviderFactory(),
new QueryStringValueProviderFactory(),
new HttpFileCollectionValueProviderFactory(),
};
public static ValueProviderFactoryCollection Factories
{
get { return _factories; }
}
}
}
由於從瀏覽器傳來的資料可能有很多種格式,有從表單 HTTP POST 過來的、有從 AJAX 發送 JSON 資料過來的、有從 路由資料 (Route Data) 過來的、有從 查詢字串 (Query String) 來的,所以 ASP.NET MVC 建立了一個公開的 ValueProviderFactories 靜態類別,預設定義了以下資料來源的解析類別,這些類別將依照順序的解析各種資料:
- ChildActionValueProviderFactory 取得從另一個 View 透過呼叫 @Html.Action 傳來的資料
- FormValueProviderFactory 取得從瀏覽器表單 HTTP POST 過來的所有欄位資料
- JsonValueProviderFactory 取得從瀏覽器的 JavaScript 透過 XHR 傳過來的 JSON 資料
- RouteDataValueProviderFactory 取得從網址路徑取得的到的路由參數值
- QueryStringValueProviderFactory 取得從瀏覽器網址列上的 查詢字串 (Query String) 傳來的值
- HttpFileCollectionValueProviderFactory 取得從瀏覽器表單透過檔案上傳功能傳來的檔案
這裡要注意的是,上述這 6 個不同的 ValueProvider 是有順序性的,會由上而下逐一取得資料,當 DefaultModelBinder 試圖取得某一個欄位的資料時,就會依序呼叫不同的 ValueProvider 來取得其值,如果第一個沒取得需要的值,就會換用下一個來取得資料,如果你在 FormValueProviderFactory 這個階段就取得需要的值的時候,就不會再往下嘗試。
我舉一個實際的例子:
- 假設有個網頁透過 JavaScript 發出一個 AJAX 要求,並傳遞一個 JSON 資料過來
- 這時 Test2 動作方法準備執行,由於 Test2 動作需要傳入一個 data 參數,且該參數型別為 Test2ViewModel
- 此時 MvcHandler 便呼叫 DefaultModelBinder 設法將可能的資料讀入,並將讀入的資料轉型成 Test2ViewModel 型別中的兩個屬性,也就是 int id 與 string Name 這兩個屬性
- 由於 DefaultModelBinder 必須一個一個解析每個屬性,所以他會先嘗試取得 int id 的資料
- 由於 DefaultModelBinder 只負責取得資料並將資料轉型成必要的型別,不負責解析瀏覽器傳來的資料,所以這次 DefaultModelBinder 便呼叫 ValueProvider 出來幫忙解析與取得資料
- 由於 DefaultModelBinder 不會知道資料到底在哪裡,所以 ValueProvider 就會開始依序叫用不同的 ValueProviderFactory 出來解析各種可能的資料來源
- 依照 ValueProviderFactories 註冊的順序,他先透過 ChildActionValueProviderFactory 查看是否有從 ChildAction 來的 id 資料?結果沒有!
- 再來透過 FormValueProviderFactory 嘗試解析是否有上一頁表單傳來的 id 欄位,由於我們這次是由 AJAX 發出的 JSON 資料,所以一樣沒找到資料。
- 接著透過 JsonValueProviderFactory 嘗試解析是否有上一頁傳來的 JSON 資料,結果這次解析成功,所以 JsonValueProviderFactory 將把屬性名稱為 id 的資料解析後回傳給 DefaultModelBinder
- DefaultModelBinder 得到 id 屬性的資料後,便將資料轉型為 int 型別,並將其值寫入到 data 物件的 id 屬性中
- 取得屬性值後,會先找出該 id 屬性是否有相對應的 驗證屬性 (ValidationAttribute) 需要進行 輸入驗證 (Input Validation),例如 [Required] 之類的驗證屬性。如果有,就會檢查該屬性是否符合必要的輸入驗證,如果驗證失敗,就會修改 ModelState.IsValid 的值為 false。
- 接著 DefaultModelBinder 要再解析下一個屬性,所以他會接著嘗試取得 string Name 這個屬性的資料
- 由於 DefaultModelBinder 只負責取得資料並將資料轉型成必要的型別,不負責解析瀏覽器傳來的資料,所以這次 DefaultModelBinder 便呼叫 ValueProvider 出來幫忙解析與取得資料
- 由於 DefaultModelBinder 不會知道資料到底在哪裡,所以 ValueProvider 就會開始依序叫用不同的 ValueProviderFactory 出來解析各種可能的資料來源
- 依照 ValueProviderFactories 註冊的順序,他先透過 ChildActionValueProviderFactory 查看是否有從 ChildAction 來的 Name 資料?結果沒有!
- 再來透過 FormValueProviderFactory 嘗試解析是否有上一頁表單傳來的 Name 欄位,由於我們這次是由 AJAX 發出的 JSON 資料,所以一樣沒找到資料。
- 接著透過 JsonValueProviderFactory 嘗試解析是否有上一頁傳來的 JSON 資料,結果這次解析成功,所以 JsonValueProviderFactory 將把屬性名稱為 Name 的資料解析後回傳給 DefaultModelBinder
- DefaultModelBinder 得到 Name 屬性的資料後,便將資料轉型為 string 型別 (其實本來就是 string 型別了),並將其值寫入到 data 物件的 Name 屬性中
- 取得屬性值後,會先找出該 Name 屬性是否有相對應的 驗證屬性 (ValidationAttribute) 需要進行 輸入驗證 (Input Validation),例如 [Required] 或 [StringLength(10)] 之類的驗證屬性。如果有,就會檢查該屬性是否符合必要的輸入驗證,如果驗證失敗,就會修改 ModelState.IsValid 的值為 false。
- 當 DefaultModelBinder 已經完整取得所有可能的屬性,所有找不到的屬性都會自動變成該屬性的預設值 default(T)
- 最後,還會檢查 Test2ViewModel 是否有實作 IValidatableObject 介面,如果有的話,就還會最後做一次 模型驗證 (Model Validation),如果驗證失敗,一樣會修改 ModelState.IsValid 的值為 false。
- 上述「模型繫結」工作結束後,就會交由 MvcHandler 正是呼叫 Test2 動作方法,並將取得後的 Test2ViewModel data 參數值傳入。
相信透過上述的說明,可以讓大家徹底了解 ModelBinder 與 ValueProvider 的真正用途與分工! (如果真的有看懂的話啦) XD
其實這個主題很有趣,也很重要,因為我們使用 ASP.NET MVC 開發網站偶爾還是會遇到鬼打牆的情況,例如我們在特定一個動作方法綁定一個 int id 參數
- public ActionResult Test(int id) {
// …
}
到底這個 id 參數傳入的值為何呢?假設你的網址長這樣,同時有路由參數 id 又有查詢字串的 id:
- http://localhost/Home/Test/1?id=2
請問你可否在開發時期確定 public ActionResult Test(int id) 所得到的 id 值到底是 1 還是 2 呢?
如果上述網站,再加上前一頁的表單 POST 過來的欄位也包含一個 id 欄位為 3 呢?請問你可否在開發時期確定 public ActionResult Test(int id) 所得到的 id 值到底是 1 是 2 還是 3 呢?
如果你知道 ValueProviderFactories 的設定順序,我想這個答案你就會了然於心,下次用起模型繫結也不會太擔心想錯了。 ;-)
相關連結