Hello World
Spiga

请别埋没了URL Routing

2009-03-05 09:04 by 老赵, 10282 visits

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

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

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

34 条回复

  1. TerryLee
    *.*.*.*
    链接

    TerryLee 2009-03-05 00:29:00

    占领沙发 :)

  2. 老赵
    admin
    链接

    老赵 2009-03-05 00:31:00

    @TerryLee
    特瑞兄最近在做啥发财呀?

  3. Jerry Qian
    *.*.*.*
    链接

    Jerry Qian 2009-03-05 00:36:00

    板凳

  4. TerryLee
    *.*.*.*
    链接

    TerryLee 2009-03-05 00:38:00

    @Jeffrey Zhao
    最近重回基础,读操作系统等课本呢 :P

  5. 老赵
    admin
    链接

    老赵 2009-03-05 00:42:00

    @TerryLee
    和我一样啊……为啥?我倒是兴趣使然。

  6. TerryLee
    *.*.*.*
    链接

    TerryLee 2009-03-05 00:47:00

    @Jeffrey Zhao
    嗯,在研究很多问题时总是不得要领,还需要从根上重新认识一下,呵呵

  7. 老赵
    admin
    链接

    老赵 2009-03-05 00:53:00

    --引用--------------------------------------------------
    TerryLee: @Jeffrey Zhao
    嗯,在研究很多问题时总是不得要领,还需要从根上重新认识一下,呵呵
    --------------------------------------------------------
    最近在研究哪些问题呀?

  8. TerryLee
    *.*.*.*
    链接

    TerryLee 2009-03-05 00:59:00

    @Jeffrey Zhao
    近期一直在搞性能优化方面的工作,还有Debug的一些东西:)

  9. 重典
    *.*.*.*
    链接

    重典 2009-03-05 01:02:00

    @Jeffrey Zhao
    严重BS老赵发文破坏我生物钟的行径


    --引用--------------------------------------------------
    TerryLee: @Jeffrey Zhao
    嗯,在研究很多问题时总是不得要领,还需要从根上重新认识一下,呵呵
    --------------------------------------------------------

    我还在叶子上爬行,要好好学习啊

  10. 老赵
    admin
    链接

    老赵 2009-03-05 01:18:00

    --引用--------------------------------------------------
    TerryLee: @Jeffrey Zhao
    近期一直在搞性能优化方面的工作,还有Debug的一些东西:)
    --------------------------------------------------------
    好玩,真好玩。

  11. 老赵
    admin
    链接

    老赵 2009-03-05 01:19:00

    @重典
    生物钟调节地像我一样就好了,hoho。

  12. Anytao
    *.*.*.*
    链接

    Anytao 2009-03-05 02:23:00

    太长了~~~
    给老赵同志说完晚安,再睡觉

  13. xiao_p
    *.*.*.*
    链接

    xiao_p 2009-03-05 02:32:00

    @Jeffrey Zhao
    问下老赵,这么晚了睡觉是不是也有助于减肥啊?

    呵呵,牛人果然都睡的比较晚!

  14. hdl253
    *.*.*.*
    链接

    hdl253 2009-03-05 08:57:00

    坐下来看看

  15. 老赵
    admin
    链接

    老赵 2009-03-05 09:04:00

    @xiao_p
    对我来说,越辛苦越要胖

  16. 高杨
    *.*.*.*
    链接

    高杨 2009-03-05 09:05:00

    老赵,你瘦了。

  17. 老赵
    admin
    链接

    老赵 2009-03-05 09:11:00

    --引用--------------------------------------------------
    高杨: 老赵,你瘦了。
    --------------------------------------------------------
    这个是……很明显的……

  18. 第一控制.NET
    *.*.*.*
    链接

    第一控制.NET 2009-03-05 10:15:00

    等来等去。等到的是rc2,不是正式版。。。

  19. 老赵
    admin
    链接

    老赵 2009-03-05 10:22:00

    @第一控制.NET
    直接用吧

  20. H2O、winnerzone
    *.*.*.*
    链接

    H2O、winnerzone 2009-03-05 11:33:00

    学习了,估计你有减了很多体重。

  21. xjb
    *.*.*.*
    链接

    xjb 2009-03-05 13:05:00

    排版有些宽了

  22. 老赵
    admin
    链接

    老赵 2009-03-05 14:12:00

    --引用--------------------------------------------------
    xjb: 排版有些宽了
    --------------------------------------------------------
    实在懒得搞了……:(

  23. 沉默杨仔
    *.*.*.*
    链接

    沉默杨仔 2009-03-05 14:19:00

    先收藏起来以后慢慢研究

  24. 老赵
    admin
    链接

    老赵 2009-03-05 20:58:00

    --引用--------------------------------------------------
    沉默杨仔: 先收藏起来以后慢慢研究
    --------------------------------------------------------
    谢谢,希望有帮助。

  25. 有所为,有所不为
    *.*.*.*
    链接

    有所为,有所不为 2009-03-06 09:06:00

    身体,身体!!!
    = =#

  26. 老赵
    admin
    链接

    老赵 2009-03-08 19:25:00

    @有所为,有所不为
    强壮,强壮!

  27. deerchao
    *.*.*.*
    链接

    deerchao 2009-03-25 16:32:00

    有没有简单的办法把一个Controller与子域名关联起来?
    比如 aaa.example.com 对应了比如WebSiteController.Index(string clientId) ,其中clientId会是"aaa".
    这个GetRouteData倒是比较简单,但是GetVirtualPath就完全不够用了,根本不给你设置Url里的Host的机会,所以使用了Html.ActionLink()之后,域名就控制不了了..很难在地址为aaa.example.com的页面里生成一个http://www.example.com/xxx/yyy这样的地址..
    不知老赵有没有办法?

  28. Cat Chen
    *.*.*.*
    链接

    Cat Chen 2009-04-02 14:16:00

    其实这些serializer、deserializer我们都写了不少了,有XML的,有JSON的,现在还有URL的。为什么每次我们都要另外开一个新的类来做Converter?我觉得应该可以好像Extention Method那样,允许Extention Interface,我只要把一个Converter的Interface附着到类上面,那就可以了。

  29. 一抹微蓝
    *.*.*.*
    链接

    一抹微蓝 2009-04-18 22:23:00

    老赵,这篇文章中的代码有没有整理出来可以下载呢?

  30. 老赵
    admin
    链接

    老赵 2009-04-18 22:55:00

    @一抹微蓝
    所有的代码都已经在文章里,没有任何遗漏。

  31. 老赵
    admin
    链接

    老赵 2009-04-18 22:57:00

    @deerchao
    其实URL Route可以扩展,其中其实包含足够信息判断子域名的……

  32. AlexChen
    *.*.*.*
    链接

    AlexChen 2009-05-24 20:55:00

    最近在学习您的MVC系统,昨天看到了URL Routing部分...
    但是您的代码我怎么调试过不了呢?
    在DebugHttpHandler 中,方法FormatRouteValueDictionary 总是报:
    Object reference not set to an instance of an object.
    Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.

    Exception Details: System.NullReferenceException: Object reference not set to an instance of an object.

    Source Error:


    Line 125:
    Line 126: string display = string.Empty;
    Line 127: foreach (string key in values.Keys)
    Line 128: display += string.Format("{0} = {1}, ", key, values[key]);
    Line 129: if (display.EndsWith(", "))


    Source File: F:\WebCast\ASP.NET MVC框架开发系列课程(3):URL导向\Code\WebCast20080507am_Demo\RouteTester\DebugHttpHandler.cs Line: 127

    跟踪进去.values.Keys 是存在的,值夜是对的的...但是按照上面的方式一调用,就出异常了...

  33. dark
    *.*.*.*
    链接

    dark 2009-10-21 20:28:00

    我在google里面搜索:URL Routing
    你这篇文章排第一~~~~ :)

  34. 老赵
    admin
    链接

    老赵 2009-10-21 20:30:00

    @dark
    可惜这篇文章实用性不佳,现在已经有更好的解决方案了。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我