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.
The case where we would need
NodeGuid
to uniquely identify a page is if there are multipleArticle (MVC)
pages under different parents but with the same name ({pageAlias}
), as{pageAlias}
meansNodeAlias
- 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:
- We are using Convention Based Routing, but it's looking more like Configuration Based.
- Our URL patterns are not configurable by the content editors in the CMS - the
/Articles/
segment of the URL pattern above is hard-coded. - We need to insert a
Guid
value into the URL to make it unique enough for the MVC application to find the rightArticle (MVC)
instance -Guid
s 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 usingNodeGuid
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:
- β
Configure a Route that matches any
NodeAliasPath
. - β
Only have our Route match for the given
NodeAliasPath
if the Page associated with it is an instance of a specific Page Type. - β
Have this Route connected to a specific
Controller
andAction
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:
- There exists a page in the CMS that has a
TreeNode.NodeAliasPath
matching ournodeAliasPath
captured by the route parameters for the current request. - That page has a
TreeNode.NodeClassName
which matches the types of pages our Route can handle, as defined bynodeClassName
.
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 withPageTypeRouteAttribute
s, and finally adds a ActionName/ControllerNamestruct
to aDictionary
with thePageTypeRouteAttribute.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. UsingNodeAliasPath
can also simplify the query we'd need to source the sitemap data - the URL, image, and modification date could all come from the sameDocumentQuery
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:
Top comments (7)
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.
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).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.
Kentico 12: Design Patterns Part 13 - Generating Page URLs
Sean G. Wright γ» Sep 16 γ» 9 min read
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...
NodeAliasPathConstraint
PageTypeRoute
PageTypeRouteAttributeCacheHelper
class to do path -> Controller/Action name lookupYou 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.