MVC利用Routing实现多域名绑定一个站点、二级域名以及二级域名注册Area
最近有这么个需求:在一个站点上绑定多个域名,每个域名进去后都要进入不同的页面。实现了这个功能以后,对于有多个域名,且有虚拟空间,但是虚拟空间却只匹配有一个站点的用户来说,可以节省很多小钱钱。
很久以前看过《ASP.NET MVC 实现二级域名》和《ASP.NET MVC 使用二级域名来注册Area区域》这两篇文章,它们是有前后延续性的。本文的思路也是延续他们的思想来发展的,因此必须先了解前面两个文章的内容。而解决方法更是采用他们的代码加以改进实现的。在此谢谢两位作者。
1、简单实现多域名对单站点
自己创建一个MVC的站点,大体结构如下:
圈1、HomeController将对应www.demo.com域名的页面;WebController将对应www.web.com的页面
圈2、添加Test的Area将对应test.web.com的页面
圈3、是从上面两篇文章中扒过来的域名解析的类
然后,在global里面加上下面的语句,一个站点绑定多个域名的功能就实现了。问题肯定有,后面慢慢说。
routes.Add("demo", new DomainRoute( "www.demo.com", "{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = "" } )); routes.Add("web", new DomainRoute( "www.web.com", "{controller}/{action}/{id}", new { controller = "Web", action = "Index", id = "" } ));
2、简单的原理说明
首先,使用自定义路由解析的入口
通常我们在增加自定义的路由规则时,都会用到MapRoute方法,通过"{controller}/{action}/{id}"或者"{controller}/{action}/{yyyy}/{mm}/{dd}"这样的路由规则串来自定义的路由规则。这时我们只定义了URL的path部分,并没有涉及到URL的host部分。
仔细看看代码,MapRoute方法返回了Route类型的对象,而routes是RouteCollection类型的对象,那么我们可以这么理解:MapRoute方法生成了一个新的路由对象(Route),并把它加入到了routes这个路由集合(RouteCollection)中。其实系统还提供一个Add方法,这个方法更直接,就是往routes里添加一个RouteBase对象。而Route类型实际是继承了RouteBase的。
到这里我们就明白了:注册一个路由新规则就是把一个新的RouteBase对象加入到路由集合中去。我们只要实现一个继承了RouteBase的类来实现对域名的解析,再把这个类型的对象加入到路由集合,就可以增加对域名的解析了。利用这个类和Add方法,我们可以扩展实现各种复杂的路由解析功能。上面例子中添加的DomainRoute类型的对象,实际就是一个解析域名的类。
其次,为了实现二级域名以及二级域名注册Area,还要准备一些基础知识
上面提到的RouteBase类是个抽象类,需要实现两个方法:GetRouteData和GetVirtualPath。简单的理解就是GetRouteData方法是解析url,把解析的结果按键值对存入RouteData中;而GetVirtualPath方法就是从RouteData中把url还原回来。
RouteData类里提供了两个存放路由键值对的地方,一个Values,一个DataTokens。那么哪些数据放Values里,哪些数据放DataTokens里呢?我们拿Route类来看,参数最全的构造函数如下:
public Route(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler);
url:对应我们自己定义的路由规则串
defaults:路由规则串里解析出来的键的对应缺省值
大体上说defaults里的键值对会放到Values里,constraints和dataTokens这里不讨论,我觉得应该会放到DataTokens里,这个需要看源代码确定一下。感兴趣的同学可以自己去验证一下。实际过程中defaults会有点特殊。defaults里的“键”(键值对里的键)是和url中“{}”中的内容是一一对应的,例如:
url:"{controller}/{action}/{id}" defaults:new { controller = "Web", action = "Index", id = "" }
也就是说url规定有哪些键,defaults说明这些键对应的值是什么。其中,controller和action可以认为是类似保留字这样的通用键,通用键不能修改;而id则是自定义的键,自定义的键是可以自己设定和增加的,例如yyyy这些。这些从url里解析出来的键值对都会保存到RouteData.Values里。当站点项目里增加了Area之后,并在defaults中传入了area键值,area也可以认为是类似保留字这样的通用键,不但RouteData.Values里要保存,RouteData.DataTokens里也要保存。只有这样,路由才会正确解析area。另外Namespaces这样的约束键(constraints里的)也要保存到DataTokens里。
如果我们自定义的域名路由解析类,能很好的解析代表域名的路由规则串,并将对应的键值对保存到正确的RouteData中,注册到路由集合中后,就能正确进行域名解析了。域名解析类的具体实现请参考文章开头提到的两篇文章,我这里只说改进的地方。
3、二级域名注册Area的问题及解决方法
从上面的知识点得知,我们可以使用下面的域名规则串来进行二级域名的路由注册了:
{controller}.web.com -- 使用controller控制二级域名
{area}.web.com -- 使用area控制二级域名
为了实现二级域名注册area,在上面简单实现的例子里增加一个路由规则来解析二级域名(注意路由名称不能重复):
routes.Add("areaforweb", new DomainRoute( "{area}.web.com", "{controller}/{action}/{id}", new { area = "Test", controller = "Page", action = "Index", id = "" } ));
程序运行后,很好,完美的解决了对 test.web.com/Page/Index 路径的解析,可还有什么问题吗?
1、当我们进入二级域名的页面时,在使用ActionLink这样的方法生成二级域名的链接地址是可以的,但是要生成顶级域名下的链接地址就有问题了
2、同理,当我们在顶级域名的页面上要使用ActionLink这样的方法生成二级域名的链接地址也是有问题的
产生这些问题的原因在哪呢?我们得回过头去看看:
RouteBase类的GetVirtualPath方法是实现从RouteData到url的还原,而且它只还原url的path部分,不还原host的部分,如果传入了多余的host的参数,生成的path部分就会有问题。因此在我们自定义的DomainRoute类中,在实现GetVirtualPath方法时,需要把保存在RouteData中的域名解析出来的键值对给去掉,类里面是通过RemoveDomainTokens方法实现的。
在DomainRoute类中实现了对域名的解析,因此对应的我们还需要增加一个还原域名的方法,类里面是通过GetDomainData方法实现的。方法的原始实现方法是将域名规则串中用"{}"扩起来的部分的键在RouteData中找到其对应的值去替换。这就导致了我们上面1,2点问题的产生。
当我们在二级域名的页面,使用的是二级域名的解析规则,还原域名时,域名中“{area}”部分始终会被“test”代替,无法回到顶级域名的部分;而当我们在顶级域名的页面时,使用的是顶级域名的解析规则,还原域名时,即使传入了像 area=“test" 的值,因为域名串中没有“{}”括起来的部分,所以始终会返回www.web.com,而无法得到去二级域名的链接地址。
怎么解决呢?我想的是:顶级域名能不能用area="www"或者area=""来进行匹配?解析域名串,往Values和DataTokens写入数据是现成的,完全没有问题;我们需要修改的是GetDomainData方法,让它判断一下area="www"或者area=""时,还原时域名进行特殊的处理。修改后的方法如下:
public DomainData GetDomainData(RequestContext requestContext, RouteValueDictionary values) { // 获得主机名 string hostname = Domain; foreach (KeyValuePair<string, object> pair in values) { if (pair.Key == "area" && string.IsNullOrEmpty(pair.Value.ToString())) { hostname = hostname.Replace("{" + pair.Key + "}", "www"); } else { hostname = hostname.Replace("{" + pair.Key + "}", pair.Value.ToString()); } } //如果域名的area还没有被替换,说明路由数据里没有area,恢复为顶级域名 if (hostname.Contains("{area}")) { hostname = hostname.Replace("{area}", "www"); } RemoveDomainTokens(values); // Return 域名数据 return new DomainData { Protocol = "http", HostName = hostname, Fragment = "" }; }
我们还需要一个重写的生成链接的ActionLink方法,这个方法在LinkExtensions类里,是HtmlHelper的一个自定义的扩展方法,修改后的方法如下:
public static MvcHtmlString ActionLink(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes, bool requireAbsoluteUrl) { if (requireAbsoluteUrl) { HttpContextBase currentContext = new HttpContextWrapper(HttpContext.Current); RouteData routeData = RouteTable.Routes.GetRouteData(currentContext); routeData.Values["controller"] = controllerName; routeData.Values["action"] = actionName; //如果不需要变换area,则routeValues不传入area的值 if (routeValues.Keys.Contains("area")) { routeData.Values["area"] = routeValues["area"]; } DomainRoute domainRoute = routeData.Route as DomainRoute; if (domainRoute != null) { DomainData domainData = domainRoute.GetDomainData(new RequestContext(currentContext, routeData), routeData.Values); return htmlHelper.ActionLink(linkText, actionName, controllerName, domainData.Protocol, domainData.HostName, domainData.Fragment, routeData.Values, null); } } return htmlHelper.ActionLink(linkText, actionName, controllerName, routeValues, htmlAttributes); }
一般我们生成的链接地址在需要跳转到其他area的时候,才在routeValues参数中传入area的值,否则不需要传入。另外,requireAbsoluteUrl参数对应是否需要生成带域名的地址,即绝对地址。调用方式如下:
@Html.ActionLink("前往", "Index", "Page", new { area = "Test" }, null, true)
另外,我将原始方法的返回值从string改成了MvcHtmlString。否则返回值是string时,还得加上一层壳:
@Html.Raw(@Html.ActionLink("前往", "Index", "Page", new { area = "Test" }, null, true))
至此,我们的问题基本上都解决了。在global中我们仅需要注册一个路由即可,默认值是顶级域名的首页。在项目的Area部分的TestAreaRegistration的RegisterArea方法中,注册area路由的内容就可以注销了。
routes.Add("web", new DomainRoute( "{area}.web.com", "{controller}/{action}/{id}", new { area = "", controller = "Web", action = "Index", id = "" } ));
对于本机调试来说,不但需要设置hosts文件,而且在IIS站点上,需要把顶级域名和二级域名都绑定到站点上,如下图:
这里是源代码下载。
最后再说一下:在我的虚拟空间里,站点上只能绑定顶级域名,空间提供商限制了只能输入除www之外的域名部分。虽然可以用www.test.web.com这样的域名来实现二级域名,可实在有点不符合使用习惯。而且二级子域名也只能绑定三个。我哭啊……果然买的不如卖的精……
最后的最后,对于这个功能其实老赵也有一系列的文章来进行解说和实现,给个地址:http://www.cnblogs.com/JeffreyZhao/archive/2009/08/25/url-routing-with-domain.html,从这个地址里可以把整个系列看完。他的实现方式更偏向于使用MVC的源代码进行实现,相对优雅不少。非常赞。大家也可以去研究一下。
Difficulty of making decision depends on what to lose not gain