Unexpected route chosen while generating an outgoi

2019-04-19 02:07发布

问题:

Please, consider the following routes:

routes.MapRoute(
    "route1",
    "{controller}/{month}-{year}/{action}/{user}"
);
routes.MapRoute(
    "route2",
     "{controller}/{month}-{year}/{action}"
);

And the following tests:

TEST 1

[TestMethod]
public void Test1()
{
    RouteCollection routes = new RouteCollection();
    MvcApplication.RegisterRoutes(routes);
    RequestContext context = new RequestContext(CreateHttpContext(), 
                                                new RouteData());

    DateTime now = DateTime.Now;
    string result;

    context.RouteData.Values.Add("controller", "Home");
    context.RouteData.Values.Add("action", "Index");
    context.RouteData.Values.Add("user", "user1");
    result = UrlHelper.GenerateUrl(null, "Index", null,
                                    new RouteValueDictionary(
                                        new
                                        {
                                            month = now.Month,
                                            year = now.Year
                                        }),
                                    routes, context, true);
    //OK, result == /Home/10-2012/Index/user1
    Assert.AreEqual(string.Format("/Home/{0}-{1}/Index/user1", now.Month, now.Year), 
                    result);
}

TEST 2

[TestMethod]
public void Test2()
{
    RouteCollection routes = new RouteCollection();
    MvcApplication.RegisterRoutes(routes);
    RequestContext context = new RequestContext(CreateHttpContext(), 
                                                new RouteData());

    DateTime now = DateTime.Now;
    string result;

    context.RouteData.Values.Add("controller", "Home");
    context.RouteData.Values.Add("action", "Index");
    context.RouteData.Values.Add("user", "user1");
    context.RouteData.Values.Add("month", now.Month + 1);
    context.RouteData.Values.Add("year", now.Year);
    result = UrlHelper.GenerateUrl(null, "Index", null,
                                    new RouteValueDictionary(
                                        new
                                        {
                                            month = now.Month,
                                            year = now.Year
                                        }),
                                    routes, context, true);
    //Error because result == /Home/10-2012/Index
    Assert.AreEqual(string.Format("/Home/{0}-{1}/Index/user1", now.Month, now.Year), 
    result);
}

This test emulates a situation when I already have route values in request context and try to generate an outgoing url with UrlHelper.

The problem is that (presented in test 2), if I have values for all the segments from the expected route (here route1) and try to replace some of them through routeValues parameter, the wanted route is omitted and the next suitable route is used.

So test 1 works well, as the request context already has values for 3 of 5 segments of route 1, and values for the missing two segments (namely, year and month) are passed through the routeValues parameter.

Test 2 has values for all 5 segments in the request context. And I want to replace some of them (namely, month and year) with other values throught routeValues. But route 1 appears to be not suitable and route 2 is used.

Why? What is incorrect with my routes?

Am I expected to clear request context manually in such circumstances?

EDIT

[TestMethod]
public void Test3()
{
    RouteCollection routes = new RouteCollection();
    MvcApplication.RegisterRoutes(routes);
    RequestContext context = new RequestContext(CreateHttpContext(), 
                                                new RouteData());

    DateTime now = DateTime.Now;
    string result;

    context.RouteData.Values.Add("controller", "Home");
    context.RouteData.Values.Add("action", "Index");
    context.RouteData.Values.Add("month", now.Month.ToString());
    context.RouteData.Values.Add("year", now.Year.ToString());
    result = UrlHelper.GenerateUrl(null, "Index", null,
                                    new RouteValueDictionary(
                                        new
                                        {
                                            month = now.Month + 1,
                                            year = now.Year + 1
                                        }),
                                    routes, context, true);
    Assert.AreEqual(string.Format("/Home/{0}-{1}/Index", now.Month + 1, now.Year + 1), 
                    result);
}

This test makes things more confused. Here I'm testing route2. And it works! I have values for all 4 segments in the request context, pass other values through routeValues, and the generated outgoing url is OK.

So, the problem is with route1. What am I missing?

EDIT

From Sanderson S. Freeman A. - Pro ASP.NET MVC 3 Framework Third Edition:

The routing system processes the routes in the order that they were added to the RouteCollection object passed to the RegisterRoutes method. Each route is inspected to see if it is a match, which requires three conditions to be met:

  1. A value must be available for every segment variable defined in the URL pattern. To find values for each segment variable, the routing system looks first at the values we have provided (using the properties of anonymous type), then the variable values for the current request, and finally at the default values defined in the route.
  2. None of the values we provided for the segment variables may disagree with the default-only variables defined in the route. I don't have default values in these routes
  3. The values for all of the segment variables must satisfy the route constraints. I don't have constraints in these routes

So, according to the first rule I've specified values in an anonymous type, I don't have default values. The variable values for the current request - I suppose this to be the values from the request context.

What is wrong with these reasonings for route2, while they work well for route1?

EDIT

Actually everything started not from unit tests, but from a real mvc application. There I used UrlHelper.Action Method (String, Object) to generate outgoing urls. Since this method is utilized in a layout view (the parent one for the majority of all views), I've taken it into my extension helper method (to exclude extra logic from views), This extension method extracts the action name from the request context, passed to it as an argument. I know I could extract all the current route values through the request context and replace those year and month (or could I create an anonymous route value collection, containing all the values from the context), but I thought it was superfluous, as mvc automatically took into account the values contained in the request context. So, I extracted only action name, as there were no UrlHelper.Action overload without action name (or I'd have liked even to "not specify" action name too), and added the new month and year through anonymous route value object.

This is an extension method:

