The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

ASP.NET MVC 開發心得分享 (25):ModelBinder 與 ValueProvider 的用途

使用 ASP.NET MVC 的人應該知道 模型繫結 (Model Binding) 是個功能十分強大的設計,早在 ASP.NET MVC 1 就有了 Model Binder 的設計,不過從 ASP.NET MVC 2 開始新增了一個 Value Provider 設計,這部分一直都不太有人提及,今天我就來說說這兩者之間的差異與實際的運作方式。

提到 ASP.NET MVC 的執行生命週期,一個最簡單的解釋為:

  1. 路由 (Routing) 解析網址路徑
  2. 執行 模型繫結 (Model Binding) 將瀏覽器傳入的資訊綁定到 控制器 (Controller) 動作方法 (Action Method) 的參數列中
  3. 執行控制器動作方法,並回傳 動作結果 (ActionResult)
  4. 執行 動作結果 (如果為 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 靜態類別,預設定義了以下資料來源的解析類別,這些類別將依照順序的解析各種資料:

  1. ChildActionValueProviderFactory 取得從另一個 View 透過呼叫 @Html.Action 傳來的資料
  2. FormValueProviderFactory 取得從瀏覽器表單 HTTP POST 過來的所有欄位資料
  3. JsonValueProviderFactory 取得從瀏覽器的 JavaScript 透過 XHR 傳過來的 JSON 資料
  4. RouteDataValueProviderFactory 取得從網址路徑取得的到的路由參數值
  5. QueryStringValueProviderFactory 取得從瀏覽器網址列上的 查詢字串 (Query String) 傳來的值
  6. 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 值到底是 12 還是 3 呢?

如果你知道 ValueProviderFactories 的設定順序,我想這個答案你就會了然於心,下次用起模型繫結也不會太擔心想錯了。 ;-)

相關連結

留言評論