Yan-Feng

记录经历、收藏经典、分享经验

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

Overview

Software developers are well known for paying close attention to the little details, especially when it comes to the quality and structure of their source code. We'll often fight long battles over code indentation styles and where curly braces should go. So it comes as a bit of a surprise when you approach a majority of sites built using ASP.NET and encounter a URL that looks like this:

For all the attention we pay to code, why not pay the same amount of attention to the URL? It may not seem all that important, but the URL is a legitimate and widely used web user interface. Usability expert Jakob Nielsen (www.useit.com) urges developers to pay attention to URLs and provides the following guidelines for high-quality URLs:

  • A domain name that is easy to remember and easy to spell

  • Short URLs

  • Easy-to-type URLs

  • URLs that reflect the site structure

  • URLs that are "hackable" to allow users to move to higher levels of the information architecture by hacking off the end of the URL

  • Persistent URLs, which don't change

Traditionally, in many Web Frameworks such as classic ASP, JSP, PHP, ASP.NET, and the like, the URL represents a physical file on disk. For example, when you see a request for:

You could bet your kid's tuition that the web site has a directory structure that contains a products folder and a List.aspx file within that folder. In this case, there is a direct relationship between the URL and what physically exists on disk. When such a request is received by the web server, the Web Framework executes code associated with this file to respond to the request. In many cases, this code contains or is associated with a template that intermixes server-side declarations with HTML markup to generate the resulting markup sent back to the browser via the response.

As you might guess from the section "Serving Methods, Not Files" in Chapter 2, this 1:1 relationship between URLs and the file system is not the case with most MVC Web Frameworks like ASP.NET MVC. These frameworks generally take a different approach by mapping the URL to a method call on a class.

These classes are generally called Controllers because their purpose is to control the interaction between the user input and other components of the system. If an application is a symphony, the Controller is the conductor — the Controller orchestrates the handling of user input, executes appropriate application and data logic in response to the request, and selects the appropriate view to send back in the response.

The methods that serve up the response are generally called actions. These represent the various actions the Controller can process in response to user input requests.

This might feel unnatural to those who are accustomed to thinking of URLs as a means of accessing a file, but consider the acronym URL itself, Uniform Resource Locator. In this case, Resource is an abstract concept. It could certainly mean a file, but it can also be the result of a method call or something else entirely.

URI generally stands for Uniform Resource Identifier, while URL means Uniform Resource Locator. All URLs are technically URIs. The W3C has said, at www.w3.org/TR/uri-clarification/#contemporary, that a "URL is a useful but informal concept: a URL is a type of URI that identifies a resource via a representation of its primary access mechanism." One way that Ryan McDonough (www.damnhandy.com) put it is that "a URI is an identifier for some resource, but a URL gives you specific information as to obtain that resource." That specific information might be http:// or ftp://.

Arguably this is all just semantics, and most people will get your meaning regardless of which name you use. However, this discussion may be useful to you as you learn MVC because it acts as a reminder that a URL doesn't necessarily mean a physical location of a static file on a web server's hard drive somewhere; it most certainly doesn't in the case of ASP.NET MVC. This chapter will help you map logical URLs/URIs to methods on controllers. All that said we'll use the conventional term URL throughout the book. This chapter also covers the ASP.NET Routing feature, which is a separate API that the MVC framework makes heavy use of in order to map URLs to method calls. The chapter first covers how MVC uses Routing and then takes a peek under the hood a bit at Routing as a standalone feature.

Introduction to Routing

Routing within the ASP.NET MVC framework serves two main purposes:

  • It matches incoming requests and maps them to a controller action.

  • It constructs outgoing URLs that correspond to controller actions.

Later, when we dig deeper, you'll see that the above two items only describe what Routing does in the context of MVC. You'll look at how routing does much more, and is used by other parts of ASP.NET in a later section.

Compared to URL Rewriting

To better understand Routing, many developers compare it to URL Rewriting. After all, both approaches are useful in creating a separation between the URL and the code that handles the URL which can help create "pretty" URLs for Search Engine Optimization (SEO) purposes. One key difference though is that URL Rewriting represents a "page-centric" view of URLs. Most rewriting schemes with ASP.NET rewrite a URL for one page to be handled by another. For example, you might see:

/product/bolts.aspx

rewritten as:

/product/display.aspx?productid=111

Routing, on the other hand takes a "resource-centric" view of URLs. In this case, the URL represents a resource (not necessarily a page) on the Web. With ASP.NET Routing, this resource is a piece of code that executes when the incoming request matches the route. The route determines how the request is dispatched based on the characteristics of the URL — it doesn't rewrite the URL.

Another key difference is that Routing also helps generate URLs using the same mapping rules that it uses to match incoming URLs. Another way to look at it is that ASP.NET Routing is more like bidirectional URL Rewriting. Where this comparison falls short is that ASP.NET Routing never actually rewrites your URL. The request URL that the user makes in the browser is the same URL your application sees throughout the entire request lifecycle.

Defining Routes

Every ASP.NET MVC application needs at least one route to define how the application should handle requests but usually will end up with at least handful. It's conceivable that a very complex application could have dozens of routes or more.

In this section, we'll look at how to define routes. Route definitions start with the URL, which specifies a pattern that the route will match. Along with the route URL, routes can also specify default values and constraints for the various parts of the URL, providing tight control over how the route matches incoming request URLs.

