There's a lot of new, helpful types and methods in Kentico Xperience 13.0... but it can sometimes be difficult to know when each should be used ๐ค.
Let's look at the simple (or is it?) example of the IPageUrlRetriever
interface and its Retrieve()
method.
๐ What Will We Learn?
- What
IPageUrlRetriever
does - The multiple overloads of the
Retrieve()
method - The hidden difference between each overload
- The best way to retrieve Page URLs
๐ฃ What is the IPageUrlRetriever?
Using Kentico Xperience's Content Tree based routing is the option most developers choose. It enables the ability to have some Pages in the Content Tree not participate in URL generation, and also ensures that culture is included in URLs based on the site's settings.
All of this means that generating URLs correctly can get a little tricky ๐ !
Fortunately, Kentico Xperience helps us out by providing the IPageUrlRetriever
interface which has 1 method, Retrieve()
.
This method returns an instance of the PageUrl
type which is defined as:
//
// Encapsulates page relative path and absolute URL.
public class PageUrl
{
public PageUrl();
//
// Relative path (starting with ~/) of the page.
public string RelativePath { get; set; }
//
// Absolute URL of the page.
public string AbsoluteUrl { get; set; }
}
We can use this PageUrl
instance to render links to other Pages in our Razor Views.
However, there are multiple overloads of the Retrieve()
method and they have very different use-cases ๐ฎ.
๐ง๐ฝโโ๏ธ Which Method Do We Choose?
Here are all the overloads of IPageUrlRetriever.Retrieve()
, with their XML doc comments summary included:
// Retrieves URL for given page
PageUrl Retrieve(TreeNode page, bool keepCurrentCulture = true);
// Retrieves URL for the given page in the given culture.
PageUrl Retrieve(TreeNode page, string cultureCode);
// Retrieves URL for a page based on given properties.
PageUrl Retrieve(string nodeAliasPath, bool keepCurrentCulture = true);
// Retrieves URL for a page based on given properties.
PageUrl Retrieve(string nodeAliasPath, string cultureCode, string siteName = null);
It looks like Kentico Xperience is giving us a lot of flexibility here. We can either supply a full TreeNode
instance or, just the nodeAliasPath
.
That's convenient! If we know there's always an /About
Page in the Content Tree, we can make a call like:
PageUrl pageUrl = retriever.Retrieve("/About");
This will give us access to the correctly generated Page URL, and we don't have to query for the TreeNode
, which means one less database round-trip ๐ (or does it?)
So, we probably feel pretty confident ๐ค in using Retrieve(TreeNode node, ...)
when we have the TreeNode
instance anyway, and using Retrieve(string nodeAliasPath, ...)
when we only know where in the Content Tree the Page is or when we get the Node Alias Path from some other Page's field.
โ Retrieve(string) vs Retrieve(TreeNode)
In this situation Xperience asks for what it needs but lets us provide less, however there's no free lunch and the convenience provided to us has a cost ๐คจ!
The IPageUrlRetriever
is implemented by the Kentico.Content.Web.Mvc.PageUrlRetriever
internal class. When we call Retrieve(string nodeAliasPath, ...)
the PageUrlRetriever
uses IPageSystemDataContextRetriever.Retrieve()
internally to get the 'page data' that matches the nodeAliasPath
we provided:
/// <summary>
/// Provides an interface for retrieving the page based on given parameters for system purposes.
/// </summary>
public interface IPageSystemDataContextRetriever
{
// ...
TreeNode Retrieve(SiteInfoIdentifier site, string nodeAliasPath, string cultureCode, bool latest);
}
IPageSystemDataContextRetriever.Retrieve()
is fortunately cached for 10 minutes, so repeated uses of IPageUrlRetrieve.Retrieve(string nodeAliasPath, ...)
won't result in multiple database calls, but the first call absolutely does hit the database because it needs more information, than what we provided, to generate the correct URL.
This is an important point to understand... Kentico Xperience provides many different ways to accomplish the same goal, which is a good thing because we can choose the right one for our use-case. At the same time, if we choose the wrong approach for our use-case, we might end up taking a performance hit we didn't intend ๐ฌ!
How bad can it get? Let's say we are generating URLs for 100 products displayed on a Page using IPageUrlRetriever.Retrieve(string nodeAliasPath, ...)
. This means we are executing at least 100 database queries just to get URLs! Add on to this all the querying we did to get the Product information and images! Ooof ๐!
This is commonly known at the N + 1 Querying Problem and is often seen with Object-Relational Mapping tools like Entity Framework Core or ... Kentico Xperience's APIs ๐.
๐ฅ More Pitfalls ๐ฃ
Let say we've avoided the N + 1 query by using the alternative overload of IPageUrlRetriever.Retrieve(TreeNode node, ...)
so that Kentico Xperience doesn't have to go and fetch all the nodes independently.
If we still have to get our Pages from
string nodeAliasPath
values, we can use theWhereIn(string columnName, ICollection<string> values)
method defined onWhereConditionBase
to query for allTreeNode
objects that match set ofnodeAliasPath
values we have. This would be a big query, but at least it's 1 query and not 100.
Since we've found the correct API for our use-case, we should be all set now, right? git commit
and deploy ๐!
Unfortunately, we could now run into a second problem ๐ซ.
In Kentico Xperience MVC (compared to older Portal Engine sites), the nodeAliasPath
is not the true URL even if parts of it match a URL for a Page. Instead, all generated URL values are stored in the CMS_PageUrlPath
table ๐ค in the database and the actual path is in the PageUrlPathUrlPath
column (what a tongue twister!)
Without this data, we cannot generate valid Page URLs.
This means that when we pass a TreeNode
to IPageUrlRetriever.Retrieve(TreeNode node, ...)
, internally it has to check if the PageUrlPathUrlPath
field is in the TreeNode
's internal data set of field/value pairs. If the value is not populated, then Kentico Xperience has to query the database for it:
PageUrlPathInfo.Provider.Get()
.WhereEquals("PageUrlPathNodeID", page.NodeID)
.WhereEquals("PageUrlPathCulture", cultureCode);
In addition to having this field populated, the culture of the TreeNode
retrieved from the database needs to match the culture of the URL we are trying to generate.
If either the PageUrlPathUrlPath
is missing or the cultures don't match, we have to make yet another database call for the URL data, and as far as I can tell, this query is not cached ๐จ.
So we're in another situation where we could be making an additional 100 database queries (and if we were using nodeAliasPath
that means at least 200 database calls!) to get Page URLs.
๐ Optimal URL Generation
Fortunately, there's a nice extension method WithPageUrlPaths()
, in the CMS.DocumentEngine.Routing
namespace, for IDocumentQuery
that ensures the CMS_PageUrlPath
table is joined when querying for our TreeNode
s ๐คฉ.
If we had a collection of NodeGUID
values (or string nodeAliasPath
values) that referenced Pages in the Content Tree that we wanted to generate URLs for, I think this would be the best approach:
Guid[] linkedNodeGuids = // ...
var linkedDocuments = await DocumentHelper
.GetDocuments<TreeNode>()
.WithPageUrlPaths()
.WhereIn(nameof(TreeNode.NodeGUID), linkNodeGuids)
.GetEnumerableTypedResultAsync(cancellationToken: token);
List<(TreeNode Node, PageUrl URL)> nodesAndURLs = linkedDocuments
.Select(node => (node, urlRetriever.Retrieve(node)))
.ToList();
With our nodesAndURLs
array of tuples we have all the data we need to create links to all those Pages ๐!
Of course, if we instead use Kentico Xperience's IPageRetriever
service, the .WithPageUrlPaths()
extension gets applied for us automatically:
Guid[] linkedNodeGuids = // ...
var linkedDocuments = await pageRetriever.RetrieveAsync<TreeNode>(
q => q.WhereIn(nameof(TreeNode.NodeGUID), linkNodeGuids),
cancellationToken: token);
List<(TreeNode Node, PageUrl URL)> nodesAndURLs = linkedDocuments
.Select(node => (node, urlRetriever.Retrieve(node)))
.ToList();
Isn't that nice ๐!
Conclusion
The convenience of the Kentico Xperience libraries help us developers create the applications our businesses need, quickly and with a lot of flexibility ๐ช๐ฝ.
That flexibility can come at a cost that we might not notice during local development or when a site isn't under heavy load.
Caching helps solve a lot of inevitable performance limitations and mistakes, but it's best if we can make the right choices the first time (especially if its only the difference of 1 method overload vs another ๐).
When trying to get URLs for Pages, especially in bulk, our best choice is to use IPageUrlRetriever.Retrieve(TreeNode node, ...)
and then make sure the TreeNode
being passed was retrieved from the database using a DocumentQuery
that called .WithPageUrlPaths()
with the correct culture... otherwise we might end up causing N + 1 (or worse!) querying against the database.
The IPageRetriever.RetrieveAsync()
method can at least make sure .WithPageUrlPaths()
is applied to our query, so we don't have to remember to do it ๐.
If there are any other APIs you have questions about, let me know in the comments below.
As always, thanks for reading ๐!
References
- Kentico Xperience - Content Tree Routing
- There's No Free Lunch
- N + 1 Querying
- Object-Relational Mapping
- Entity Framework Core
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.
Or my Kentico Xperience blog series, like:
Top comments (0)