代码改变世界

浅谈URL生成方式的演变

2009-10-29 00:29  Jeffrey Zhao  阅读(21135)  评论(18编辑  收藏  举报

开发Web应用程序的时候,在页面上总会放置大量的链接,而链接的生成方式看似简单,也有许多不同的变化,且各有利弊。现在我们就来看看,在一个ASP.NET MVC应用程序的视图中如果要生成一个链接地址又有哪些做法,它们之间又是如何演变的。

目标

作为示例,我们总要有个目标URL。我们这里的目标为面向如下Action的URL,也就是一篇文章的详细页:

public class ArticleController : Controller
{
    public ActionResult Detail(Article article)
    {
        ...
    }
}

public class Article
{
    public int ArticleID { get; set; }
    public string Title { get; set; }
}

而我们的目标URL则是文章的ID与标题的联合,其中标题里的空格替换为很短横线——把文章的标题放入URL自然是为了SEO。于是乎我们使用这样的Route规则:

routes.MapRoute(
    "Article.Detail",                                  // Route name
    "article/{article}",                               // URL with parameters
    new { controller = "Article", action = "Detail" }  // Parameter defaults
);

在URL Routing捕获到article之后,它的形式可能是这样的:

10-this-is-the-title

我们只考虑这个ID,后面的字符串虽然在URL中,但是完全被忽略。在实际项目中,我们可以编写一个Model Binder从这样一个字符串中提取ID,再获取对应的Article对象。不过我们的现在不关注这个。

我们的目标只有一个:如何生成URL。

方法一:直接拼接字符串

这是个最直接,最容易想到的做法:

<% foreach (var article in Models.Articles) { %>
    <a href="/article/<%= article.ArticleID %>-<%= Url.Encode(article.Title.Replace(' ', '-')) %>">
        <%= Html.Encode(article.Title) %>
    </a>
<% } %>

这个做法随着ASP.NET的诞生陪伴我们一路走来,已经有7、8个年头了,相信大部分朋友对它都不会陌生。它的优点自然是最为简单,最为直接,几乎没有任何门槛,也不需要任何准备就可以直接使用,而且理论上性能也是最佳的。但是它的缺点也很明显,那就是需要在每个页面,每个地方都重复这样一个字符串。试想,如果我们URL的生成规则忽然有所变化,又会怎么样?我们必须找出所有的生成链接的地方,一个一个改过来。这往往是一个浩大的工程,而且非常容易出错——因为我们根本没有静态检查可以依托。因此,在实际情况下,除非是快速开发的超小型的,随做随抛的实验性项目,一般不建议使用这样的做法。

方法二:使用辅助方法

为了避免方法一的缺点,我们可以使用一个辅助方法来生成URL:

public static class ArticleUrlExtensions
{
    public static string ToArticle(this UrlHelper helper, Article article)
    {
        return "/article/" + article.ArticleID + "-" + helper.Encode(article.Title.Replace(' ', '-'));
    }
}

我们把负责生成URL的辅助方法写作UrlHelper的扩展方法,于是我们就可以在页面上这样生成URL了(省略多余标记):

<a href="<%= Url.ToArticle(article) %>">...</a>

这个做法的优点在于把生成URL的逻辑汇集到了一处,这样如果需要变化的时候只需要修改这一个地方就行了,而且它几乎没有任何副作用,使用起来也非常简单。而它的缺点还是在于有些重复:如果这个URL修改涉及到Route配置的变化(例如从http://www.domain.com/article/5变成了http://articles.domain.com/5),则ToArticle方法也必须随之修改。也就是说,这个方法对DRY(Don’t Repeat Yourself)原则贯彻地还不够彻底。不过,对于一般项目来说,这个做法也不会有太大问题——这也是构造URL方式的底线。

方法三:从Route配置中生成URL

对于URL Routing的双向职责我已经提过无数次了,我们配置了Route规则,那么便可以使用它来生成URL:

public static string ToDetail(this UrlHelper helper, Article article)
{
    var values = new
    {
        article = article.ArticleID + "-" + helper.Encode(article.Title.Replace(' ', '-'))
    };

    var path = helper.RouteCollection.GetVirtualPath(
        helper.RequestContext, "Article.Detail", new RouteValueDictionary(values));

    return path.VirtualPath;
}

由于Route配置知道如何根据Route Value集合里的值生成一个URL,因此我们只要把这个职责交给它即可。一般来说,我们会指定Route规则的名称,这样节省了遍历尝试每个规则的开销,也不会被冲突问题所困扰。此时,即便是URL需要变化,只要调整Route规则即可——只要保持规则对“值”的需求不变就行了。例如之前提到的URL的变化,我们只要把Route配置调整为:

routes.MapDomain(
    "Article",
    "http://articles.{*domain}",
    innerRoutes =>
    {
        innerRoutes.MapRoute(
            "Detail",
            "",
            new { controller = "Article", action = "Detail" });
    };

这个做法的优点在于“自动”与Route配置同步,几乎不需要额外的逻辑。而它的缺点——可能在从性能角度上考虑会有“细微”的差距(在实际应用中是否重要另当别论)……

方法四:使用Lambda表达式生成URL

我也经常强调使用Lambda表达式生成URL的好处:

<a href="<%= Url.Action<ArticleController>(c => c.Detail(article)) %>">...</a>

由于在ASP.NET MVC中,一个URL的最终目标归根到底是一个Action,因此如果我们可以更直观地在代码中表现出这一点,则可以进一步提高代码的可读性。这一点在ASP.NET MVC 1.0自带的MvcFutures项目中已经有所体现,只可惜它作的远远不够,几乎没有任何实用价值。不过现在您也可以使用MvcPatch项目进行开发,它提供了完整的使用Lambda表达式生成URL的能力,它相对于MvcFutures里的辅助方法作了各种补充:

  1. 支持ActionNameAttribute
  2. 提高性能
  3. 允许忽略部分参数
  4. 可指定Route规则的名称
  5. 支持Action复杂参数的双向转化

使用这种方式,我们需要对Action方法做些简单的修改:

public class ArticleBinder : IModelBinder, IRouteBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        ...
    }

    public RouteValueDictionary BindRoute(RequestContext requestContext, RouteBindingContext bindingContext)
    {
        var article = (Article)bindingContext.Model;
        var text = article.ArticleID + "-" + HttpUtility.UrlEncode(article.Title.Replace(' ', '-'));

        return new RouteValueDictionary(new { bindingContext.ModelName = text });
    }
}

public class ArticleController : Controller
{
    [RouteName("Article.Detail")]
    public ActionResult Detail([ModelBinder(typeof(ArticleBinder))]Article article)
    {
        ...
    }
}

请注意我们对Action方法标记了RouteNameAttribute,以此指定Route规则的名称(第4点);同时,ArticleBinder也实现一个新的接口IRouteBinder负责从Article对象转化为Route Value。

这个做法的优点在于基本上回避了“生成URL”这个工作,而将关注点放在Action方法这个根本的目标上。此外,各种逻辑也很内聚,它们都环绕在Action方法周围,遇到问题也不用四散查询,而将Article对象转化为Route Value的职责也和它的对应操作放在了一起,更容易进行独立的单元测试。此外,使用Lambda表达式生成URL还能获得编译器的静态检查,这确保了可以在编译期间解决尽可能多的问题。

而它的缺点主要是比较复杂,如果您不使用MvcPatch项目的话,可能就需要自行补充许多辅助方法,它们并不那么简单。此外,在视图上的代码页稍显多了一些。还有便是基于表达式树解析的做法多少会有些性能损失,我们下次再来关注这个问题。

方法五:简化Lambda表达式的使用

第五个方法其实是前者的补充。例如,我们可以再准备这样一个辅助方法:

public static class ArticleUrlExtensions
{
    public static string ToArticle(this UrlHelper urlHelper, Expression<Action<ArticleController>> action)
    {
        return urlHelper.Action<ArticleController>(action);
    }
}

这样在页面上使用时无须指定ArticleController类了——这类名的确有些长:

<a href="<%= Url.ToArticle(c => c.Detail(article)) %>">...</a>

或者,我们可以结合方法二或三,提供一个额外的辅助方法:

public static class ArticleUrlExtensions
{
    public static string ToArticle(this UrlHelper urlHelper, Article article)
    {
        return urlHelper.Action<ArticleController>(c => c.Detail(article));
    }
}

至于最终使用哪个辅助方法,我想问题都不是很大。前者的“准备工作”更为简单,只需为每个Controller准备一个辅助方法就够了,而后者则需要为每个Action提供一个辅助方法,不过它使用起来却更为方便一些。

这个做法的优点在于继承了Lambda表达式构造URL的优势之外,还简化了它的使用。至于缺点,可能也和Lambda表达式类似吧,例如准备工作较多,性能理论上略差一些。

第五个方法,也是我在ASP.NET MVC项目中使用的“标准做法”。

总结

这次我们把“URL生成”这个简单的目标使用各种方法“演变”了一番,您可以选择地使用。这个演变的过程,其实也是一步步发现缺点,再进行针对性改进的过程。我们虽然使用在ASP.NET MVC的视图作为演示载体,但是它的方式和思路并不仅限于此,它也可以用在ASP.NET MVC的其它方面(如在Controller中生成URL),或是其它模型(如WebForms),甚至与Web开发并无关联的应用程序开发上面。