loading...

Kentico 12: Design Patterns Part 10 - MVC Routing with NodeAliasPath

seangwright profile image Sean G. Wright ・14 min read

Photo by George Hiles on Unsplash

To celebrate #10 in my Kentico 12 - Design Patterns series πŸŽ‰, I've written up a long post describing a cool approach for unlocking the power of traditional Portal Engine routing in our Kentico 12 MVC apps. Read on!

Building Kentico CMS applications using the ASP.NET MVC technology stack enables developers to use a great framework and take full control over the delivery and presentation of all the content managed within Kentico.

Kentico 12 MVC has many advantages compared to previous versions of the platform (which you can read more about here), but there are some features of Kentico's previous, Web Forms based, Portal Engine architecture that don't have a clear counterpart in MVC.

One of the features, that developers with prior Kentico experience, might find themselves looking for, is a way to have routing in their MVC applications work with the NodeAliasPath value of pages in the CMS content tree.

This isn't possible with the pieces that Kentico provides out-of-the-box 😟, but with a few key integration points into the MVC framework we can effectively replicate this behavior and build sites with very dynamic routing πŸ™Œ.

Let's take a look at an example using the Kentico demo Dancing Goat site of how things are built by default, and then we'll look at how we get to NodeAliasPath driven MVC routing.

MVC Driven Convention Based Routing

Articles in the CMS Content Tree

If we look at the content tree of the Dancing Goat demo we can see a node named Articles which is an instance of the custom Page Type Article section (MVC).

This page exists only to organize content within the content tree - as it doesn't have a "Page" tab - and it isn't represented as a navigable item in the MVC application.

The child pages are instances of the custom Page Type Article (MVC) and they do have the "Page" tab enabled - which means they can be routed to in the MVC application by specific URL patterns.

How is the routing handled for this page and the content nested beneath it?

Let's take a look...

Articles in MVC

First we note that there is an explicit route defined in App_Start\RouteConfig.cs in the DancingGoat application:

var route = routes.MapRoute(
    name: "Article",
    url: "{culture}/Articles/{guid}/{pageAlias}",
    defaults: new 
    { 
        culture = defaultCulture.Name, 
        controller = "Articles", 
        action = "Show" 
    },
    constraints: new 
    { 
        culture = new SiteCultureConstraint(), 
        guid = new GuidRouteConstraint() 
    }
);

The route configuration for this content is using MVC's convention based routing (as opposed to Attribute Routing).

Given that we have an explicit route defined for this content that only uses a single controller, it's not very convention based - this is feeling more like configuration. This is normal - MVC's routing conventions only go so far without customization πŸ€·β€β™€οΈ.

We can already see how the URLs for our content are tied to the MVC application route configuration. All articles in the site are found under a {culture}/Articles/{guid}/{pageAlias} path pattern.

We can see that the route parameter {guid}, which is the NodeGuid of the Article (MVC) page instance in the tree, is included to ensure the MVC application can find the right page.

An example of a URL for the Article - "Coffee Beverages Explained"

The case where we would need NodeGuid to uniquely identify a page is if there are multiple Article (MVC) pages under different parents but with the same name ({pageAlias}), as {pageAlias} means NodeAlias - the 'slugified' version of the page's friendly name.

The Guid is kinda forced on us to bridge πŸŒ‰ the gap between content management in the CMS and content delivery in MVC.

The rendering controller of Article (MVC) content in our site is exclusively handled by Controllers\ArticlesController.cs.

This isn't a bad thing - in fact for most use cases this will make sense - no matter where an instance of Article (MVC) is in the content tree, we will want it to be retrieved from the database, mapped to a view model, and rendered in the same way on the MVC side.

The primary things I want us to note from this example are the following:

  1. We are using Convention Based Routing, but it's looking more like Configuration Based.
  2. Our URL patterns are not configurable by the content editors in the CMS - the /Articles/ segment of the URL pattern above is hard-coded.
  3. We need to insert a Guid value into the URL to make it unique enough for the MVC application to find the right Article (MVC) instance - Guids aren't very user friendly and it feels like a necessary evil.

To be fair, Kentico provides a way around #2 above using MVC Alternative URLs, but this feature is best used as a way to generate nice looking URLs for marketing purposes, not as the primary URL generation for all site content.