public static MvcHtmlString GetPeriodLink(this HtmlHelper html, 
                                          RequestContext context, 
                                          DateTime date)
{
    UrlHelper urlHelper = new UrlHelper(context);
    return MvcHtmlString.Create(
              urlHelper.Action(
                 (string)context.RouteData.Values["action"], 
                 new { year = date.Year, month = date.Month }));
}

As I described in the tests above, it worked for shorter routes (when request context contained only controller, year and month, and action), but failed for a longer one (when request context contained controller, year and month, action, and user).


I've posted a workaround I use to make the routing work the way I need.

While indeed I would love to find out, why on earth do I have to make any workaround in such scenario and what is the key difference between these two routes that prevents route1 from working the way route2 does.


EDIT

Another remark. As far as the values in request context are of type string, I decided to try to set them into the context as strings to ensure there were no type confusion (int vs string). I do not understand, what has changed in this respect, but some of the routes started generating correctly. But not all...This makes yet less sense. I've changed this in a real application, not tests, as the tests have int in context, not strings.

Well, I've found the condition under which route1 is used - it's only used when the values of month and year in the context are equal to the ones given in the anonymous object. If they differ (in the tests this is true both for int and string), route2 is used. But why?

This confirms what I have in my real application:

  1. I had strings in the context, but provided ints through the anonymous object, it somehow confused the mvc and it couldn't use route1.
  2. I changed ints in the anonymous object to strings, and the urls where month and year in context are equal to the ones in the anonymous object, started generating correctly; whereas all others didn't.

So, I see one rule: the properties of the anonymous object should be of type string to match the type of the route values in the request context.

But this rule seems to be not compulsory, as in Test3, I changed the types (you may see it now above) and it still works correctly. MVC manages to cast types itself correctly.


Finally I found the explanation to all this behaviour. Please, see my answer below.

回答1:

This is a quick workaround I use to get it work:

public static MvcHtmlString GetPeriodLink(this HtmlHelper html, 
                                          RequestContext context, 
                                          DateTime date)
{
    UrlHelper urlHelper = new UrlHelper(context);

    context.RouteData.Values["month"] = date.Month;
    context.RouteData.Values["year"] = date.Year;

    return MvcHtmlString.Create(
              urlHelper.Action(
                 (string)context.RouteData.Values["action"]));
}

I simply remove the month and year entries from the context.RouteData.Values. I simply replace the month and year entries on the request context. If delete them from context (as I did at first), they would be unavailable for the helpers' methods invoked after this one.

This approach makes my extension method work by the scenario, described in Test 1 (please, see the question).


FINALY

Carefully rereading Sanderson S., Freeman A. - Pro ASP.NET MVC 3 Framework (3rd edition) I at least found the explanation of all this stuff:

Part 2 ASP.NET MVC in detail

Chapter 11 URLs, Routing and Areas

Generating outgoing URLs

in section Understanding segment variable reuse:

The routing system will reuse values only for segment variables that occur earlier in the URL pattern than any parameters that are supplied to the Html.ActionLink method.

As far as my month-year segment is met right after controller and I do specify values for month and year, all trailing segments (action,user) are not reused. As far as I do nor specify them in my anonymous object, they appear to be unavailable for the route. So, route1 cannot match.

In the book there is even a warning:

The best way to deal with this behavior is to prevent it from happening. We strongly recommend that you do not rely on this behavior, and that you supply values for all of the segment variables in a URL pattern. Relying on this behavior will not only make your code harder to read, but you end up making assumptions about the order in which your users make requests, which is something that will ultimately bite you as your application enters maintenance.

Well, it has bitten me)))

Worth to lose 100 rep to remember (I shall even repeat it here again) the rule: The routing system will reuse values only for segment variables that occur earlier in the URL pattern than any parameters that are supplied.



回答2:

Would one route with a default for user be enough? That is

routes.MapRoute(
    "route1",
    "{controller}/{month}-{year}/{action}/{user}",
    new { user = "" }
);

Otherwise you would have to allow something more specific to the user route so it would not match route2 first when creating the url.

Update: I'd suggest leaving your helper and work directly with Url.Action, here are two tests for your scenario above:

 [TestFixture]
    public class RouteRegistrarBespokeTests
    {
        private UrlHelper _urlHelper;

        [SetUp]
        public void SetUp()
        {
            var routes = new RouteCollection();
            routes.Clear();
            var routeData = new RouteData();
            RegisterRoutesTo(routes);
            var requestContext = new RequestContext(HttpMocks.HttpContext(), 
                                           routeData);
            _urlHelper = new UrlHelper(requestContext, routes);
        }

        [Test]
        public void Should_be_able_to_map_sample_without_user()
        {
            var now = DateTime.Now;
            var result = _urlHelper.Action("Index", "Sample", 
                               new {  year = now.Year, month = now.Month });
            Assert.AreEqual(string.Format("/Sample/{0}-{1}/Index", 
                                      now.Month, now.Year ), result);
        }

        [Test]
        public void Should_be_able_to_map_sample_with_user()
        {
            var now = DateTime.Now;
            var result = _urlHelper.Action("Index", "Sample", 
                          new { user = "user1", year = now.Year, 
                                month = now.Month });
            Assert.AreEqual(string.Format("/Sample/{0}-{1}/Index/{2}", 
                                     now.Month, now.Year, "user1"), result);
        }



private static void RegisterRoutesTo(RouteCollection routes)
{
    routes.MapRoute("route1", "{controller}/{month}-{year}/{action}/{user}");
    routes.MapRoute("route2", "{controller}/{month}-{year}/{action}");
}

}