代码改变世界

配合域名作URL Routing

2009-08-25 16:00 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

经常有朋友问我,如何对域名作URL Routing,他们可能希望根据域名(或自域名)来获得一些值,最终影响Controller,Action或某些参数的选择。之前我只是简单地说“扩展一下ASP.NET Routing吧”,而现在由于自己也正好需要使用这个功能,便实现了一个扩展。使用下来,效果不错。

ASP.NET Routing已经实现了针对Path的匹配和构造,而如今我们是希望在这个基础上提供额外的Domain支持,而扩展的结果依旧是对URL的Routing支持。这种增加职责而不改变其外观的需求让我想到了装饰器模式。也就是说,如果我们的目标是构造一个RouteDomain,那么它可能就是这样的:

public class DomainRoute : RouteBase
{
    private DomainParser m_domainParser;

    public RouteBase InnerRoute { get; private set; }

    public string Pattern { get; private set; }

    public DomainRoute(RouteBase innerRoute, string pattern)
    {
        this.InnerRoute = innerRoute;
        this.Pattern = pattern;
        this.m_domainParser = new DomainParser(pattern);
    }

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        ...
    }

    public override VirtualPathData GetVirtualPath(
        RequestContext requestContext,
        RouteValueDictionary values)
    {
        ...
    }
}

DomainRoute会封装一个内部Route对象,将匹配或构造Path的任务交给这个内部对象“之余”,再把对Domain的处理工作交给DomainParser进行,而DomainRoute的主要逻辑,实际上便是将上两者进行组合。如GetRouteData方法:

public override RouteData GetRouteData(HttpContextBase httpContext)
{
    // match domain
    var domainValues = this.m_domainParser.Match(httpContext.Request.Url);
    if (domainValues == null) return null;

    // match path
    var routeData = this.InnerRoute.GetRouteData(httpContext);
    if (routeData == null) return null;

    // merge
    routeData.Values.CopyFrom(domainValues);
    routeData.Route = this;

    return routeData;
}

GetRouteData的功能是匹配URL,分三步走,第一步是匹配Domain,第二步是使用内部Route匹配Path,然后通过常用辅助方法中的CopyFrom方法,把一个字典中的所有数据复制到RouteData中并返回即可。可见,由于我们把任务进行了细小地拆分,每个类的职责均非常简单,可以进行独立的单元测试,因此代码也可以显得非常简单易懂。

ASP.NET Routing的功能是构造URL和构造URL,因此我们还需要实现一个GetVirtualPath方法:

public override VirtualPathData GetVirtualPath(
    RequestContext requestContext,
    RouteValueDictionary values)
{
    // bind domain
    var domain = this.m_domainParser.Bind(requestContext.RouteData.Values, values);
    if (domain == null) return null;

    // bind path
    var innerValues = new RouteValueDictionary();
    innerValues.CopyFrom(values).RemoveKeys(this.m_domainParser.Segments);
    var pathData = this.InnerRoute.GetVirtualPath(requestContext, innerValues);
    if (pathData == null) return null;
    
    // merge
    pathData.Route = this;
    pathData.VirtualPath = Merge(requestContext.HttpContext, domain, pathData.VirtualPath);

    return pathData;
}

private static string Merge(HttpContextBase context, string domain, string path)
{
    var domainWithSlash = domain + "/";
    var ignoreDomain = context.Request.Url.ToString().StartsWith(domainWithSlash);
    return ignoreDomain ? path : domainWithSlash + path;
}

与GetRouteData的逻辑类似,GetVirtualPath方法首先根据所得的Route Value组装出Domain,再使用内部Route对象构造一个Path,并将其合并(Merge)起来。略有不同的是,再调用内部Route对象之前,必须去除所有用于Domain的部分(Segment),否则这些会出现在URL的QueryString部分中。在合并Domain和Path的时候,也有些许逻辑。Merge方法会判断当前请求与目标的Domain,如果两者相同,则会返回一个相对路径,省略URL前完整的域名。