But Does it Scale?

This routing design pattern presented by Kentico in the Dancing Goat demo site is a good pattern! It will work well for you! πŸ’ͺ

ASP.NET MVC developers have been building apps for a decade using this approach and if it fits your needs, then the rest of this post will be for education only.

Allow me to posit a scenario where the above routing architecture will not work.

Imagine we have two authors of articles on our site, Axel and Rose, and they each want their own vanity URLs with their names in them as follows:

/axels-coffee-corner/{articleName}
/rose-knows-coffee/{month}-{year}/{articleName}

Axel writes his articles infrequently so having all of his articles nested under /axels-coffee-corner/ is good enough.

Rose, however, writes articles on coffee twice a day, even on weekends (she really likes coffee β˜• and writing πŸ“!), so we want to add a {month}-{year} pattern in her URLs so her readers know how fresh her content is.

Rose also mentioned some other people in the company have become interested in writing about coffee and might want to start their own sections of articles on the site... 😨

We have a couple problems here with our existing MVC routing architecture.

  • ❌ We don't have a good way to separate one author's articles from another's via URLs - we only have 1 pattern: {culture}/Articles/{guid}/{pageAlias}.
  • ❌ If we use a page container ({month}-{year}) in the CMS to organize Rose's articles, it will be tricky to parse those out of the URL on the MVC side, and then use those values to query for the right item in the database. We will need to use them for querying as they effectively take the place of using NodeGuid to guarantee uniqueness.
  • ❌ Adding any new authors to the site with their own vanity URLs will require new routing patterns in the MVC application to be deployed.

Our requirements are looking more and more like the organization of the content in the CMS should match the URLs we use to navigate to it.

We don't want to parse URLs into logical segments for querying since a 'full' URL already exists in the database - the NodeAliasPath.

At the same time we want to easily and consistently render the content in the same way on the MVC site without needing to continuously deploy route configuration updates. πŸ˜’

MVC is all about content delivery, not content organization, so how do we truly abstract the rendering of the content in MVC from the organization and URL identification of it in the CMS? πŸ€”

Routing by NodeAliasPath with IRouteConstraint

Let's start by defining what we need to accomplish:

  1. βœ… Configure a Route that matches any NodeAliasPath.
  2. βœ… Only have our Route match for the given NodeAliasPath if the Page associated with it is an instance of a specific Page Type.
  3. βœ… Have this Route connected to a specific Controller and Action which are coded to handle pages of the required Page Type.

Configuring Our Route

All of these requirements can be fulfilled by implementing the System.Web.Routing.IRouteConstraint interface.

IRouteConstraint has 1 method, Match, which returns true when the context of the request and Route match our requirements and false when they do not:

bool Match(
    HttpContextBase httpContext, 
    Route route, 
    string parameterName, 
    RouteValueDictionary values, 
    RouteDirection routeDirection)

We assign instances of classes that implement IRouteConstraint to our Convention based route definitions in an object that we pass to the constraints parameter.

routes.MapRoute(
    name: "default",
    url: "{controller}/{action}/{id}",
    defaults: new
    {
        controller = "Home",
        action = "Index",
        id = UrlParameter.Optional
    },
    constraints: new
    {
        id = new MyCustomConstraint()
    });

In the above example we are constraining the {id} route parameter by the implementation of MyCustomConstraint.

Its Match method will be passed "id" as the value of the string parameterName parameter, which can be used to look up the value in the RouteValueDictionary values parameter.

Let's first define our Route and then implement IRouteConstraint for our use-case below:

routes.MapRoute(
    name: "Articles",
    url: "{*nodeAliasPath}",
    defaults: new
    {
        controller = "Articles",
        action = "Show"
    },
    constraints: new
    {
        nodeAliasPath = new NodeAliasPathConstraint(Article.CLASS_NAME)
    });

Above we are defining a route with a very loose URL pattern of {*nodeAliasPath}, which effectively matches every URL.

This would normally be bad since only URLs for Articles should be handled by ArticlesController.Show, but here our NodeAliasPathConstraint helps us.

The constraint takes the Kentico CLASS_NAME of the Article (Article (MVC) Page Type) class as a parameter and constrains the nodeAliasPath token of the Route pattern template to meet its requirements. πŸ‘

