DEV Community

Cover image for Bits of Xperience: The Hidden Cost of IPageUrlRetriever.Retrieve
Sean G. Wright for WiredViews

Posted on

Bits of Xperience: The Hidden Cost of IPageUrlRetriever.Retrieve

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; }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 the WhereIn(string columnName, ICollection<string> values) method defined on WhereConditionBase to query for all TreeNode objects that match set of nodeAliasPath 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 ๐Ÿ˜Ž!

Ship falling into the water

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);
Enter fullscreen mode Exit fullscreen mode

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 TreeNodes ๐Ÿคฉ.

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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


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.

#kentico

#xperience

Or my Kentico Xperience blog series, like:

Oldest comments (0)