In the following sections, you start with an extremely simple route and build up from there.

Route URLs

After you create a new ASP.NET MVC Web Application project, take a quick look at the code in Global.asax.cs. You'll notice that the Application_Start method contains a call to a method named RegisterRoutes method. This method is where all routes for the application are registered.

Important 

Product Team Aside

Rather than adding routes to the RouteTable directly in the Application_Start method, we moved the code to add routes into a separate static method named RegisterRoutes to make writing unit tests of your routes easier. That way, it is very easy to populate a local instance of a RouteCollection with the same routes that you defined in Global.asax.cs simply by writing the following code:

var routes = new RouteCollection();GlobalApplication.RegisterRoutes(routes);//Write tests to verify your routes here...

Let's clear out the routes in there for now and replace them with this very simple route.

routes.MapRoute("simple", "{first}/{second}/{third}");

The simplest form of the MapRoute method takes in a name for the route and the URL pattern for the route. The name is discussed later. For now, focus on the URL pattern.

Notice that the route URL consists of several URL segments (a segment is everything between slashes but not including the slashes) each of which contains a placeholder delimited using curly braces. These placeholders are referred to as URL parameters.

This is a pattern-matching rule used to determine if this route applies to an incoming request. In this example, this rule will match any URL with three segments because a URL parameter, by default, matches any nonempty value. When it matches a URL with three segments, the text in the first segment of that URL corresponds to the {first} URL parameter, the value in the second segment of that URL corresponds to the {second} URL parameter, and the value in the third segment corresponds to the {third} parameter.

We can name these parameters anything we'd like, as we did in this case. When a request comes in, Routing parses the request URL into a dictionary (specifically a RouteValueDictionary accessible via the RequestContext), using the URL parameter names as the keys, and subsections of the URL in the corresponding position as the values. Later you'll learn that when using routes in the context of an MVC application, there are certain parameter names that carry a special purpose. The following table displays how the route we just defined will convert certain URLs into a RouteValueDictionary.

Open table as spreadsheet

URL

URL Parameter Values

/products/display/123

{first} = products