Defining Our Constraint

Below is a yet-to-be completed implementation of our constraint:

public class NodeAliasPathConstraint : IRouteConstraint
{
    private readonly string nodeClassName;

    public NodeAliasPathConstraint(string nodeClassName) => 
        this.nodeClassName = nodeClassName;

    public bool Match(
        HttpContextBase httpContext, 
        Route route, 
        string parameterName, 
        RouteValueDictionary values, 
        RouteDirection routeDirection)
    {
        // ❗ If we didn't configure the route correctly and no
        // nodeAliasPath was defined in the route URL template
        // we can't match

        if (!values.TryGetValue(parameterName, out object nodeAliasPathObj))
        {
            return false;
        }

        // Converts nodeAliasPathObj into a TreeNode.NodeAliasPath
        // by prefixing with a /

        string nodeAliasPath = $"/{nodeAliasPathObj}";

        // TODO: Determine if nodeAliasPath matches any page in the CMS
        // with a TreeNode.NodeClassName matching our nodeClassName

        return false;
    }
}

Above, NodeAliasPathConstraint, takes a nodeClassName and constructs a nodeAliasPath from the Route values.

We now need to query the database to check for two conditions:

  1. There exists a page in the CMS that has a TreeNode.NodeAliasPath matching our nodeAliasPath captured by the route parameters for the current request.
  2. That page has a TreeNode.NodeClassName which matches the types of pages our Route can handle, as defined by nodeClassName.

Let's add the logic to query the database:

// Additional filters (e.g. culture) should be applied as needed

bool isPreview = HttpContext.Current.Kentico().Preview().Enabled;

var node = DocumentHelper.GetDocuments()
    .WhereEquals(nameof(TreeNode.NodeAliasPath), nodeAliasPath)
    .LatestVersion(isPreview)
    .Published(!isPreview)
    .OnSite(SiteContext.SiteName)
    .CombineWithDefaultCulture()
    .TopN(1)
    .Column(nameof(TreeNode.ClassName))
    .FirstOrDefault();

if (node is null)
{
    return false;
}

if (string.Equals(node.ClassName, nodeClassName, StringComparison.OrdinalIgnoreCase))
{
    // ❗ We replace the `nodeAliasPath` route value with the one we prefixed
    // above so that the ArticlesController.Show(string nodeAliasPath)
    // call is passed a correct TreeNode.NodeAliasPath value

    values[parameterName] = nodeAliasPath;

    return true;
}

return false;

We want to keep our query fast πŸƒβ€β™€οΈ since it will be called any time MVC's routing process needs to know if our "anything" Route of "{*nodeAliasPath}" is a match for a specific URL.

So, we limit the columns to ClassName, and take only the first result (we should only have 1).

If no page existed with a NodeAliasPath matching the URL, we return false.

If the query above returns a node for a page and the page's NodeClassName doesn't match the Page Type handled by this route, we return false.

We only return true when a page exists in the content tree with a matching NodeAliasPath and and the page NodeClassName matches the one provided to the Constraint's constructor.

Optimizing Our Logic with Caching

Currently, if we use this Constraint on multiple routes, all with the same "{*nodeAliasPath}" URL template, our constraint - and therefore our query - will continue to be executed until it succeeds - once for each Route that MVC checks the requested URL against.

In the worst case, for example, with 15 Controllers, each handling a different Page Type, we will have 15 Routes defined with unique instances of this Constraint. If the last Route checked is the correct one then we will run this query 15 times. 😝 Blech!

Our Constraint will also be called on every subsequent HTTP request for the same route (i.e., you make a request and then refresh the page).

We don't want to repeat this query needlessly when the result is already known!

The quick and effective way to resolve this performance scalability issue is to use caching.

Kentico provides a CacheHelper class to help developers add items to the cache and set the dependencies of those cached items.

Here is how our use of CacheHelper will look:

Func<TreeNode> query = () => DocumentHelper.GetDocuments()
    .WhereEquals(nameof(TreeNode.NodeAliasPath), nodeAliasPath)
    .LatestVersion(isPreview)
    .Published(!isPreview)
    .OnSite(SiteContext.SiteName)
    .CombineWithDefaultCulture()
    .TopN(1)
    .Column(nameof(TreeNode.ClassName))
    .FirstOrDefault();

