路由 - ASP.NET MVC 4 系列
软件开发人员常常对一些细小的细节问题倍加关注,由其在考虑源代码的质量和结构时更是如此。因此,当遇到大部分使用 ASP.NET 技术构建的站点,使用如下的 URL 地址时,可能会有些奇怪:
http://example.com/albums/list.aspx?catid=17173&genreid=33723&page=3
既然我们对代码倍加重视,为什么不能同样的重视 URL 呢?虽然它看上去并不是那么重要,但它却是一种合法且广泛使用的 Web 用户接口!
理解 URL(Uniform Resource Locator)
可用性专家力劝开发人员重视 URL,并指出高质量的 URL 应该满足以下几点要求:便于记忆和拼写、简短、便于输入、可以反映出站点结构、“可破解的”,用户可以移除 URL 的末尾,进而到达更高层次的信息体系结构、持久,不能改变。
按照传统,在很多 Web 框架中(如 ASP、JSP、PHP、ASP.NET 等),URL 代表的是磁盘上的物理文件,例如上面的 URL 我们可以确定站点的目录结构中有一个 albums 文件夹,且还包含一个 List.aspx 文件。URL 和文件系统的这种对应关系,并不适用于大部分基于 MVC 的 Web 框架,这类框架应用不同的方法把 URL 映射到某个类的方法调用,而不是磁盘上的某个物理文件。
URL 是统一资源定位符的首字母所写,资源是一种抽象概念,既可以指一个文件,也可以指方法调用的结果或服务器上的一些其他内容。
URI 代表统一资源标识符,从技术角度看,所有 URL 都是 URI。W3C 认为 URL是一个非正式的概念,它通过表示自身的主要访问机制来标识资源。而有专家提出另一种看法:URI 是某资源的标识符,URL 则为获取该资源提供了具体的信息。
路由概述
ASP.NET MVC 框架中的路由主要有两种用途:
- 匹配传入的请求,并把这些请求映射到控制器操作。
- 构造传出的 URL,用来响应控制器中的操作。
很多开发人员喜欢把路由与 URL 重写进行对比。因为这两种方法都可用于分离传入 URL 和结束处理请求。此外,它们都可以为搜索引擎优化(Search Engine Optimization,SEO)构建“漂亮的”URL。然而,它们也有很大的区别:URL 重写关注的是将一个 URL 映射到另一个 URL,例如常把旧的 URL 映射到新的 URL,与之相比,路由关注的则是如何将 URL 映射到资源。
路由的定义
每个 ASP.NET MVC 程序都至少需要一个路由来定义自己处理请求的方式,但通常,总是会有一个或多个路由,非常复杂的程序可能会有数十个甚至更多。
路由的定义是从 URL 模式开始的,因为它指定了与路由相匹配的模式。路由可以指定它的 URL 及其默认值,可以约束 URL 各个部分,提供关于路由如何、何时与传入的请求 URL 相匹配的严格控制。
现在清除 RegisterRoutes 方法中所有的代码,然后添加一个非常简单的路由,添加后如下:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute("simple", "{first}/{second}/{third}");
}
MapRoute 方法的最简单形式是采用路由名称和路由的 URL 模式。下表展示了在上面代码中定义的路由如何把指定的 URL 解析成一个存储在 RouteValueDictionary 实例中的键/值对,从而可以帮助理解,路由如何把 URL 分解成稍后在请求管道中使用的重要信息片段:
URL |
URL 参数值 |
/albums/display/123 | first="albums" second="display" third="123" |
/foo/bar/baz | first="foo" second="bar" third="baz" |
/a.b/c-d/e-f | first="a.b" second="c-d" third="e-f" |
路由 URL 是由若干个 URL 段(斜杠之间所有内容)组成,每个段都包括一组花括号限定的占位符,这些占位符就是 URL 参数。这是一种模式匹配规则,用来决定路由是否适用于传入的请求。针对本示例,由于 URL 参数在默认的情况下将匹配任何非空值,因此,示例中定义的规则可以匹配任何带有 3 个断的 URL。
当客户端的请求到达服务器时,路由解析请求的 URL,并将解析出的 路由参数值 放入字典(通过 RequestContext 访问的 RouteValueDictionary)中,在生成的字典中把路由 URL 参数名称作为 key,将对应位置上的字段作为 value。
路由值
如果真的请求上面注册的 URL,会返回 404 错误。尽管可以使用任何想要的名称来定义路由,但 ASP.NET MVC 框架要求使用一些特定的参数名称:{controller}、{action}。
{controller} 参数的值用于实例化一个控制器类,按照约定,ASP.NET MVC 把 Controller 后缀添加到 {controller} URL 参数值的后面构成一个类型名称,然后根据该名称查找实现了 System.Web.Mvc.IController 接口的类型,不区分大小写。
{action} 参数值用来指明该类中需要调用的方法。
现在,我们将路由注册代码修改为 ASP.NET MVC 约定的模式:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute("simple", "{controller}/{action}/{id}");
}
参考上表第一个示例,现在变为请求名称为“album”的 controller,框架把 Controller 作为后缀添加到 URL 参数值“album”之后,从而得到类型名称“albumController”,不区分大小写,且该类型如果还实现了 IController 接口,那么该类就会被实例化,并用于处理这个请求。
注意:上表中第三个 URL 是一个有效的路由 URL,但它并不能匹配任何的控制器和操作,原因很简单,两者都不是有效的 ASP.NET 类名和方法名。
除了 {controller} 和 {action} 之外,如果还有其他任何路由参数,它们都可以作为参数传递到操作方法中!
假设存在如下的控制器:
public class AlbumsController : Controller
{
public ActionResult Display(int id)
{
// do something...
return View();
}
}
现在如果发出请求:/albums/display/123,上述代码则完全能被匹配。
{controller}/{action}/{id} 中每一个段都包含一个 URL 参数,同时 URL 参数也占有对应的整个段。事实上,并不一定总是这样,路由 URL 在段中也允许包含字面值,如果要把 MVC 集成到一个现有的站点中,并且想让所有 MVC 请求都以 site 开头,那可以如下实现:
site/{controller}/{action}/{id} // 这个路由只有第一个段以 site 开头,才能与请求匹配。
还有更灵活的路由语法规则,在 URL 段中允许字面值和参数混合在一起,仅有的限制是不允许两个连续的 URL 参数:
{language}-{country}/{controller}/{action} // 合法
{controller}.{action}.{id} // 合法
{controller}{action}/{id} // 错误的,路由无法知道传入请求 URL 的控制器部分何时结束,操作方法部分何时开始
URL 模式及其匹配示例:
路由 URL 模式 |
匹配的 URL 示例 |
{controller}/{action}/{genre} | /albums/list/rock |
service/{action}-{format} | /service/display-xml |
{report}/{year}/{month}/{day} | /sales/2010/06/19 |
路由默认值
路由 URL 并不是在匹配请求时所要考虑的唯一因素,还应该考虑为路由 URL 参数提供的默认值。
假设现在有一个没有任何参数的操作方法:
public ActionResult List()
{
// do something...
return View();
}
我们会很自然的想到通过这样的 URL 调用 List 方法:/albums/list,然而,根据先前定义的路由 URL 就不能正常运行,因为先前的路由定义只匹配包含 3 个段的 URL,但 /albums/list 只包含 2 个段。似乎需要重新定义一个类似这样的两个段的路由:{controller}/{action}。但如果能指出先前的路由定义中,第三个段是可选的,不是更好?
路由 API 允许为参数段提供默认值,例如:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute("simple", "{controller}/{action}/{id}",
new { id = UrlParameter.Optional });
}
{ id = UrlParameter.Optional } 为 {id} 参数定义了默认值,该默认情况就允许路由匹配没有 id 参数的请求。换言之,该路由现在可以匹配具有两个段的 URL,也可以匹配具有三个段的 URL!
还可以将 id 设置为空串{id=""} 来实现上述功能,但为什么不呢?先前说过,框架会解析 URL 参数值,并将解析后的内容放入一个字典中,当使用 UrlParameter.Optional 时,在 URL 中并没有提供值,路由就不会在字典中添加条目,若使用空串,则路由会在字典中添加 key 为 id,值为 空 的条目。某些场合中,这种差别是重要的,可以让我们知道 id 值没有被指定和指定为空的区别。
可以为多个参数提供默认值,下面的代码为 {action} 参数提供了一个默认值:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute("simple", "{controller}/{action}/{id}",
new { id = UrlParameter.Optional, action = "index" });
}
路由约束
有时,相对于 URL 段的数量来说,还需要对 URL 有更多的控制,如下两个 URL:
它们都包含 3 个段,且都可以和先前定义的默认路由相匹配。如果不小心,就会使系统查找一个名为 2008Controller 的控制器和名为 01 的方法,这显然是很荒唐的。然而,仅通过查看这些 URL,我们如何才能知道它们应该映射到哪些内容呢?
约束允许 URL 段使用正则表达式来限制路由是否匹配请求,例如:
public static void RegisterRoutes(RouteCollection routes)
{
// 映射指定的 URL 路由并设置默认路由值和约束。
routes.MapRoute("blog", "{year}/{month}/{day}",
new { controller = "blog", action = "index" },
new { year = @"\d{4}", month = @"\d{2}", day = @"\d{2}" });
routes.MapRoute("simple", "{controller}/{action}/{id}",
new { id = UrlParameter.Optional, action = "index" });
}
在路由的底层使用 Regex 类,熟悉正则表达式的语法规则,可以知道 \d{4} 实际上匹配包含有 4 个连续数字的任意字符串,如“abc1234def”,然而,路由机制会总动使用“^”和“$”符号包装指定的约束表达式,以确保表达式能够精确的匹配参数值。换言之,在这里并不能匹配 “abc1234def”。
这个路由添加在默认的 simple 路由之前,是因为路由会按先后顺序与传入的 URL 进行匹配,直到匹配成功。而 /2008/06/07 这类请求与两个定义的路由都匹配,自然要把更具体的路由放在前面。
路由命名
ASP.NET 中的路由机制不要求路由具有名称,且大多数情况下没有名称的路由也能满足大多数应用场合。通常为了生成一个 URL,只需抓取预定义的路由值,并把它们交给路由引擎,剩余工作就由路由引擎来做。但有些情况下,这种方法在选择生成 URL 的路由时,会产生二义性,而为路由指定名称可解决这个问题,因为这样可以在生成 URL 时,对路由选择进行精确控制。
假设应用程序已经定义了以下两个路由:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute(
name: "Test",
url: "code/p/{action}/{id}",
defaults: new { controller = "Section", action = "Index", id = "" }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = "" }
);
}
为在视图中生成一个指向每个路由的超链接,编写了下面两行代码:
@Html.RouteLink("Test", new { controller = "section", action = "Index", id = 123 })
@Html.RouteLink("Default", new { controller = "Home", action = "Index", id = 123 })
注意,上面的两个方法调用不能指定使用哪个路由来生成链接。它们只是提供了一些路由值,来让 ASP.NET 路由引擎帮助生成 URL。正如期望的那样,生成了对应的 URL:
<a href="/code/p/Index/123">Test</a>
<a href="/Home/Index/123">Default</a>
假设我们在路由列表的开始部分添加了如下的路由,以便 /aspx/SomePage.aspx 页面能够处理 URL/static/url:
routes.MapPageRoute("new", "static/url", "~/aspx/SomePage.aspx");
将上面的路由移动到定义路由列表的开始位置,看起来是无足轻重的变化,但真的是这样吗?对于传入的请求,该路由只能匹配 /static/url 的请求,这正是我们想要的。但是如何生成 URL 呢?回到前面查看两次调用 RouteLink 返回的结果,将会发现返回的两个 URL 都是不可用的:
<a href="/static/url?controller=section&action=Index&id=123">Test</a>
<a href="/static/url?controller=Home&action=Index&id=123">Default</a>
通常,当使用路由生成 URL 时,我们提供的路由值会被用来填充本文开始所说的 URL 参数。由于新的路由没有 URL 参数,因此它可以匹配每一个可能生成的 URL,使其它已有的路由不可用。
这个问题修正起来非常简单:生成 URL 时指定路由名称。大多时候,路由机制挑选出来生成 URL 的路由完全是随机的,而通常我们自己都非常明确自己想要的路由,因此,我们可以指定它。这不仅可以避免二义性,还可以提高性能,因为路由引擎可以直接定位到指定的路由。下面的代码进行了修改,也得到了正确生成的 URL:
@Html.RouteLink(
linkText: "route: Test",
routeName: "test",
routeValues: new { controller = "section", action = "Index", id = 123 }
)
@Html.RouteLink(
linkText: "route: Default",
routeName: "default",
routeValues: new { controller = "Home", action = "Index", id = 123 }
)
<a href="/code/p/Index/123">route: Test</a>
<a href="/Home/Index/123">route: Default</a>
段中的多个 URL 参数
正如先前所述,路由 URL 的每个段都可能含有多个参数,下面这些是有效 URL:
- {title}-{artist}
- Album{title}and{artist}
- {filename}.{ext}
为了避免二义性,我们规定参数不能临近,下面列出的 URL 都是无效的:
- {title}{artist}
- Download{filename}{ext}
路由 URL 在与传入的请求匹配时,它的字面值是与请求精确匹配的,而其中的 URL 参数则是贪婪匹配!这与正则表达式有同样的含义,换言之,路由使每个 URL 参数都尽可能多的匹配文本。
例如,路由 {filename}.{ext} 是如何匹配 /asp.net.mvc.xml 请求的呢?如果 {filename} 不是贪婪匹配,那么它只需要匹配 asp,而由 {ext} 参数匹配剩余的 .net.mvc.xml,但由于 URL 参数要求贪婪匹配,所以 {filename} 参数会尽可能匹配它能匹配的文本 asp.net.mvc,但它不能再匹配更多的了,因为必须为 .{ext} 部分留下匹配空间。
揭秘路由如何生成 URL
路由两大主要职责,除了之前所叙述的如何匹配传入的请求 URL之外,路由机制另一大指责是构造与特定路由对应的 URL。在生成 URL 时,生成 URL 的请求应该首先与选择用来生成 URL 的路由相匹配,这样路由就可以在处理传入传出 URL 时成为一个完整的双向系统!
路由核心是一个非常简单的算法,该算法基于一个由 RouteCollection 类和 RouteBase 类组成的简单抽象对象。可以采用多种方法来生成 URL,但这些方法都以调用一个 RouteCollection.GetVirtualPath 的重载方法而结束。该方法有两个重载的版本:
public VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values);
public VirtualPathData GetVirtualPath(RequestContext requestContext, string name, RouteValueDictionary values);
1. 路由集合通过 Route.GetVirtualPath 方法遍历每个路由并询问:可以生成给定参数的 URL 吗?这个过程类似于路由在与传入请求匹配时所运用的逻辑。
2. 如果一个路由可以应答,那么它就返回一个包含了 URL 的 VirtualPathData 实例以及其他匹配信息,否则它就返回空值,路由机制移向列表的下一个路由。
重载版本二接收 3 个参数,多了路由名称。在路由集合中路由名称是唯一的,路由机制可以立即找到指定名称的路由,并进行上述逻辑,若指定的路由不能匹配指定的参数,Route.GetVirtualPath 返回空值,并且不会再匹配其他路由。
URL 生成详解
Route 类提供了前面高层次算法的具体实现:
- 开发人员调用像 Html.ActionLink 或 Url.Action 之类的方法,这些方法反过来再调用 RouteCollection.GetVirtualPath 方法,并向它传递一个 RequestContext 对象、一个包含值的字典、选择生成 URL 的路由名称(可选参数)。
- 路由机制查看要求的路由 URL 参数(即没有提供 URL 参数的默认值),并确保提供的路由值字典为每一个要求的参数提供一个值。否则,URL 生成程序会立即停止,并返回空值。
- 一些路由可能包含没有对应 URL 参数的默认值。例如,路由可能为 category 键提供一个默认值“pastries”,但是 category 不是路由 URL 的一个参数。这种情况下,如果用户传入的路由值字典为 category 提供了一个值,那么该值必须匹配 category 的默认值!
- 路由系统应用路由的约束,如果有的话。
- 路由匹配成功。现在可以查看每一个 URL 参数,并尝试用字典中的对应值填充对应参数,进而生成 URL。
溢出参数(overflow parameters)
指在 URL 生成过程中使用但没有在路由定义中指定的路由值,且溢出参数会作为查询字符串参数附加在生成的 URL 之后。
例如下面第一的默认路由:
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = "" }
);
如果使用这条指令渲染一个 URL:
@Url.RouteUrl(new { controller = "Report", action = "List", page = "123" })
上述代码生成的 URL 是:/Report/List?page=123
假设定义了下面的路由:
public static void RegisterRoutes(RouteCollection routes)
{
routes.MapRoute("report", "reports/{year}/{month}/{day}", new { day = 1 });
}
还有一些按照下面的一般格式,调用 Url.RouteUrl 方法后返回的结果:
@Url.RouteUrl(new { param1 = values1, param2 = values2,..., paramN = valuesN, })
参数及响应结果如下表:
参 数 |
返 回 URL |
说 明 |
year=2007, month=1, day=12 | /reports/2007/1/12 | 直接匹配 |
year=2007, month=1 | /reports/2007/1 | 有默认值,day=1 |
year=2007, month=1,
day=12, category=123 |
/reports/2007/1/12?category=123 | 溢出参数进入到 URL 的查询字符串中 |
year=2007 | 返回空值 | 没有为匹配提供足够的参数 |
揭秘路由如何绑定到操作
这里介绍 URL 绑定到控制器操作的底层细节,使我们可以更透彻的理解其中的原理。路由已经变成了一个非常通用的特性,它既不包含 MVC 的内部知识,也不依赖于 MVC。事实上,ASP.NET Web Form 和 ASP.NET Dynamic Data 都引入了路由机制。
为了更好的理解路由机制如何适应 ASP.NET 请求管道,下面介绍路由请求的步骤:
- UrlRoutingModule 尝试使用在 RouteTable 中注册的路由匹配当前请求。
- 如果有一个路由成功匹配,路由模块就会从匹配成功的路由中获取 IRouteHandler 接口对象。
- 路由模块调用 IRouteHandler 接口的 GetHandler 方法,并返回用来处理请求的 IHttpHandler 对象。
- 调用 HTTP 处理程序中的 ProcessRequest 方法,然后把要处理的请求传给它。
- 在 ASP.NET MVC 中,IRouteHandler 是 MvcRouteHandler 类的一个实例,MvcRouteHandler 转而返回一个实现了 IHttpHandler 接口的 MvcHandler 对象。返回的 MvcHandler 对象主要用来实例化控制器,并调用其中的操作方法。