Model Binder机制的缺陷
2009-03-02 09:08 by 老赵, 8459 visits在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》。
这篇文章和下一片文章的内容,其实已经在Webcast里讲过了,不过当时给出了一个错误的解决方案,对此只能说声抱歉了。