string scope = nameof(NodeAliasPathConstraint);
string cacheItemName = $"{scope}|preview:{isPreview}|" +
    {SiteContext.SiteName}|{nodeAliasPath}"

var node = CacheHelper.Cache(
    query,
    new CacheSettings(
        cacheMinutes: 5, 
        cacheItemNameParts: cacheItemName)
    {
        GetCacheDependency = () => CacheHelper.GetCacheDependency(new[]
        {
            $"node|{SiteContext.CurrentSiteName}|{nodeAliasPath}"
        })
    });

// Check if node is a match ...

This use of CacheHelper will cache the result of our query for a TreeNode matching the given nodeAliasPath for 5 minutes and also clear the cache if the CMS updates any node matching the nodeAliasPath for the current site.

We now have a reusable NodeAliasPathConstraint class that can be used to constrain which URLs match all the various Routes our MVC application defines.

Since the Route we defined above matches on any NodeAliasPath for an Article (MVC) page, our ArticlesController can handle any Article anywhere in the content tree, no matter how it is organized or what the URL looks like - as long as the URL is the page's NodeAliasPath.

Both Axel and Rose's Article URLs will work how they expected. πŸ€Έβ€β™‚οΈ

Any new authors simply need to create a container page in the tree and start nesting new Article (MVC) page instances beneath it.

Caveats of Convention Based Routing

Our NodeAliasPath based routing works exactly how we hoped, and there's nothing wrong with stopping here πŸ˜….

However, with the current design we're going to end up with a bunch of RouteCollection.MapRoute() calls in our MVC application that effectively do the same thing, all in slightly different ways.

We also have the problem with "Scattered Strings" that I've mentioned in previous posts:

defaults: new
{
    controller = "Articles",
    action = "Show"
}

If we rename our ArticlesController to ArticleController (no s), our Route will break unless we update the above line to controller = "Article" 😒.

Since we are applying Constraints to specific Controller/Action pairs to match specific NodeAliasPath/NodeClassName pairs, we will need a Route defined, in most cases, for both the "List" view and "Detail" view for each Page Type.

If we want to use a method named List instead of the Index method for showing a list of posts, we need to update the methods and also the Route configuration 😀.

If we only have a handful of Routes where we are using this Constraint then the above pattern will work well...

But, there is a final form to this design that will elegantly handle our NodeAliasPath based routing across many Controllers throughout our application - it will scale as much as we want.

Read ahead if you dare! πŸŽƒπŸ‘»

The Ultimate Approach!

PageTypeRouteAttribute

First, we are going to create a custom Attribute that is not based on any MVC classes.

[AttributeUsage(AttributeTargets.Method)]
public class PageTypeRouteAttribute : Attribute
{
    public PageTypeRouteAttribute(params string[] classNames) =>
        ClassNames = classNames;

    public string[] ClassNames { get; }
}

This Attribute will act as a marker on all of our Controller Action methods, indicating, which custom Page Types the method supports for rendering.

This Attribute allows for multiple class names since a single Action method could potentially handle different Page Types in a very generic and abstract way, using only their common TreeNode properties, or through a custom interface we create and apply to all of them 🧐.

Below are examples of how we'd use this Attribute:

// ArticlesController.cs

[PageTypeRoute(Article.CLASS_NAME)]
public ActionResult Index(string nodeAliasPath)
{
    // ...
}

// or maybe HomeController.cs

[PageTypeRoute(Home.CLASS_NAME, "CMS.Root")]
public ActionResult Index(string nodeAliasPath)
{
    // ...
}

Updating Our NodeAliasPathConstraint

We want to use the standard MVC routing behavior when we find a NodeAliasPath match, except we want the "controller" and "action" values in the RouteValueDictionary to come from the Controller and Action with the right PageTypeRouteAttribute, matched by NodeClassName.

We can first remove the constructor for NodeAliasPathConstraint we defined above because our new implementation will be used for all custom Page Types.

We will use the NodeClassName value returned by our query to find the correct Controller and Action to process the request, using the PageTypeRouteAttributeCacheHelper:

if (!values.TryGetValue(parameterName, out object nodeAliasPathObj))
{
    return false;
}

string nodeAliasPath = $"/{nodeAliasPathObj as string}";

