代码改变世界

Model Binder机制的缺陷

2009-03-02 09:08  Jeffrey Zhao  阅读(12632)  评论(24编辑  收藏  举报

在ASP.NET MVC中,每个请求都被映射到一个Action方法,而Action方法的参数由Model Binder根据Request中的数据转化而成。例如,URL Routing将Request URL解析成数据——这往往是一个字符串,然后该字符串可以被转换一个整型的值;还有可能是从服务器端POST过来的数据中获取特别字段的值进行转化——这还是一个字符串。由于HTTP协议是基于文本的,因此一切都是字符串到某个特定类型的转化。

在ASP.NET MVC中,这个转化的过程由一类特殊的组件来完成,那就是Model Binder。虽然框架中已经提供了一个非常强大的DefaultModelBinder类,已经为我们节省了80%的工作量,但是这种字符串到具体类型对象的转换始终不是一件“自然”的事情。由于业务的不同,我们可能对字符串的“格式”有着不一样的要求,在此时我们就需要定义自己的Model Binder。

Model Binder是一个非常简单而优秀的转化机制,将这部分的关注点分离到一个独立的层次上去,大大简化了框架的使用与测试。不过,Model Binder也不是框架内建的“唯一”解决方案的,在没有得到合适指引的情况下也很容易被滥用。现在我们来做一个小测试,看看您是否得了传说中的……咳咳……其实是老赵提出的“Model Binder强迫症”:

假设DemoController中有个Action方法,它接受一个DateTime作为参数,如下:

public ActionResult Date(DateTime date) { ... }

URL中已经进行了正确的配置(当然,您也可以为date使用正则表达式进行限制):

routes.MapRoute(
    "Demo.BadDate",
    "Demo/BadDate/{date}",
    new { controller = "Demo", action = "BadDate" });

现在,您会使用什么方法将yyyy-MM-dd格式的字符串转化为date参数呢?没错,Model Binder……不过还有其他更好的方法吗?如果您觉得Model Binder是唯一的方法,那么经诊断,您患有“Model Binder强迫症”的几率为80%……玩笑,玩笑。诚然,这个情况看似是Model Binder的一个典型使用场景,我们也可以轻易地通过自定义一个Model Binder和Model Binder Attribute来“解决”这个问题:

public class DateTimeModelBinder : IModelBinder
{
    public string Format { get; private set; }

    public DateTimeModelBinder(string format)
    {
        this.Format = format;
    }

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var value = bindingContext.ValueProvider[bindingContext.ModelName].RawValue;
        if (value is string)
        {
            return DateTime.ParseExact((string)value, this.Format, null);
        }
        else
        {
            return value;
        }            
    }
}

public class DateTimeAttribute : CustomModelBinderAttribute
{
    public string Format { get; private set; }

    public DateTimeAttribute(string format)
    {
        this.Format = format;
    }

    public override IModelBinder GetBinder()
    {
        return new DateTimeModelBinder(this.Format);
    }
}

于是乎,问题解决了:

public ActionResult Date([DateTime("yyyy-MM-dd")]DateTime date)
{
    this.ViewData["Date"] = date;
    return this.View();
}

没错,问题似乎是解决了。我们使用DateTimeAttribute标记了date参数,这样框架便会用它来获取参数绑定所需的Mode Binder对象。在DateTimeAttribute内部,将会根据指定的日期格式创建一个DateTimeModelBinder对象,而这个对象就会使用这个格式把URL中的字符串解析为一个DateTime对象。现在,当我们请求Demo/Date/2009-03-01这个URL时,便会调用Date方法,而date参数也会得到正确的值“2009年3月1日”。

可是,您不妨想得更远一些,如果别人要在View里写一个面向该Action的链接,又该怎么做?老赵先来做一个演示:

<% var date = (DateTime)this.ViewData["Date"]; %>    
<p>
    <%=
        Html.ActionLink("Yesterday", "Date",
            new { date = date.AddDays(-1).ToString("yyyy-MM-dd") })
    %>        
    <span><%= date.ToShortDateString() %></span>        
    <%=
        Html.ActionLink("Tomorrow", "Date", new { date = date.AddDays(1) })
    %>
</p>

这里使用了Html.ActionLink辅助功能来生成一个链接,并提供了date的值作为生成URL所需的参数。但是,在生成Yesterday和Tomorrow时提供的date是不一样的。在生成Testerday链接时,我们提供了一个字符串,其格式满足Action方法的需要,而Tomorrow链接则直接给定了一个DateTime对象。那么两者的结果又有什么区别呢?

<p>
    <a href="/Demo/Date/2002-12-30">Yesterday</a>        
    <span>
        2002/12/31
    </span>        
    <a href="/Demo/Date/01/01/2003%2000:00:00">Tomorrow</a>
</p>

显然,只有Yesterday的链接是对的,而Tomorrow的链接变成了一个错误的样子。朋友们应该很容易看出个种原因:我们在辅助方法中指定了一个DateTime对象之后,框架并不知道该如何其转化为URL的一部分,因此只是调用了它的ToString()方法来生成一个字符串,这种“臆断”自然让我们得到了一个失效的链接。归纳说来,框架会利用Model Binder把一个字符串(确切地说,也可能是其他类型数据)转化为一个参数对象,但是却不知道如何把对象表现为一个正确的URL。这正是Model Binder的缺陷:它的功效是“单向”的

难道,我们只能使用转化为字符串的方式来解决这个问题了吗?可惜在多个地方出现同样格式字符串明显违反了DRY原则。虽然我们可以使用构建常量(如const string DATE_FORMAT)来保存这个字符串并多次使用,但是各种显式的ToString调用也是一件麻烦事,万一有遗漏即会发生错误。即便如此,我们还是无法使用《尽可能地使用强类型数据》提到的实践,即使用辅助方法来构造一个面向Action的链接:

Html.ActionLink<DemoController>(c => c.Date(date.AddDays(1)), "Tomorrow")

我们就只能对此妥协吗?这可不是我们程序员的风骨。就此问题,请参考老赵下一篇文章,《请别埋没了URL Routing》。