本文做法不甚妥当,更好的做法请参考:《对Action方法的参数进行双向转化》
实现分析
既然Model Binder机制有着明显的缺陷,那么我们又该如何处理这样的问题呢?
我们再来回顾一下目前问题:对于从URL中表现出来的参数,我们可以把URL Routing捕获到的数据使用Model Binder进行转化(例如上例中的DateTimeModelBinder);但是如果我们在生成URL时直接提供复杂参数,则框架只会把它简单的ToString后放入URL。这是因为那些与URL有关的HTML Helper会将数据交给URL Ruoting组件来生成URL,而Route规则在生成URL时不知道一个复杂对象该如何转变为URL,因此……
慢着,你刚才说,把数据“交给URL Routing组件来生成URL”?URL Routing不是解析URL用的吗?为什么还负责“生成”URL?没错,与Model Binder不同,URL Routing的工作职责是“双向”的。它既负责从URL中提取RouteData,也负责根据Route生成一个URL——可惜微软没有对URL Routing给出足够的资料,有相当多的朋友没有意识到这一点。
可恶的微软。
既然问题的原因是Model Binder的“单向性”,那么如果存在一个“双向”的Model Binder就应该可以解决问题。例如,我们可以继承现有的IModelBinder接口进行扩展,那么至少从解析URL到执行Action方法这个流程中所有的功能都不需要任何额外工作。可惜,这种做法对于大多数HTML Helper来说,我们就必须定义新的扩展,才能利用所谓的“双向Model Binder”。不过其实我们可以有更好的解决方案——成本低廉,通用性强。既然上次提到了传说中的“Model Binder强迫症”,那么我们现在就把目光移到Model Binder以外的地方。
您一定已经猜到我们要从哪里入手了。没错,就是URL Routing。关于这方面,大名鼎鼎的Scott Hanselman同学提出将DateTime类型进行分割,也就是将一个DateTime切成年、月、日多个部分进行表示。这个做法老赵颇不赞同,无论从易用性还是通用性等角度来看,这种做法都是下下之策。说实话,这样的做法其实并没有跳出框架既有功能给定的圈子,它只是通过“迎合框架”来满足自己的需求,而不是让框架为我们的需求服务。
那么,我们来分析一下URL Routing组件的运作方式吧,这是必要的预备工作:
- 首先,应用程序为RouteCollection类型的RouteTable.Routes集合添加一些Route规则,每个规则即为一个RouteBase对象。RouteBase是一个抽象类型,其中包含两个抽象方法,GetRouteData和GetVirtualPath。
- 在捕获URL中数据的时候,URL Routing组件将调用RouteTable.Routes.GetRouteData方法来获得一个RouteData对象。简单来说,它会依次调用每个RouteBase对象的GetRouteData方法,直到得到第一个不为null的RouteData对象。
- 在生成URL时,URL Routing组件将调用RouteTable.Routes.GetVirtualPath方法来获得一个VirtualPathData对象。简单来说,它会依次调用每个RouteBase对象的GetVirtualPath方法,直到得到第一个不为null的VirualPathData对象。
显然,光有RouteBase抽象类型是不足以提供任何有用功能的。因此URL Routing框架还提供了一个具体的Route类型供大家使用。说起Route类,它的功能可谓非常强大。我们在使用ASP.NET MVC框架时用到的MapRoute方法,其实就是在向RouteTable.Routes集合中添加Route对象。而其中的URL占位符,默认值,约束等功能,实际上完全由Route对象实现了。多么强大的Route类型!如果想要写一个足以匹敌,并且包含额外功能的RouteBase实现可不是一件容易的事情。幸好我们生活在面向对象的美好世界中,“复用”是我们手中威力非凡的利器。如果我们基于现有的Route类型进行扩展,那么大部分的工作我们弹指间便可完成。
现有的Route只能从URL中提取字符串类型的数据,同时也只能把任何对象作为字符串来生成URL。而我们将要构造RouteBase实现,就要弥补这一缺陷,让Route规则能够直接从URL中提取出复杂对象,并且知道如何将一个复杂对象转化为一个URL。有了前者,RouteData就能包含复杂类型的对象,以此应对Action方法的参数自然不是问题;有了后者,我们只需要提供一个强类型的复杂对象,Route规则也能顺利地将其转化为可以识别的URL——多么美好。
Route Formatter
那么解析字符串,或生成URL的职责由谁来完成呢?于是我们定义一个IRouteFormatter来负责这件事情:
public interface IRouteFormatter
{
bool TryParse(object value, out object output);
bool TryToString(object value, out string output);
}
TryParse方法负责将一个对象转化为我们需要的复杂类型对象,而TryToString则将一个复杂类型对象转化为字符串(即URL)。两个方法都返回一个布尔值,以表示这次转化是否合法。您可能会发现,TryToString输出的是一个string,而TryParse……他接受的是一个object类型的参数,这是怎么回事呢?原因在于Route规则中的“默认值”设置。在Route规则中我们可以为RouteData中的某个“字段”设定默认值,这样即使URL中无法捕获到这个字段,它也可以出现在RouteData中。从URL中捕获得到的自然是一个字符串,但是默认值则可以设为任意类型的对象。因此Formatter需要可以接受一个object参数,并设法将其转化为我们需要的复杂类型。
是不是有点绕?请继续看下去,您会了解它的作用的。虽说TryParse需要接受一个object参数,但是在大多数情况下,我们更多是要处理强类型。因此我们不妨再定一个RouteFormatter抽象类,方便强类型IRouteFormatter对象的编写:
public abstract class RouteFormatter<T> : IRouteFormatter
{
public abstract bool TryParse(string value, out T output);
public abstract bool TryToString(T value, out string output);
bool IRouteFormatter.TryParse(object value, out object output)
{
if (value is T)
{
output = value;
return true;
}
string s = value as string;
if (s == null)
{
output = null;
return false;
}
else
{
T t;
var result = this.TryParse(s, out t);
output = t;
return result;
}
}
bool IRouteFormatter.TryToString(object value, out string output)
{
if (value is T)
{
return this.TryToString((T)value, out output);
}
else
{
output = null;
return false;
}
}
}
RouteFormater<>类接受一个范型参数,并且准备两个强类型的抽象方法让子类实现。至于接口中的两个类型,它们会处理一部分逻辑——主要是类型判断——只在合适的时候将操作交给范型方法来实现。TryToString方法朴实无华,而TryParse方法相对较为有趣,它会首先判断value参数的类型,如果已经符合当前的范型类型,则直接将其转化后返回。这就是为了“默认值”而进行的处理,例如用户准备了一个DateTime类型的默认值,并被Route规则采纳了,则我们的RouteFormatter<DateTime>就会将其直接返回,不做任何转化。
为了解决目前提出的问题,我们会编写一个DateTimeFormatter,它接受一个Format参数表示日期的格式:
public class DateTimeFormatter : RouteFormatter<DateTime>
{
public string Format { get; private set; }
public DateTimeFormatter(string format)
{
this.Format = format;
}
public override bool TryParse(string value, out DateTime output)
{
return DateTime.TryParseExact(value, this.Format, null, DateTimeStyles.None, out output);
}
public override bool TryToString(DateTime value, out string output)
{
output = value.ToString(this.Format);
return true;
}
}
那么有没有某个Route Formatter需要直接实现IRouteFormatter接口呢?有。之前提到TryParse方法将在value参数符合范型T的情况下直接返回“通过”,如果某个Route Formatter不支持这条判断,则自然无法继承于RouteFormatter<>类型。例如下面的RegexFormatter,将使用正则表达式对某个字段的值进行约束。在我们的RouteBase实现中,RegexFormatter便是Route类中“约束”功能的替代品。如下:
public class RegexFormatter : IRouteFormatter
{
public Regex Regex { get; private set; }
public RegexFormatter(string pattern)
{
this.Regex = new Regex(pattern,
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
public bool TryParse(object value, out object output)
{
string s;
bool result = this.Try(value, out s);
output = s;
return result;
}
public bool TryToString(object value, out string output)
{
return this.Try(value, out output);
}
private bool Try(object value, out string output)
{
var s = value as string;
if (s != null && this.Regex.IsMatch(s))
{
output = s;
return true;
}
else
{
output = null;
return false;
}
}
}
RegexFormatter的关键在于Try方法。Try方法首先判断value参数是否为一个字符串,如果是,则使用正则表达式进行验证。当且仅当value为字符串并满足指定的正则表达式时,RegexFormatter才表示“通过”。
FormatRoute实现
FormatRoute便是我们RouteBase抽象类的实现,它提供了Route类的所有功能,并可以为每个字段设置一个Route Formatter对象,以此对这个字段进行转换或约束。之前提到,我们会将主要功能委托给现有Route类型,这样可以大大简化我们的工作量。因此,我们会在FormatRoute中包含一个Route类型的对象,此外还会保留所有字段与其Route Formatter的映射关系。请看如下构造函数:
public class FormatRoute : RouteBase
{
private Route m_route;
private IDictionary<string, IRouteFormatter> m_formatters;
public FormatRoute(
string url,
RouteValueDictionary defaults,
IDictionary<string, IRouteFormatter> formatters,
RouteValueDictionary constaints,
RouteValueDictionary dataTokens,
IRouteHandler routeHandler)
{
this.m_formatters = formatters;
this.m_route = new Route(
url,
defaults,
constaints,
dataTokens,
routeHandler);
}
...
}
RouteBase的关键方法便是GetRouteData和GetVirtualPath。有了Route类型的辅助,这两个方法其实非常简单。如下:
public override RouteData GetRouteData(HttpContextBase httpContext)
{
var result = this.m_route.GetRouteData(httpContext);
if (result == null) return null;
var valuesModified = new Dictionary<string, object>();
foreach (var pair in result.Values)
{
var key = pair.Key;
IRouteFormatter formatter = null;
if (this.m_formatters.TryGetValue(key, out formatter))
{
object o;
if (formatter.TryParse(pair.Value, out o))
{
valuesModified[key] = o;
}
else
{
return null;
}
}
}
foreach (var pair in valuesModified)
{
result.Values[pair.Key] = pair.Value;
}
return result;
}
public override VirtualPathData GetVirtualPath(
RequestContext requestContext, RouteValueDictionary values)
{
var routeValues = new RouteValueDictionary();
foreach (var pair in values)
{
var key = pair.Key;
IRouteFormatter formatter = null;
if (this.m_formatters.TryGetValue(key, out formatter))
{
string s;
if (formatter.TryToString(pair.Value, out s))
{
routeValues[key] = s;
}
else
{
return null;
}
}
else
{
routeValues[key] = pair.Value;
}
}
return this.m_route.GetVirtualPath(requestContext, routeValues);
}
GetRouteData会接受一个HttpContextBase对象,并调用Route对象的GetRouteData方法获取一个RouteData对象。如果RouteData不为null,则遍历其中的所有字段,如果指定了对应的Route Formater,则还需要通过Route Formatter的检验及转化——没错,经历了Route Formatter之后的RouteData中已经包含了强类型对象。而GetVirtualPath方法则略有不同,它首先遍历values参数中的所有字段,将其中的强类型对象转化为字符串,也就是URL片段,这样交给Route对象来生成VirtualPathData时,便可以得到正确的URL了。
最后便是FormatRoute的运用:
routes.Add(
"Demo.Date",
new FormatRoute(
"{controller}/{action}/{date}",
new RouteValueDictionary(), // defaults
new Dictionary<string, IRouteFormatter>
{
{"controller", new RegexFormatter("Demo")},
{"action", new RegexFormatter("Date")},
{"date", new DateTimeFormatter("yyyy-MM-dd")}
},
new RouteValueDictionary(), // constaints
new RouteValueDictionary(), // data tokens
new MvcRouteHandler()));
除了为date字段指定了转化用的DateTimeFormatter之外,我们也为controller和action字段提供了负责约束的RegexFormatter——这点只是为了演示。更好的做法是直接将URL设为Demo/Date/{date},并在默认值中指定controller和action的值。此外,您也可以使用传统的方式为字段提供约束,而不是使用RegexFormatter。当然,效果几乎可以说是一模一样的。
总结
现在我们完美地解决了之前提出的问题。使用FormatRoute可以轻松地处理URL中特定类型对象的提取,并且可以把特定类型的对象转化为URL的片段。除了日期时间之外,我们还可以转化语言文化,查询条件等任意复杂类型。而RouteFormatter对象与Route规则的分离,使得我们可以对RouteFormatter进行独立的单元测试,这也是一件十分理想的事情。这下在视图中,无论是指定Route Values,还是使用强类型的方式,我们都可以正确获得所需的URL了。如下:
<%= Html.ActionLink("Yesterday", "Date", new { date = date.AddDays(-1) }) %>
<span><%= date.ToShortDateString() %></span>
<%= Html.ActionLink<DemoController>(c => c.Date(date.AddDays(1)), "Tomorrow") %>
那么,从设计上讲,把数据的提取转移到URL Routing上是否合适呢?答案是肯定的。因为URL Routing的职责原本就是从URL中提取数据——任意类型的数据,以及把数据转化为URL,我们现在只是充分利用了URL Routing的功能而已。事实上,我建议任何使用URL表示的数据,都把转化的职责转移到URL Routing这一层,因为这时我们基本上无可避免地需要根据数据来生成URL。一般情况下,我们要尽可能地使用强类型数据。那么Model Binder难道就没有用了吗?当然不是。URL Routing负责从URL中提取数据,而Model Binder则用于从其他方面来获取参数。例如POST来的数据,例如《最佳实践》中的Url Referrer参数。
打开视野,发挥程序员的敏捷思路,生活就会变得更加美好。