方便起见,我们也可以使用一个扩展方法来辅助DomainRoute的构造:

public static class RouteExtensions
{
    public static DomainRoute WithDomain(this RouteBase route, string pattern)
    {
        return new DomainRoute(route, pattern);
    }
}

最后还是进行几个单元测试吧。首先,我们可以捕获整个URL中的数据(关于MockHelper请参考这里):

[Fact]
public void Capture_Request_Scheme()
{
    Mock<HttpRequestWrapper> mockRequest;
    var mockContext = MockHelper.MockRequest("http://jeffz.space.cnblogs.com/posts/2009", out mockRequest);

    var route = new Route("{section}/{data}", null);
    var domainRoute = route.WithDomain("{scheme}://{user}.{area}.{*domain}");

    var routeData = domainRoute.GetRouteData(mockContext.Object);
    Assert.Equal("http", routeData.Values["scheme"]);
    Assert.Equal("space", routeData.Values["area"]);
    Assert.Equal("cnblogs.com", routeData.Values["domain"]);
    Assert.Equal("jeffz", routeData.Values["user"]);
    Assert.Equal("posts", routeData.Values["section"]);
    Assert.Equal("2009", routeData.Values["data"]);
}

其次,对于无法匹配的URL,也能够返回null:

[Fact]
public void Specified_Request_Scheme()
{
    Mock<HttpRequestWrapper> mockRequest;
    var mockContext = MockHelper.MockRequest("http://space.cnblogs.com/Home", out mockRequest);

    var sslRoute = new Route("{controller}", null).WithDomain("https://{sub_domain}.{*domain}");
    var sslData = sslRoute.GetRouteData(mockContext.Object);
    Assert.Null(sslData);
}

最后,我们也可以成功地构造整段URL:

[Fact]
public void Build_Url()
{
    Mock<HttpRequestWrapper> mockRequest;
    var mockContext = MockHelper.MockRequest("http://wiki.cnblogs.com/Home/Index", out mockRequest);

    var route = new Route("{controller}/{action}", null).WithDomain("{scheme}://{area}.{*domain}");
    var routeData = route.GetRouteData(mockContext.Object);
    var requestContext = new RequestContext(mockContext.Object, routeData);

    Assert.Equal("http", routeData.Values["scheme"]);
    Assert.Equal("wiki", routeData.Values["area"]);
    Assert.Equal("cnblogs.com", routeData.Values["domain"]);
    Assert.Equal("Home", routeData.Values["controller"]);
    Assert.Equal("Index", routeData.Values["action"]);

    // same domain
    var values = new RouteValueDictionary(new { controller = "Account", action = "List" });
    var pathData = route.GetVirtualPath(requestContext, values);
    Assert.Equal("Account/List", pathData.VirtualPath);

    // different domain
    var spaceRoute = new Route("{controller}/{action}", null).WithDomain("http://{user}.{area}.{*domain}");
    var spaceHash = new { controller = "Account", action = "List", area = "space", user = "jeffz" };
    var spaceValues = new RouteValueDictionary(spaceHash);
    var spacePathData = spaceRoute.GetVirtualPath(requestContext, spaceValues);
    Assert.Equal("http://jeffz.space.cnblogs.com/Account/List", spacePathData.VirtualPath);
}

整个DomainRoute类就这样完成了,除了单元测试外,总共也就60多行代码,但已经实现了我们所需要的常用功能。当然,目前还不支持“端口”,如果您需要的话,也可以修改代码,让其为您所用。

不过,虽然DomainRoute已经准备好了,但是在视图中“构造”URL时的辅助方法还需要一些额外的实现。这个下次再说吧(已发布,请参考《支持DomainRoute的URL构造辅助方法》)。

如果您有什么其他的想法或建议也请提出,我们可以一起讨论一下。