ASP.NET MVC,深入淺出IModelBinder,在Post方式下慎用HtmlHelper
本文基于ASP.NET MVC Beta版本,正式版如有變動諸不另行通知!
在開始這個主題之前,我先簡要介紹一下如何在ActionMethod中通過Form使用Post的方式進行傳遞參數。
原生類型參數傳遞
先看一個簡單的示例:
public ActionResult SimplePost(string number) { ViewData["Title"] = "SimplePost Page"; ViewData["Message"] = "Increase :"; #region Increase SimplePostModel model = new SimplePostModel(); int result; if (!string.IsNullOrEmpty(number)) { if (int.TryParse(number, out result)) { model.SimplePostResult = result; ViewData["number"] = model.Increase(); } else { ViewData["number"] = number; } } else { ViewData["number"] = model.SimplePostResult; } #endregion return View(); }
<%@ Page Language="C#" AutoEventWireup="true" MasterPageFile="~/Views/Shared/Site.Master" CodeBehind="SimplePost.aspx.cs" Inherits="MvcAppWarningPostWithHtmlHelper.Views.Home.SimplePost" %> <%@ Import Namespace="MvcAppWarningPostWithHtmlHelper.Models" %> <asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server"> <h2> <%= Html.Encode(ViewData["Message"]) %>h2> <% using (Html.BeginForm("SimplePost", "Home", "post")) {%> <input type="text" name="number" value="<%= ViewData["number"] %>" /> <br /> <input type="submit" value="Increase" title="Update Form"/> <%} %> asp:Content>
該示例通過在頁面放置一個Form再用一個Submit進行提交,在Form中,通過向Controller SimplePost發送Form表單,在public ActionResult SimplePost(string number) 的參數中我們將獲得從客戶端傳回的number輸入框的值,由此我們就可以展開后續的工作了,具體的設置用法等,大家可以參考以上代碼。
那么我們是否僅限于傳遞類似string這樣的簡單類型了呢?答案當然是否定的,如果僅限于此就完全沒有多少值得探討的地方了。
下面我們用一個自定義的類型來看看我們是如何完成這個任務的。
首先在頁面上放置了一個表單,并在Submit按鈕點擊后,向服務端提交表單,并將數據整理成一個User對象,而不是幾個字符串,然后再從服務端輸出到Submit按鈕下方的空白處,返回回來。
我們的HTML一定是一組文本串,這一點毋庸置疑,在將表單提交回服務端的時候,服務端最常用的手段就可以根據傳回來的值獲得用戶輸入的數據,但是服務端并不知道如何將這些數據組織成我們所需要的復合對象,而這一點就需要我們有所作為。在MVC框架中,默認為我們提供了DefaultModelBinder,這個類繼承自IModelBinder接口,目的只有一個,就是將客戶端的數據組合成我們需要的Model的類型。因為是復雜類型,具體該怎么組合,程序并不知道,因此我們引入自己定義的ModelBinder,這個ModelBinder繼承自IModelBinder,目的旨在一個BindModel方法上,我們在方法內通過傳遞的參數得到ModelBindingContext,然后從提交的數據中進行分析,就可以隨意組合成我們自己的復雜對象了。(詳細代碼請下載后查閱)而就表單提交而言,則是通過Form的方式從頁面中獲取對應已知元素的值,這些值即是我們所要捕獲的數據。
但是一定有朋友會質疑,那我的系統有無數的Model類型,那么我不是也要很多很多的ModelBinder類了嗎?當然也不是,因為系統既然有DefaultModelBinder,它肯定不是僅為一種特殊的類服務的。DefaultModelBinder采用了反射的方式,通過分析我們的ActionMethod的參數名,通過我們的指定的參數名和相應類型,它可以在“可查詢的值范圍”內查找相應的值。而這里“可查詢的值范圍”默認就是DefalultValueProvider,DefaultValueProvider會從RouteData,QueryString,Form中查詢,查詢優先級也是依照這個,可以看出Url的優先級優于表單。
通過IValueProvider接口我們不難看出(只有一個方法),它通過鍵值的方式進行取值,值被保存在ValueProviderResult中返回。因此我們可以簡單地認為只要能夠在所提供的ValueProvider中獲取到相應的值,并且符合對應的類型(類型轉換將由ModelBinder來完成),即可完成相應的供值任務。而在取到值之后則由ModelBinder進行組裝最后將這些值返回給我們的ActionMethod即完成了自定義轉換值的任務。
上圖是ControllerActionInvoker中InvokeAction中的一段方法,其中的代碼
IDictionary<string, object> parameters = GetParameterValues(methodInfo);
就是為了將ActionMethod的參數轉換為一個鍵值對,而這里的鍵就是我們的參數名,而值則是對應類型的值,感興趣的朋友可以在這里設置斷點自行跟蹤。
已知元素
在上文我用下劃線標注了一個“已知元素”,我們知道我們通過Form方式進行取值,我們必須知道頁面元素的名字,但是我們使用DefaultModelBinder的時候,也就是我們沒有對復合類型指定任何的自定義ModelBinder的時候,我們從未提供任何的值用于指定我們的元素對應規則,那么框架是如何為我們提供取值的呢?
DefaultModelBinder通過反射的方式,利用我們提供的ActionMethod的參數名,在Form中尋找對應的名字以及它的屬性。雖然我們從未提供對應屬性的值,但是根據反射,即可獲得對應屬性名,再依照約定將這些名字組合成對應的Form Key,即可從Form中獲取相應的元素值了。具體對應規則則為:
假設我們的User對象定義為如下形式
public class User { public string Name { get; set; } public int Age { get; set; } }
而我們的參數名為User user,那么在所提交的表單中,name=”user.Name”, name=”user.Age”的元素的value將會對應user.Name和user.Age的屬性值。而所有那些復雜的對應組合關系就統統交給DefaultModelBinder去幫忙分析好了,只要類型不夠簡單,它就會繼續遞歸向下直到所有的類型都滿足不可遞歸為止。
雖然我們總是詬病于反射的性能,但是就易用性而言,這種透明的方式顯然看起來更友好。友好嗎?其實不然。如果我們不了解轉換的實質,我們就可能掉進那些特殊的例子中,這一個個的陷阱都只能讓我們木訥,但還好我們現在好像看清了本質。
既然我們說它是用遞歸的方式對參數類型以及參數名進行了分析和遞歸,那么就必然牽涉到遞歸的終點問題,遞歸啥時候停?剛才說到都是簡單類型的時候就會停,但是如果出現了循環引用的情況,就必將導致程序的崩潰。這里我們就必須提供自己的ModelBinder用來做這些比復雜更復雜的轉換,而即便你再懶也需要懂得如何避開它。其實實現一個CustomModelBinder很容易,因為只有一個方法,唯一的不好就是它增加了我們管理項目文件的成本,在一個大型系統中,這樣的管理甚至有點可怕,不過即便如此這個死穴我們也得保護起來,總不能讓錯誤運行在我們的視線范圍吧?
給User加一個配偶,這樣User就涵蓋了另一個User,因為“配偶”本身就是個循環引用,所以我們的實體也是個天然的循環引用體。按照我們的分析,DefaultModelBinder對這種實體天生就有抵觸情緒。下面這個類就是我們增加用來自定義ModelBinder的一個示例。
namespace MvcAppWarningPostWithHtmlHelper.Models { public class UserModelBinder : IModelBinder { public UserModelBinder() { NameUniqueID = "user$Name"; AgeUniqueID = "user$Age"; SpouseNameUnique = "userSpouse$Name"; SpouseAgeUnique = "userSpouse$Age"; } private string NameUniqueID { get; set; } private string AgeUniqueID { get; set; } private string SpouseNameUnique { get; set; } private string SpouseAgeUnique { get; set; } private User UserConvert(string name, string age, User spouse) { int iAge = 0; int.TryParse(age, out iAge); if (spouse != null && spouse.Spouse == null) { spouse.Spouse = new User(name, iAge, spouse); } return new User(name, iAge, spouse); } #region IModelBinder 成員 public ModelBinderResult BindModel(ModelBindingContext bindingContext) { HttpRequestBase request = bindingContext.HttpContext.Request; if (request.Form != null && request.Form.HasKeys()) { return new ModelBinderResult(UserConvert(request.Form.GetValues(NameUniqueID)[0], request.Form.GetValues(AgeUniqueID)[0], UserConvert(request.Form.GetValues(SpouseNameUnique)[0], request.Form.GetValues(SpouseAgeUnique)[0], null))); } return null; } #endregion } }
下圖則為我們的ActionMethod調用的寫法:
注意到這個model是一個不折不扣的循環引用。說到循環引用,我們除了在填充的時候有轍對付以外,我們還有一種不夠美的方式,就是改變我們原本的類,將這種循環引用的關系拆散。這種拆散也在之前的Ajax中的Json類型轉換中大展手腳。但是改變之后的引用關系則沒有那么“循環引用”了,只能我們自己知曉配偶是互相循環的。
下圖為拆散后循環引用關系后的調用圖,對象結構可以讓我們很明顯地看到與之前的不同。但注意到,因為消除了循環引用,我們又可以再使用DefaultModelBinder了。
集合類型
上面說了那么多,仍然只是對單一類型做的一些解釋,但它們并不完全適用于集合類型,準確地講,對于DefaultModelBinder而言,集合類型的處理需要另外一些約定。原本寫這篇文章只為講述一下在集合類型下轉換的一些需要注意的細節,并沒有打算講解上面一大籮筐的東西,但由于上面又是這些內容的基礎,因此就多解釋了一些內容。
public ActionResult CollectionPost(IList<Product> models) { //Other code!!……ViewData.Model = models; return View(); }
這個示例通過數據庫添加了一些內容,用來保持在多次調用之間的差別,以更真實地模擬現實的環境。注意到這里我們的參數是一個IList集合,而集合的對象是一個數據庫對象。這里為了我們能夠更加便利地獲得表單的數據用以填充models,框架向我們約定了一個規則,那就是“index”。先讓我們看看index要怎么用的:
<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server"> <h2> <%= Html.Encode(ViewData["Message"]) %>h2> <% using (Html.BeginForm("CollectionPost", "Home", "post")) { int current = 0; foreach (Product item in (IEnumerable)ViewData.Model) { current = item.Id; %> <input type ="hidden" name="models.index" value = "<%= current.ToString() %>"/> <%=Html.Hidden("models[" + current.ToString() + "].Id", item.Id) %> <%=Html.TextBox("textbox1_" + item.Id.ToString(), item.Name, new { name = "models[" + current.ToString() + "].Name" })%> <%=Html.TextBox("textbox2_" + item.Id.ToString(), item.Description, new { name = "models[" + current.ToString() + "].Description" , style= "width:400px;"})%> <br /> <%}%> <br /> <input type="submit" value="Submit" title="Update Form"/> <%} %> asp:Content>
注意到我們這里使用了foreach方法,也就是說當我們的models在輸出時為多項的話,這里將會有多個Product的輸入表單。如下圖:
注意看這行元素
<input type ="hidden" name="models.index" value = "<%= current.ToString() %>"/>
這里的models.index可不是會隨著foreach而遞增的,它是一個字符串常量,也就是說在頁面中將有多個名為models.index的元素。那么在我們用Form[“models.index”]的時候,我們將會得到一個數組,而這個數組的元素就是它們各自的value值。
這也就是我們跟MVC框架的一個簡單的約定,即以[ParameterName].index為名字在頁面上標識出的項的值代表表單元素中的項元素的一個索引,也就是集合的一個子項。再通過以index的value值為一個名字,用來組合出各自不同的名字前綴,而這個前綴剛好唯一標識了頁面元素是屬于哪個子項的,也就是利用這些標識,最后才能將這個復雜的集合類型組合出來。
下面是上面表單的HTML:
<form action="/?Length=4" method="post"> <input type="hidden" name="models.index" value="200" /> <input id="models[200].Id" name="models[200].Id" type="hidden" value="200" /> <input id="textbox1_200" name="models[200].Name" type="text" value="Product0" /> <input id="textbox2_200" name="models[200].Description" style="width: 400px;" type="text" value="I am the Product0, made in China!" /> <br /> <input type="hidden" name="models.index" value="201" /> <input id="models[201].Id" name="models[201].Id" type="hidden" value="201" /> <input id="textbox1_201" name="models[201].Name" type="text" value="Product1" /> <input id="textbox2_201" name="models[201].Description" style="width: 400px;" type="text" value="I am the Product1, made in China!" /> <br /> <input type="hidden" name="models.index" value="202" /> <input id="models[202].Id" name="models[202].Id" type="hidden" value="202" /> <input id="textbox1_202" name="models[202].Name" type="text" value="Product2" /> <input id="textbox2_202" name="models[202].Description" style="width: 400px;" type="text" value="I am the Product2, made in China!" /> <br /> <input type="hidden" name="models.index" value="203" /> <input id="models[203].Id" name="models[203].Id" type="hidden" value="203" /> <input id="textbox1_203" name="models[203].Name" type="text" value="Product3" /> <input id="textbox2_203" name="models[203].Description" style="width: 400px;" type="text" value="I am the Product3, made in China!" /> <br /> <input type="hidden" name="models.index" value="204" /> <input id="models[204].Id" name="models[204].Id" type="hidden" value="204" /> <input id="textbox1_204" name="models[204].Name" type="text" value="Product4" /> <input id="textbox2_204" name="models[204].Description" style="width: 400px;" type="text" value="I am the Product4, made in China!" /> <br /> <br /> <input type="submit" value="Submit" title="Update Form" /> form>
在第一個Product中,我用粗體標注了一些點,這里我以產品在數據庫的ID為識別的name,這也比較對應,而且肯定不會錯,其次以models[id]用于標識元素究竟屬于哪個子項。而這個id將依次遍歷從頁面取得的models.index的values。因此以上的HTML代碼可以被自動轉換成IList models。
在Post方式下慎用HtmlHelper
其實這部分才是真正讓我想寫此文的原因。不好講究竟是設計上的原因還是一些其它方面的失誤,總之這個HtmlHelper并非完全可以取代直接在頁面上寫下HTML的這種做法。就在離我們最近的這個示例中:
<%--<%=Html.Hidden("models.Index", current.ToString()) %>--%> <input type ="hidden" name="models.index" value = "<%= current.ToString() %>"/>
我們可以用上面綠色的這行代碼替換正常的這行代碼,并同樣執行以上的示例。在第一次執行的時候,一切都很正常,同樣包括了在第一次PostBack中。但是第二次就出現了囧樣了。
我們所期待的HTML:
<input id="models[200].Id" name="models[200].Id" type="hidden" value="200" />
變成了:
<input id="models[200].Id" name="models[200].Id" type="hidden" value="200,201,202,203,204" />
這必然導致嚴重的錯誤。經過分析我發現,因為第一次回發的時候value正常,因此不會出錯,而隨后由于HtmlHerper.Hidden方法在執行的時候,如果ModelState中有值,就將這些值轉換為逗號分隔的字符作為我們的目標值,而上文中通過current.ToString()所傳入的value直接被忽略了。也就是這點原因導致了這里的value并未生效,因此就出現了嚴重的偏差,而帶給開發者的就是諸多的莫名其妙。
其實不僅是Html.Hidden方法,HtmlHelper中的很多方法準確地講就是除了CheckBox以外的調用了InputHelper的方法都有問題,而所有的Helper方法都調用了這個InputHelper,因此就不約而同地出現了這些錯誤。
if (isCheckBox) { // Helpers that take isChecked as parameter should never look at ViewData if (useViewData) { isChecked = htmlHelper.EvalBoolean(name); } tagBuilder.MergeAttribute("value", Convert.ToString(value, CultureInfo.CurrentUICulture)); } else { tagBuilder.MergeAttribute("value", attemptedValue ?? ((useViewData) ? htmlHelper.EvalString(name) : Convert.ToString(value, CultureInfo.CurrentUICulture))); }
以上這行代碼的加粗部分就是導致這個問題的核心。
準確得講,這個問題并非任何時候都發生,它只有在滿足
1、回發,也就是數據有機會疊加的情況;
2、重復name,也就是從Form[name]能取出一個集合而不是單一元素的時候發生。
只有這二者同時兼備才會發生如上的不可預測的錯誤。
現在看來這顯然是個不小的Issue,因為既然框架被設計為支持集合,而且這種設計基于多個重名元素,那么這種錯誤必將發生。但是這個方法在InputExtensions中,準確地講這是框架提供者為我們寫的一個比較好的范例而已,我們有必要自行擴充這樣的方法。當然一般情況并沒有這個必要,因為我們完全可以通過直接寫入HTML的方式來避免這種疊加現象,這里只希望大家都能夠有所注意。
因為編輯器的問題,不好多貼代碼,而園子里的編輯器只剩下100px左右的高度,根本沒法編輯,所以對具體代碼感興趣的朋友只好下載源代碼進行演示了。