代码改变世界

请别埋没了URL Routing

2009-03-05 09:04  Jeffrey Zhao  阅读(13787)  评论(34编辑  收藏  举报

本文做法不甚妥当,更好的做法请参考:《对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参数。

打开视野,发挥程序员的敏捷思路,生活就会变得更加美好。