{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"

If you actually make a request to the URLs listed above, you'll notice that your ASP.NET MVC application will appear to be broken. While you can define a route with any parameter names you'd like, there are certain special parameter names required by ASP.NET MVC in order to function correctly — {controller} and {action}.

The value of the {controller} parameter is used to instantiate a controller class to handle the request. By convention, MVC appends the suffix "Controller" to the {controller} value and attempts to locate a type of that name (case insensitively) that also inherits from the System.Web.Mvc.IController interface.

Going back to the simple route example, let's change it from

routes.MapRoute("simple", "{first}/{second}/{third}");

to:

routes.MapRoute("simple", "{controller}/{action}/{id}");

so that it contains the special URL parameter names.

Now looking again at the first example in the previous table, you see that the request for /products/list/123 is a request for a {controller} named "Products". ASP.NET MVC takes that value and appends the "Controller" suffix to get a type name, ProductsController. If a type of that name that implements the IController interface, exists, it is instantiated and used to handle the request.

The {action} parameter value is used to indicate which method of the controller to call in order to handle the current request. Note that this method invocation only applies to controller classes that inherit from the System.Web.Mvc.Controller base class. Continuing with the example of /products/list/123, the method of ProductsController that MVC will invoke is List.

Note that the third URL in the preceding table, while it is a valid route URL, will probably not match any real Controller and action, as it would attempt to instantiate a Controller named a.bController and call the method named c-d, which are not valid method names.

Any route parameters other than {controller} and {action} are passed as parameters to the action method, if they exist. For example, assuming the following Controller:

public class ProductsController : Controller{  public ActionResult Display(int id)  {    //Do something    return View();  }}

a request for /products/display/123 would cause MVC to instantiate this class and call the Display method passing in 123 for the id. You'll get more into the details of how Controllers work in Chapter 5 after you've mastered Routing.

In the previous example with the route URL {controller}/{action}/{id}, each segment contains a URL parameter that takes up the entire segment. This doesn't have to be the case. Route URLs do allow for literal values within the segments. For example, you might be integrating MVC into an existing site and want all your MVC requests to be prefaced with the word "site", you could do this as follows:

site/{controller}/{action}/{id}

This indicates that first segment of a URL must start with "site" in order to match this request. Thus, /site/products/display/123 matches this route, but /products/display/123 does not match.

It is even possible to have URL segments that intermix literals with parameters. The only restriction is that two consecutive URL parameters are not allowed. Thus:

{language}-{country}/{controller}/{action}{controller}.{action}.{id}

are valid route URLs, but:

{controller}{action}/{id}

is not a valid route. There is no way for the route to know when the controller part of the incoming request URL ends and when the action part should begin.

Looking at some other samples (shown in the following table) will help you see how the URL pattern corresponds to matching URLs.

Open table as spreadsheet

Route URL Pattern

Examples of URLs that match

{controller}/{action}/{category}

/products/list/beverages/blog/posts/123

service/{action}-{format}

/service/display-xml

{reporttype}/{year}/{month}/{date}

/sales/2008/1/23

Defaults

So far, the chapter has covered defining routes that contain a URL pattern for matching URLs. It turns out that the route URL is not the only factor taken into consideration when matching requests. It's also possible to provide default values for a route URL parameter. For example, suppose that you have an action method that does not have a parameter:

public class ProductsController : Controller{  public ActionResult List()  {    //Do something    return View();  }}

Naturally, you might want to call this method via the URL:

/products/list

However, given the route URL defined above, {controller}/{action}/{id}, this won't work, as this route only matches URLs containing three segments, and /products/list only contains two segments.

At this point, it would seem you need to define a new route that looks like the above route, but only defines two segments like {controller}/{action}. Wouldn't it be nicer if you didn't have to define another route and could instead indicate to the route that the third segment is optional when matching a request URL?

Fortunately, you can! The routing API has the notion of default values for parameter segments. For example, you can define the route like this:

routes.MapRoute("simple", "{controller}/{action}/{id}", new {id = ""});

The new {id = ""} defines a default value for the {id} parameter. This allows this route to match requests for which the id parameter is missing (supplying an empty string as the value for {id} in those cases). In other words, this route now matches any two or three segment URLs, as opposed to only matching three segment URLs before you tacked on the defaults.

This now allows you to call the List action method, using the URL /products/list, which satisfies our goal, but let's see what else we can do with defaults.

Multiple default values can be provided. The following snippet, demonstrates providing a default value for the {action} parameter as well.

routes.MapRoute("simple"  , "{controller}/{action}/{id}"  , new {id = "", action="index"});
Important 

Product Team Aside

We're using shorthand syntax here for defining a dictionary. Under the hood, the MapRoute method converts the new {id="", action="index"} into an instance of RouteValueDictionary, which we'll talk more about later. The keys of the dictionary are "id" and "action" with the respective values being "" and "index". This syntax is a slight hack for turning an object into a dictionary by using its property names as the keys to the dictionary and the property values as the values of the dictionary. The specific syntax we use here creates an anonymous type using the object initialize syntax. It may feel hackish initially, but we think you'll soon grow to appreciate its terseness and clarity.

This example supplies a default value for the {action} parameter within the URL via the Defaults dictionary property of the Route class. While the URL pattern of {controller}/{action} would normally only match a two segment URL, by supplying a default value for one of the parameters, this route no longer requires that the URL contain two segments. It may now simply contain the {controller} parameter in order to match this route. In that case, the {action} value is supplied via the default value.

Let's revisit the previous table on route URL patterns and what they match and now throw in defaults into the mix.

Open table as spreadsheet

Route URL Pattern

Defaults

Examples of URLs that Match

{controller}/{action}/{id

new {id=""}

/products/display/beverages

/products/list

{controller}/{action}/{id

new {controller="home",action="index", id=""}

/products/display/beverages

/products/list

/products

/

One thing to understand is that the position of a default value relative to other URL parameters is important. For example, given the URL pattern {controller}/{action}/{id}, providing a default value for {action} like new{action="index"} is effectively the same as not having a default value for {action} because there is no default value for the {id} parameter.

Why is this the case?

A quick example will make the answer to this question clear. Suppose that Routing allowed a middle parameter to have a default value, and you had the following two routes defined:

routes.MapRoute("simple", "{controller}/{action}/{id}", new {action="index"});routes.MapRoute("simple2", "{controller}/{action}");

Now if a request comes in for /products/beverage, which route should it match? Should it match the first because you provide a default value for {action}, thus {id} should be "beverage"? Or should it match the second route, with the {action} parameter set to "beverage"?

The problem here is which route the request should match is ambiguous and difficult to keep track of when defining routes. Thus, default values only work when every URL parameter after the one with the default also has a default value assigned. Thus, in the previous route, if you have a default value for {action}, you must also have a default value for {id} which is defined after {action}.

Routing treats how it handles default values slightly different when there are literal values within a URL segment. Suppose that you have the following route defined:

routes.MapRoute("simple", "{controller}-{action}", new {action="index"});

Notice that there is a string literal "-" between the {controller} and {action} parameters. It is clear that a request for /products-list will match this route but should a request for /products- match? Probably not as that makes for an awkward-looking URL.

It turns out that with Routing, any URL segment (the portion of the URL between two slashes) with literal values must not leave out any of the parameter values when matching the request URL. The default values in this case come into play when generating URLs, which is covered later in the section "Under the Hood: How Routes Generate URLs."

Constraints

Sometimes, you need more control over your URLs than specifying the number of URL segments. For example, take a look at the following two request URLs:

Both of these URLs contain three segments and would both match the default route you've been looking at in this chapter thus far. If you're not careful you'll have the system looking for a Controller called 2008Controller and a method called 01! However, just by looking at these URLs, it seems clear that they should map to different things. So how can we make that happen?

This is where constraints are useful. Constraints allow you to apply a regular expression to a URL segment to restrict whether or not the route will match the request. For example:

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}");

In the above snippet, you create a route with three segments, {year}, {month}, and {day}, and you constrain each of these three segments to be digits via a constraints dictionary. The dictionary is specified again using an anonymous object initializer as a shortcut. The constraint for the {year} segment is:

year = @"\d{4}"
Note 

Note that we use the @ character here to make this a verbatim string literal so that we don't have to escape the backslashes. If you omit the @ character, you would need to change this string to "\\d{4}".

The keys for the constraint dictionary map to the URL parameter that they constrain. The regular expression value for the key specifies what the value of that URL parameter must match in order for this route to match. The format of this regular expression string is the same as that used by the .NET Framework's Regex class (in fact, the Regex class is used under the hood). If any of the constraints does not match, the route is not a match for the request and routing moves onto the next route.

So in this case, the year must be a four-digit string. Thus this route matches /2008/05/25 but doesn't match /08/05/25 because "08" is not a match for the regular expression @"\d{4}".

Note 

Note that we put our new route before the default "simple" route. Recall that routes are evaluated in order. Since a request for /2008/06/07 would match both defined routes, we need to put the more specific route first.

By default, constraints use regular expression strings to perform matching on a request URL, but if you look carefully, you'll notice that the constraints dictionary is of type RouteValueDictionary, which implements from IDictionary<string, object>. This means the values of that dictionary are of type object, not of type string. This provides flexibility in what you pass as a constraint value. You'll see how to take advantage of that in a later section.

Named Routes

When constructing a URL, it's quite possible that more than one route matches the information provided to the RouteCollection in order to construct that route URL. In this case, the first match wins. In order to specify that a specific route should construct the URL, you can specify a name for the route. The name of a route is not actually a property of RouteBase nor Route. It is only used when constructing a route (not when matching routes); therefore, the mappings of names to routes is managed by the RouteCollection internally. When adding a route to the collection, the developer can specify the name using an overload:

Example of adding a named route:

public static void RegisterRoutes(RouteCollection routes){    routes.MapRoute("MyRoute",         "reports/{year}/{month}", new ReportRouteHandler()));}

The name of the route is not stored with the route but managed by the route table.

Catch-All Parameter

The catch-all parameter allows for a route to match a URL with an arbitrary number of parameters. The value put in the parameter is the rest of the URL sans query string.

For example, the route in Listing 4-1

Listing 4-1
Image from book
public static void RegisterRoutes(RouteCollection routes){    routes.MapRoute("catchallroute", "query/{query-name}/{*extrastuff}",        new QueryRouteHandler));}
Image from book

would handle the following requests like these:

Open table as spreadsheet

URL

"Parameter" value

/query/select/a/b/c

extrastuff = "a/b/c"

/query/select/a/b/c/

extrastuff = "a/b/c"

/query/select/

extrastuff = "" (Route still matches. The "catch-all" just catches the empty string in this case.)

As mentioned in section 3.2, a route URL may have multiple parameters per segment. For example, all of the following are valid route URLs.

  • {title}-{author}

  • Book{title}and{foo}

  • {filename}.{ext}

To avoid ambiguity, parameters may not be adjacent. For example, the following are invalid:

  • {foo}{bar}

  • Xyz{foo}{bar}blah

When matching incoming requests, literals within the route URL are matched exactly. URL parameters are matched greedily, which has the same connotations as it does with regular expressions In other terms, we try to match as much as possible with each URL parameter.

For example, looking at the route {filename}.{ext}, how would it match a request for /asp.net.mvc.xml? If {filename} were not greedy, it would only match "asp". But because URL parameters are greedy, it matches everything it can, "asp.net.mvc". It cannot match any more because it must leave room for the .{ext} portion to match the rest of the URL.

The following table demonstrates how various route URLs with multiple parameters would match. Note that we use the shorthand for {foo=bar} to indicate that the URL parameter {foo} has a default value "bar".

Open table as spreadsheet

Route URL

Request URL

Route Data Result

Notes

{filename}.{ext}

/Foo.xml.aspx

filename="Foo.xml"

ext="aspx"

The {filename} parameter did not stop at the first literal "." character, but matched greedily instead.

My{location}-{sublocation}

/MyHouse-LivingRoom

location="House"

sublocation="LivingRoom"

 

{foo}xyz{bar}

/xyzxyzxyzblah

foo="xyzxyz"

bar="blah"

Again, greedy matching.

StopRoutingHandler

There are situations in which the developer may wish to exclude certain URLs from being routed. One way to do this is to use the StopRoutingHandler. Listing 4-2 shows adding a route the manual way, by creating a route with a new StopRoutingHandlerStopRoutingHandler and adding the route to the RouteCollection.

Listing 4-2
Image from book
public static void RegisterRoutes(RouteCollection routes){    routes.Add(new Route    (         "{resource}.axd/{*pathInfo}",         new StopRoutingHandler()    ));    routes.Add(new Route    (         "reports/{year}/{month}"         , new SomeRouteHandler()    ));}
Image from book

If a request for /WebResource.axd comes in, it will match that first route. Because the first route returns a StopRoutingHandler, the routing system will pass the request on to normal ASP.NET processing, which in this case falls back to the normal http handler mapped to handle the .axd extension.

There's an even easier way to tell routing to ignore a route, and it's aptly named IgnoreRoute. It's an extension method that's added to the RouteCollection object just like MapRoute that you've seen before. It's a convenience and using this new method along with MapRoute changes Listing 4-2 to look like Listing 4-3.

Listing 4-3
Image from book
public static void RegisterRoutes(RouteCollection routes){    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");    routes.MapRoute(null, "reports/{year}/{month}", new MvcRouteHandler());}
Image from book

Isn't that cleaner and easier to look at? You'll find a number of places in ASP.NET MVC where extension methods like MapRoute and IgnoreRoute can make things a bit tidier.

Under the Hood: How Routes Generate URLs

So far, this chapter has focused mostly on how routes match incoming request URLs, which is one of the primary responsibilities for routes. The other primary responsibility of the routing system is to construct a URL that corresponds to a specific route. When generating a URL, a request for that URL should match the route that was selected to generate the URL. This allows routing to be a complete two-way system for handling both outgoing and incoming URLs.

Important 

Product Team Aside

Let's take a moment and call those two sentences out. "When generating a URL, a request for that URL should match the route that was selected to generate the URL. This allows routing to be a complete two-way system for handling both outgoing and incoming URLs." This is the point where the difference between Routing and standard URL Rewriting become clear. Letting the routing system generate URLs also separates concerns between not just the Model, View, and the Controller but also the powerful but silent fourth player, Routing.

In principle, developers supply a set of route values that the routing system uses to select the first route that is capable of matching the URL.

High-Level View of URL Generation

At its core, the routing system is a simple abstraction based on the RouteCollection and RouteBase classes. It's instructive to first look at how routing works with these classes before digging into the interaction of the more complex Route class with routing. URL construction starts with a method call, RouteCollection.GetVirtualPath, passing in the RequestContext and user specified route values (dictionary) used to select the desired route in the form of a dictionary of parameter values.

  • The route collection loops through every route and asks each one, "Can you generate a URL given these parameters?" via the Route.GetVirtualPath method. This is similar to the matching logic that applies when matching routes to an incoming request.

  • If a route can answer that question (i.e., it matches), it returns a VirtualPathData instance containing the URL. If not, it returns null.

Detailed Look at URL Generation

The Route class provides a specific more powerful default implementation of the above high-level algorithm. This is the logic most developers will use for routing.

Here is how the URL generation algorithm works, expressed as an outlined use case.

Named Routes

For named routes, you can pass the name for the route to the GetVirtualPath method. In this case, you don't iterate through all routes looking for a match; you simply grab the route corresponding to the specified name and apply the route matching rules to that route to see if it can generate a URL.

Ambient Values

There are scenarios in which URL generation makes use of values that were not explicitly supplied to the GetVirtualPath method by the caller. Let's look at a scenario for an example of this.

Ambient Values and Default Values Without Corresponding URL Parameter

Yeah, this title is a mouthful. This is a unique scenario that bears explanation. Suppose that you have the following routes defined. Notice the first route has no {controller} parameter in the URL, but there is a default value for the controller URL parameter.

public static void RegisterRoutes(RouteCollection routes){    routes.MapRoute("todo-route", "todo/{action}",         new {controller="todo", action="list", page=0});    routes.MapRoute("another-route", "{controller}/{action}",         new {controller="home", action="list", page=0});}

Also suppose that the current request looks like this:

/home/list

The route data for the current request (ambient values) looks like this:

Open table as spreadsheet

Key

Value

Controller

home

Action

list

Now suppose that you want to generate a URL pointing to the TodoController like this:

VirtualPathData vp = routes.GetVirtualPath(null,  "todo-route", new RouteValueDictionary());  if(vp != null)  {    return vp.VirtualPath;  }  return null;}

According to the rules listed in the first Simple Case above, because there is a default value for {controller}, but {controller} is not within the URL, any user supplied value for {controller} must match the default value.

In this case, you only look at the user supplied value and not the ambient value. So this call would match the first route.

Overflow Parameters

Overflow parameters are route values explicitly passed into the GetVirtualPath that are not specified in the route URL. Note that ambient values are not used as overflow parameters. Overflow parameters used in route generation are appended to the generated URL as query string parameters unless the overflow parameter is specified in the route's Defaults dictionary or in the route's Constraints dictionary. In that case, the parameter value must match the corresponding default value.

Again, an example is most instructive in this case. Assume that the following Routes are defined: Note that in this case, we're not defining ASP.NET MVC routes, so we switch from calling MapRoute to explicitly adding routes to our RouteCollection. When explicitly adding routes, it is necessary to specify a route handler (an instance of a type that implements IRouteHandler). The MapRoute method specifies the MvcRouteHandler as the route handler. In this example, we specify a ReportRouteHandler, which we made up for the sake of discussion.

public static void RegisterRoutes(RouteCollection routes){    routes.Add(new Route    (        "blog/{user}/{action}"        , new ReportRouteHandler()    )    {        Defaults = new RouteValueDictionary{             {"controller", "blog"},             {"user", "admin"}}    });    routes.Add(new Route    (        "forum/{user}/{action}"        , new ReportRouteHandler()    )    {        Defaults = new RouteValueDictionary{               {"controller", "forum"},               {"user", "admin"}}    });}

In this example, there is no route URL parameter for controller, but the route does define a default. Any requests that match the first route will have the value blog for the key controller in the RouteData. Any requests that match the second route will have the value forum.

Now suppose that the developer wants to generate a URL:

string url1 = RouteCollection.GetVirtualPath(            context,            new {action="Index", controller="forum"}).VirtualPath;            //Should check for null, but this is an example.VirtualPathData vpd2 = RouteCollection.GetVirtualPath(            context,            new {action="Index", controller="blah"});            //returns null.

The first URL generated will be /forum/admin/Index. Because the call to GetVirtualPath specifies the overflow parameter controller, but the routes don't specify controller as a parameter, the defaults of the route are checked to see if there's an exact match.

The default of user has a corresponding parameter in the route URL, so its value is used in URL generation and doesn't need to be specified in the call to GetVirtualPath.

The second URL will return null because the call to GetVirtualPath doesn't match any of the routes. Even though the controller value specified is an overflow parameter, these routes define a default with the same name and the values do not match.

More Examples of URL Generation with the Route Class

Let's assume that the following route is defined:

void Application_Start(object sender, EventArgs e){    RouteTable.Routes.Add(new Route    (        "reports/{year}/{month}/{day}"        , new ReportRouteHandler()    )    {        Defaults = new RouteValueDictionary{{"day",1}}    });}

Here are some results of some GetVirtualPath calls which take the following general form:

RouteCollection.GetVirtualPath(    context,    new RouteValueDictionary {      {param1, value1},      {param2,value2},      ...,      {paramN,valueN}      });
Open table as spreadsheet

Parameters

Resulting URL

Reason

year=2007, month=1, day=12

/reports/2007/1/12

Straightforward matching.

year=2007, month=1

/reports/2007/1

Default for day = 1.

Year=2007, month=1, day=12, category=123

/reports/2007/1/12?category=123

"Overflow" parameters go into query string in generated URL.

Year=2007

returns null

Not enough parameters supplied for a match.

RouteCollection.GetVirtualPath will automatically prepend the ApplicationPath, so subclasses of RouteBase should not do this.

 

Under the Hood: How Routes Tie Your URL to an Action

In the last section, you walked through how routes map to controller actions within the MVC framework. In this section, you take a look under the hood to get a better look at how this happens. This will give you a better picture of where the dividing line is between Routing and MVC.

One common misconception is that Routing is just a feature of ASP.NET MVC. During the early stages of ASP.NET MVC implementation, this was true, but after a while, it became apparent that this was a more generally useful feature. The ASP.NET Dynamic Data team in particular was also interested in using it in their feature. At that point, Routing became a more general-purpose feature that has neither internal knowledge of nor dependency on MVC.

One very outward bit of proof that routing is separate is not just that it's a separate assembly but that it lives in the System.Web.Routing namespace, and not a theoretical System.Web.Mvc.Routing. You can glean a lot reading into namespaces.

Note 

The discussion here focuses on routing for IIS 7 integrated mode. There are some slight differences when using routing with IIS 7 classic mode or IIS 6. When using the Visual Studio built-in web server, the behavior is very similar to the IIS 7 Integrated mode.

The High-Level Request Routing Pipeline

The routing pipeline consists of the following high-level steps:

  1. UrlRoutingModule attempts to match the current request with the routes registered in the RouteTable.

  2. If a route matches, then the routing module grabs the IRouteHandler from that route.

  3. The routing module calls GetHandler from the IRouteHandler, which returns an IHttpHandler. Recall that a typical ASP.NET Page (aka System.Web.UI.Page) is nothing more than an IHttpHandler.

  4. ProcessRequest is called on the http handler, thus handing off the request to be handled.

  5. In the case of MVC, the IRouteHandler is by default an instance of MvcRouteHandler, which in turn returns an MvcHandler (implement IHttpHandler). The MvcHandler is responsible for instantiating the correct controller and calling the action method on that controller.

Route Matching

At its core, routing is simply matching requests and extracting route data from that request and passing it to an IRouteHandler. The algorithm for route matching is very simple from a high-level perspective. When a request comes in, the UrlRoutingModule iterates through each route in the RouteCollection accessed via RouteTable.Routes in order. It then asks each route, "Can you handle this request?" If the route answers "Yes I can!", then the route lookup is done and that route gets to handle the request.

The question of whether a route can handle a request is asked by calling the method GetRouteData. The method returns null if the current request is not a match for the route (in other words, there's no real conversation going on between the module and routes).

RouteData

Recall that when we call GetRouteData, it returns an instance of RouteData. What exactly is RouteData? RouteData contains information about the route that matched a particular request, including context information for the specific request that matched.

Recall in the previous section that we showed a route with the following URL: {foo}/{bar}/{baz}. When a request for /products/list/123 comes in, the route attempts to match the request. If it does match, it then creates a dictionary that contains information parsed from the URL. Specifically, it adds a key to the dictionary for each url parameter in the route URL.

So in the case of {foo}/{bar}/{baz}, you would expect the dictionary to contain at least three keys, "foo", "bar", "baz". In the case of /products/list/123, the URL is used to supply values for these dictionary keys. In this case, foo = products, bar = list, and baz = 123.

 

Advanced Routing with Custom Constraints

Earlier, we covered how to use regular expression constraints to provide fine-grained control over route matching. As you might recall, we pointed out that the RouteValueDictionary class is a dictionary of string-object pairs. When you pass in a string as a constraint, the Route class interprets the string as a regular expression constraint. However, it is possible to pass in constraints other than regular expression strings.

Routing provides an IRouteConstraint class with a single Match method. Here's a look at the interface definition:

public interface IRouteConstraint{  bool Match(HttpContextBase httpContext, Route route, string parameterName,    RouteValueDictionary values, RouteDirection routeDirection);}

When defining the constraints dictionary for a route, supplying a dictionary value that is an instance of a type that implements IRouteConstraint, instead of a string, will cause the route engine to call the Match method on that route constraint to determine whether or not the constraint is satisfied for a given request.

Routing itself provides one implementation of this interface in the form of the HttpMethodConstraint class. This constraint allows you to specify that a route should only match a specific set of HTTP methods (verbs).

For example, if you want a route to only respond to GET requests, but not POST requests, you could define the following route.

routes.MapRoute("name", "{controller}", null  , new {httpMethod = new HttpMethodConstraint("GET")});

Note that custom constraints don't have to correspond to a URL parameter. Thus, it is possible to provide a constraint that is based on some other piece of information such as the request header (as in this case) or based on multiple URL parameters.

 

Route Extensibility

Most of the time, you will find little need to write a custom route. However, in those rare cases that you do, the Routing API is quite flexible. At one extreme, you can toss out everything in the Route class and inherit directly from RouteBase instead. That would require that you implement GetRouteData and GetVirtualPath yourself. In general, I wouldn't recommend that approach except in the most extreme scenarios. Most of the time, you'll want to inherit from Route and add some extra scenario specific behavior.

Having said that, let's look at an example where we might implement RouteBase directly instead of inheriting from Route. In this section, we'll look at implementing a RestRoute class. This single route will match a set of conventional URLs that correspond to a "Resource", in our case a Controller.

For example, when you're done, you'll be able to define the following route using a new extension method you will create:

routes.MapResource("Products");

And that will add a RestRoute to the RouteTable.Routes collection. That route will match the set of URLs in the following table.

Open table as spreadsheet

URL

Description

/products

Displays all products.

/product/new

Renders a form to enter a new product.

/product/1

Where 1 is the ID.

/product/1/edit

Renders a form to edit a product.

However, what happens when one of these URLs are requested depends on the HTTP method of the request. For example, a PUT request to /product/1 will update that product, while a DELETE request to that URL will delete the product.

Note that at the time of this writing, browsers do not support creating a form with a method of PUT or DELETE, so these URLS would require writing a custom client to call everything appropriately. We could implement a "cheat" to enable this for our routes, but that is left as an exercise for the reader.

The first step is to create a new class that implements RouteBase. RouteBase is an abstract method with two methods we'll need to overload.

public class RestRoute : RouteBase{    public override RouteData GetRouteData(HttpContextBase httpContext)    {        //...    }    public override VirtualPathData GetVirtualPath(RequestContext requestContext,           RouteValueDictionary values)    {        //...    }}

You need to add a constructor that takes in the name of the resource. This will end up corresponding to our controller class name. The strategy we'll take here is to have this route actually encapsulate an internal set of routes that correspond to the various URLs we will match. So the constructor will instantiate those routes and put them in an internal List. Along with the constructor, we will implement a simple helper method for adding routes, a Resource property, and an internal list for the routes.

List<Route> _internalRoutes = new List<Route>();public string Resource { get; private set; }public RestRoute(string resource){    this.Resource = resource;    MapRoute(resource, "index", "GET", null);    MapRoute(resource, "create", "POST", null);    MapRoute(resource + "/new", "newitem", "GET", null);    MapRoute(resource + "/{id}", "show", "GET", new { id = @"\d+" });    MapRoute(resource + "/{id}", "update", "PUT", new { id = @"\d+" });    MapRoute(resource + "/{id}", "delete", "DELETE", new { id = @"\d+" });    MapRoute(resource + "/{id}/edit", "edit", "GET", new { id = @"\d+" });}public void MapRoute(string url, string actionName, string httpMethod,                     object constraints){    RouteValueDictionary constraintsDictionary;    if (constraints != null)    {        constraintsDictionary = new RouteValueDictionary(constraints);    }    else    {        constraintsDictionary = new RouteValueDictionary();    }        constraintsDictionary.Add("httpMethod", new HttpMethodConstraint(httpMethod));    _internalRoutes.Add(new Route(url, new MvcRouteHandler())    {        Defaults = new RouteValueDictionary(new              { controller = Resource, action = actionName }),        Constraints = constraintsDictionary    });}

Finally, you need to implement the methods of RouteBase. These are fairly straightforward. For each method, we iterate through our internal list of routes and call the corresponding method on each route, returning the first one that returns something that isn't null.

public override RouteData GetRouteData(HttpContextBase httpContext){    foreach (var route in this._internalRoutes)    {        var rvd = route.GetRouteData(httpContext);        if (rvd != null) return rvd;    }    return null;}public override VirtualPathData GetVirtualPath(RequestContext requestContext,  RouteValueDictionary values){    foreach (var route in this._internalRoutes)    {        VirtualPathData vpd = route.GetVirtualPath(requestContext, values);        if (vpd != null) return vpd;    }    return null;}

To use this route, we simply do the following in the RegisterRoutes method of Global.asax.cs:

routes.Add(new RestRoute("Products"));

Of course, we should make sure to have a Controller with the corresponding methods. The following is the outline of such an implementation.

public class ProductsController : Controller{    public ActionResult Index()    {        return View();    }    public ActionResult New()    {        return View();    }    public ActionResult Show(int id)    {        return View();    }    public ActionResult Edit(int id)    {        return View();    }    public ActionResult Update(int id)    {        //Create Logic then...        return RedirectToAction("Show", new { id = id });    }    public ActionResult Create()    {        //Create Logic then...        return RedirectToAction("Index");    }    public ActionResult Destroy(int id)    {        //Delete it then...        return RedirectToAction("Index");    }}

As you can see in this example, it is possible to extend Routing in ways not anticipated by the product team to provide very custom control over the URL structure of your application.

 
 

Using Routing with Web Forms

While the main focus of this book is on ASP.NET MVC, Routing is a core feature of ASP.NET as of .NET 3.5 SP1. The natural question I've been asked upon hearing that is "Can I use it with Web Forms?" to which I answer "You sure can, but very carefully."

In this section, we'll walk through building a WebFormRouteHandler, which is a custom implementation of IRouteHandler. The WebFormRouteHandler will allow us define routes that correspond to an ASPX page in our application, using code like this (defined within the Application_Start method of a normal WebForms project):

RouteTable.Routes.Add(new Route("somepage",    new WebFormRouteHandler("~/webforms/somepage.aspx"));

Before we dive into the code, there is one subtle potential security issue to be aware of when using routing with URL Authorization. Let me give an example.

Suppose that you have a web site and you wish to block unauthenticated access to the admin folder. With a standard site, one way to do so would be to drop the following web.config file in the admin folder:

<?xml version="1.0"?><configuration>    <system.web>        <authorization>            <deny users="*"/>        </authorization>    </system.web></configuration>

OK, I am being a bit draconian here. I decided to block access to the admin directory for all users. Attempt to navigate to the admin directory and you get an access denied error. However, suppose that you use a naïve implementation of WebFormRouteHandler to map the URL /fizzbin to the admin dir like this:

RouteTable.Routes.Add(new Route("fizzbin",    new WebFormRouteHandler("~/admin/secretpage.aspx"));

Now, a request for the URL /fizzbin will display secretpage.aspx in the admin directory. This might be what you want all along. Then again, it might not be.

In general, I believe that users of routing and Web Forms will want to secure the physical directory structure in which Web Forms are placed using URL authorization. One way to do this is to call UrlAuthorizationModule.CheckUrlAccessForPrincipal on the actual physical virtual path for the Web Form.

Important 

Product Team Aside

In general, routes should not be used to make security decisions. With ASP.NET MVC applications, security is applied via the AuthorizeAttribute directly on the controller or controller action, rather than via routes.

As mentioned before in this chapter, this is one key difference between Routing and URL Rewriting. Routing doesn't actually rewrite the URL. Another key difference is that routing provides a mean to generate URLs as well and is thus bidirectional.

The following code is an implementation of WebFormRouteHandler, which addresses this security issue. This class has a boolean property on it that allows you to not apply URL authorization to the physical path if you'd like (in following the principal of secure by default the default value for this property is true which means it will always apply URL authorization).

public class WebFormRouteHandler : IRouteHandler{  public WebFormRouteHandler(string virtualPath) : this(virtualPath, true)  {  }  public WebFormRouteHandler(string virtualPath, bool checkPhysicalUrlAccess)  {    this.VirtualPath = virtualPath;    this.CheckPhysicalUrlAccess = checkPhysicalUrlAccess;  }  public string VirtualPath { get; private set; }  public bool CheckPhysicalUrlAccess { get; set; }  public IHttpHandler GetHttpHandler(RequestContext requestContext)  {    if (this.CheckPhysicalUrlAccess      && !UrlAuthorizationModule.CheckUrlAccessForPrincipal(this.VirtualPath              , requestContext.HttpContext.User              , requestContext.HttpContext.Request.HttpMethod))      throw new SecurityException();    var page = BuildManager      .CreateInstanceFromVirtualPath(this.VirtualPath        , typeof(Page)) as IHttpHandler;    if (page != null)    {      var routablePage = page as IRoutablePage;      if (routablePage != null)        routablePage.RequestContext = requestContext;    }    return page;  }}

You'll notice that the code here checks to see if the page implements an IRoutablePage interface. If your Web Form page implements this interface, the WebFromRouteHandler class can pass it the RequestContext. In the MVC world, you generally get the RequestContext via the ControllerContext property of Controller, which itself inherits from RequestContext.

The RequestContext is needed in order to call the various API methods for URL generation. Along with the IRoutablePage, I provide a RoutablePage abstract base class that inherits from Page. The code for this interface and the abstract base class that implements it is in the download at the end of this post.

The following code provides an example for how Web Form routes might be registered within the Global.asax.cs file:

public static void RegisterRoutes(RouteCollection routes){    //first one is a named route.    routes.MapWebFormRoute("General",      "haha/{filename}.aspx", "~/forms/haha.aspx");    routes.MapWebFormRoute ("backdoor", "~/admin/secret.aspx");}

The idea is that the route URL on the left maps to the WebForm virtual path to the right.

The code that comes with this book contains a solution that contains all this code you've seen and more, including:

  • WebFormRouting: The class library with the WebFormRouteHandler and helpers

  • WebFormRoutingDemoWebApp: A web site that demonstrates how to use WebFormRouting and also shows off URL generation

  • WebFormRoutingTests: A few noncomprehensive unit tests of the WebFormRouting library

Using techniques like WebFormRouting can also enable you to have successful hybrid WebForms/MVC applications, which can ultimately enable you to use the best of both worlds — MVC when it's appropriate and Web Forms when they are appropriate.

 
 

Summary

Routing is much like the Chinese game of Go, simple to learn and a lifetime to master. Well, not a lifetime, but certainly a few days at least. The concepts are basic, but in this chapter you've seen how routing can enable a number of very sophisticated scenarios in your ASP.NET MVC (and Web Forms) applications.

posted on 2010-06-26 13:17  Yan-Feng  阅读(1389)  评论(0编辑  收藏  举报