// Use the same CacheHelper.Cache call to query for matching Node

var node = ...

if (node is null)
{
    return false;
}

if (!PageTypeRouteAttributeCacheHelper
        .ClassNameLookup
        .TryGetValue(nodeClassName, out var pair))
{
    return false;
}

values["action"] = pair.ActionName;
values["controller"] = pair.ControllerName;
values[parameterName] = nodeAliasPath;
values["nodeClassName"] = node.ClassName;

return true;

The PageTypeRouteAttributeCacheHelper class can be found in a GitHub Gist here as it's too long to put in the post.

It will reflect over all types in the application, find Controller instances, find all Actions on those Controllers with PageTypeRouteAttributes, and finally adds a ActionName/ControllerName struct to a Dictionary with the PageTypeRouteAttribute.ClassNames values as keys.

Ideally, the lookup would be performed by a service implementing an interface that is injected into the constructor of NodeAliasPathRouteHandler to make it more testable. I'll leave this up to the reader πŸ€“!

Register Our Route

We now only need to register a single Route that uses the NodeAliasPathConstraint.

routes.MapRoute(
    name: "NodeAliasPath",
    url: "{*nodeAliasPath}",
    defaults: null,
    constraints: new
    {
        nodeAliasPath = new NodeAliasPathConstraint()
    });

Updating the CMS Page Type Configuration

The last thing for us to do is to change the "URL Pattern" field of the Article (MVC) Page Type to {% NodeAliasPath %}.

This will ensure that when we view the "Page" tab of an Article page in the content tree, it will send the correct request to the MVC app - using the NodeAliasPath as the URL - and render the page using everything we built above.

Where To Go From Here

We can use this pattern on as many custom Page Types as we want and we'll be guaranteed that the correct Controller and Action will be selected no matter where in the tree you place your content πŸ‘.

We can also be assured that the organized structure of the CMS content tree, and the names of all the pages, will be reflected in the URLs used to access the content in the MVC application ⚑.

A pattern we've been using at WiredViews is to create a custom Page Type for the "list page" for each content Page Type.

So, similar to the above example with Article (MVC), the Article section (MVC) would have the "Page" tab enabled.

We would configure the ArticlesController as follows:

[PageTypeRoute(ArticleSection.CLASS_NAME)]
public ActionResult Index(string nodeAliasPath)
{
    // ...
}

Every Article Section would be rendered by the same logic in the MVC application but different sections could be created anywhere in the content tree, each with their own NodeAliasPath to be used as the URL for rendering their content πŸ’ͺ.

Example:

Imagine we have a "Article Container" -> "Article Author" -> "Article" content hierarchy:

/Company-Articles/Sean-G-Wright could list the top 10 most recent articles I've written.
/Company-Articles/Moe-Tucker would list Moe's top 10 most recent.
/Company-Articles/Moe-Tucker/How-To-Make-Coffee would be a specific article of Moe's.
/Guest-Articles/Exene-Cervenka/Rock-And-Roll-Coffee would show Exene's guest article.

Being able to have multiple "homes" for the same content type and not having to worry about adjusting the MVC routing is a pretty powerful capability πŸš€πŸ€˜!

Two bonus perks, that I won't cover here, are:

  • The ability to much more easily generate dynamic menus by using a page's NodeAliasPath instead of having to generate the URL via MVC's APIs.
  • Creating an XML sitemap using NodeAliasPath for pages that would normally have complex requirements for generating URLs. Using NodeAliasPath can also simplify the query we'd need to source the sitemap data - the URL, image, and modification date could all come from the same DocumentQuery for all documents, regardless of the Page Type.

Summary

Ok, that was long... but if you've read my posts before you know I enjoy digging deep into design patterns, Kentico, and ASP.NET MVC - so, sorry #notsorry 🀣.

We have a solution for NodeAliasPath based routing in our Kentico 12 MVC application, but what all did we accomplish exactly?

  • βœ… We adapted the NodeAliasPath based URL feature from Kentico CMS Portal Engine sites to Kentico 12 MVC, so content editors have control over URLs.
  • βœ… We made it possible for Page Types to have multiple homes in the CMS without increasing the complexity of MVC Route configuration.
  • βœ… We made an easily re-usable Attribute that clearly defines what content is connected to which Controller Action.
  • βœ… We leveraged Kentico's caching API to ensure our frequently called Route Constraint doesn't impact site performance.

