玩转C科技.NET

从学会做人开始认识这个世界!http://volnet.github.io

导航

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输入框的值,由此我们就可以展开后续的工作了,具体的设置用法等,大家可以参考以上代码。

SimplePost

那么我们是否仅限于传递类似string这样的简单类型了呢?答案当然是否定的,如果仅限于此就完全没有多少值得探讨的地方了。

下面我们用一个自定义的类型来看看我们是如何完成这个任务的。

CustomModelBinder

首先在页面上放置了一个表单,并在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即完成了自定义转换值的任务。

InvokeAction

上图是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调用的写法:

 image

注意到这个model是一个不折不扣的循环引用。说到循环引用,我们除了在填充的时候有辙对付以外,我们还有一种不够美的方式,就是改变我们原本的类,将这种循环引用的关系拆散。这种拆散也在之前的Ajax中的Json类型转换中大展手脚。但是改变之后的引用关系则没有那么“循环引用”了,只能我们自己知晓配偶是互相循环的。

下图为拆散后循环引用关系后的调用图,对象结构可以让我们很明显地看到与之前的不同。但注意到,因为消除了循环引用,我们又可以再使用DefaultModelBinder了。

image

集合类型

上面说了那么多,仍然只是对单一类型做的一些解释,但它们并不完全适用于集合类型,准确地讲,对于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的输入表单。如下图:

CollectionPost

注意看这行元素

<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<Product> 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左右的高度,根本没法编辑,所以对具体代码感兴趣的朋友只好下载源代码进行演示了。

下载源码

posted on 2008-11-11 04:30  volnet(可以叫我大V)  阅读(5316)  评论(11编辑  收藏  举报

使用Live Messenger联系我
关闭