I'd love to get feedback on this pattern from all the Kentico developers out there that are building Kentico 12 MVC applications.

I know that Kentico is planning on adding a "dynamic routing" solution in Kentico 2020, but since we aren't in that wonderful Valhalla 🌞 of ASP.NET Core Kentico just yet, we Kentico developers need to get creative sometimes.

Thanks for reading πŸ™!


If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:

Or my Kentico blog series:

Posted on by:

seangwright profile

Sean G. Wright

@seangwright

dev lead @WiredViews, founding partner @craftbrewingbiz. @Kentico Xperience MVP. love to learn / teach web dev & software engineering, collecting vinyl records, mowing my lawn, craft 🍺

Discussion

pic
Editor guide
 

Hello Sean,

we are approaching Kentico MVC development after 10+ years of Kentico development.

I liked your approach to routing but have you any idea about using it in a multi language web site? I know I should include a culture reference in the url pattern but at the same time I noticed that the pagealias (nodealias) is the same in all cultures so it's not very SEO and user friendly. So I looked to alternative URLs and saw I can define N alternative URLs for every page but I don't know how to manage them if had to use it dynamically on the site (eg. a menu)

 

Yuri,

Thanks for reaching out!

There's a couple of options here.

  1. Still use NodeAliasPath routing, and separate the culture from the URL when the route constraint is applied. You could then store the culture in a service to be used later in the request (like when querying the database).

  2. Check out the DynamicRouting project that Trevor Fayas has been working on, which is a much more full featured and robust approach than what I have presented here.

For option 1, you're querying will be more complex, and you'll probably want to have a well defined way for finding culture segments in URLs and separating those from the NodeAliasPath part. But, choosing this option will let you get going with a lot less infrastructure.

For option 2, well that project is still in development, but Trevor is making rapid progress. It actually uses a very similar attribute approach to routing that I show here (I did the attribute integration in Trevor's project). The DynamicRouting project supports URL generation and customization in the CMS far beyond what Kentico provides out-of-the-box.

I also remember Dmitry Bastron mentioning on Twitter (in the conversation linked in the comment above) that he has done this kind of routing while also taking into account the culture, so maybe ask him if he has some tips?

Generating page links / URLs is another challenge altogether. I have a blog post detailing some of my explorations and solutions there that you might want to check out.

I've been able to create CMS managed dynamic menus with this approach.

If you aren't in the Kentico Slack account, I'd recommend joining (link also in the comment above). It can be a great place to get some help on these things.

Cheers!

 

As you know, the Dynamic Routing is now finished and on NuGet and can handle Cultures perfectly fine, along with any pattern really.

 

Sean,

I really like this post, but I'm wondering if you have a complete code example / project on GitHub that you could post here to give a little more context to the snippets. I'm trying to work through using this pattern and I'm having a bit of a hard time, but this post is perfect for what I need on my current project!

Thanks,

Jim Piller

 

Jim,

Thanks for the feedback and I'm glad the post is helpful.

I don't have any repo with the full picture of this implementation, but I'm planning on writing a follow up about generating URLs when using NodeAliasPath routing. Maybe I could put an example online then.

Was there a specific bit of code you were unsure about?

There shouldn't be too many pieces in then end...

  1. NodeAliasPathConstraint
  2. PageTypeRoute
  3. Static PageTypeRouteAttributeCacheHelper class to do path -> Controller/Action name lookup

You can define these anywhere, though Kentico uses an \Infrastructure\ folder at the root of their MVC codebase and puts these cross-cutting or framework code pieces in there - I think that's a good pattern.

 

That's a really good approach, especially with the use of adding an attribute onto the controller itself. I'll tweak my original long winded approach that I first used when Kentico had full MVC support (Kentico 9), which I have refactored recently for use in Kentico 12.

But looking forward to Kentico adding dynamic routing in Kentico 2020!

 

Glad you found this useful. Your blog contains lots of great content!

There is a Kentico slack channel kentico-community.slack.com. You should join and have your blog added to the syndication feed channel there.

Somme other Kentico devs and I were having a discussion on Twitter about this topic - you might find some of their insights helpful as well.