Improving
Sitefinity Cache

Dec 20

Introduction

Without doubt you should be using the built in cache features of Sitefinity. The difference is significant if you don't. One of the first tweaks is to extend the time in cache for objects. But there are a few more improvements I have found from browsing the web and I have put them together here.

I read a good post on Sitefinity Downunder about Sitefinity Cache. A very smart guy, Stephan Pittman, had written an article about the Sitefinity Cache and a particular problem that I had come across with caching and POST backs. Take a read his post.

Override the default cache rules

I wanted to follow Stephan's advice and avoid any caching action on a post back. He also has a section in his code that stops the default caching per browser. Its rare to require a different cache result for every different browser and version out there. I had already solved this by overriding the PageRouteHandler of Sitefinity from a blog post. So I took Stephans suggestions and added them to my own custom page route handler.

protected override Boolean ApplyServerCache(HttpContextBase context, Telerik.Sitefinity.Modules.Pages.Configuration.OutputCacheProfileElement profile, PageSiteNode siteNode)
{
    Boolean result = false;
    if (context.Request.RequestType != "POST" && !context.Request.QueryString.HasKeys())
    {
        result = base.ApplyServerCache(context, profile, siteNode);
    }
 
    //Need to use reflection as .NET does not allow for setting false value once we've set it to true
    var headers = context.Response.Cache.VaryByHeaders;
    var headersCollection = (NameObjectCollectionBase)headers.GetType().GetField("_headers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(headers);
    if (headersCollection != null)
    {
        var method = headersCollection.GetType().GetMethod("BaseRemove", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        method.Invoke(headersCollection, new string[] { "User-Agent" });
    }
    return result;
}

Thanks to Ole Christian Synnes for advice in his comment below about testing the headersCollection for null to avoid errors being thrown.

Testing now shows that my page runs full life cycle when there is a POST back. Sweet! All seems good.

Cache Substitution

I also looked at Stephans second piece of advice around the the need to create controls which may live on a cached page but shouldn't be cached. I tried it out by putting it into a widget control which inherited from SimpleView. But what happened was that when it ran it would make the whole page un-cached not just the widget I wanted. I suspect this works as expected if you use User Controls as your widgets. But I don't. (Not because it is bad. I just structure all widgets to inherit from the SimpleView.)

So I checked out the CacheSubstitutionWrapper that Sitefinity has provided. This is basically just like the ASP.NET substitution control. The primary issue is that it can only replace a String. So when using it you need to create code which generates all the HTML and deliver it as a String.

So I have 3 options.
Creating User Controls using Stephan's method.
Using a Page.Uncache result which makes the whole page that the widget is placed on un cached overriding any Sitefinity page settings.
Or the CacheSubstitutionWrapper from Sitefinity.

CacheSubstitutionWrapper

It's a bit hard to find an example of how to implement this. The only doco Sitefinity has is to mention that it exists. I had to ask Sitefinity and they, (Atanas - who is a very helpful), pointed me to the LoginStatus widget which uses it. So looking at that code I was able to implement it.

Now my use case was my menu. To generate my menu I create my own custom HTML. Out putting a string, it is created based on the page structure. Most of the time the page structure does not change. So I really only want to rerun the menu build function when the structure changes rather than on every page request. To deal with this I used the Sitefinity cache. I cache the HTML and check for a cached version each time.
Note I needed a different key for each page as the active menu is different on different pages. I used a combo of the page url and a static control Guid to generate a unique hash key. Now when the page runs my cache substitution first checks if the HTML exists on cache. If so it uses that.

One thing to note. When a person returns to a page that is cached the only method that runs is the RenderCacheSubstitutionMarkup. If you need any references you need to load them up in the first page load via the parameters dictionary. (Again, only strings.) These are then available each time. I used it to store the pageUrl which I was using to ID the page and generate the unique hash. I found that I could not use the SiteMapBase.GetActualCurrentNode() method as it always returned null.

Next is to clear the cache when the page structure changes. I subscribe to the Page Publish event. Unfortunately this is not a published event and so I need to create a bit of code to handle the event. But once there, I go through all the pages, create a hash and then remove any keys in the cache. Thus the next time the page loads the menu will be rebuilt according to the new page structure.

Depending on my widgets and data sources I have a several options regarding my caching. Its working really well and as expected for my menus at least.

So there you go. Happy coding.

Widget Init

public String PageUrl { getset; }
 
protected override void InitializeControls(GenericContainer container)
{
    if (!SystemManager.IsDesignMode)
    {
        PageSiteNode page = SiteMapBase.GetActualCurrentNode();
        if (page != null)
        {
            PageUrl = page.Url;
        }
    }
}

Cache Substitution


private static readonly Guid menuId = new Guid("a3b20fe5-14af-466d-9c23-90ba89c26cb9");
private static readonly ICacheManager cache = SystemManager.GetCacheManager(CacheManagerInstance.Global);
 
public String PageUrl { getset; }
 
protected override StuffSimpleView.CacheLevel ControlCacheLevel
{
    get { return CacheLevel.ContentListener; }
}
 
protected override void RenderContents(System.Web.UI.HtmlTextWriter writer)
{
    if (this.ControlCacheLevel != CacheLevel.AlwaysCache)
    {
        Dictionary<StringString> parameters = new Dictionary<StringString>();
        parameters.Add("PageUrl", PageUrl);
 
        CacheSubstitutionWrapper cacheSubstitutionWrapper = new CacheSubstitutionWrapper(parameters, new CacheSubstitutionWrapper.RenderMarkupDelegate(Menu.RenderCacheSubstitutionMarkup));
        cacheSubstitutionWrapper.RegisterPostCacheCallBack(this.Context);
    }
}
 
internal static String RenderCacheSubstitutionMarkup(Dictionary<StringString> parameters)
{
    String menuHtml = String.Empty;
    String pageUrl = parameters["PageUrl"];
 
    var hashKey = Common.CalculateMD5Hash(pageUrl + menuId.ToString());
 
    if(cache.Contains(hashKey))
    {
        menuHtml = (String)cache.GetData(hashKey);
    }
    else
    {
        menuHtml = BuildMenu(pageUrl);
        cache.Add(hashKey, menuHtml);
    }
    return menuHtml;
}

Page Publish Event


public static void PageManagerExecuting(Object sender, Telerik.Sitefinity.Data.ExecutingEventArgs e)
{
    if (e.CommandName == "CommitTransaction")
    {
        // Removed content revelant to finding if this page is being published
        // Find all pages and clear any menu html
        var siteMapProvider = SiteMapBase.GetSiteMapProvider("FrontendSiteMap");
        PageSiteNode nodeToTraverse = (PageSiteNode)siteMapProvider.RootNode;
 
        if (nodeToTraverse.ChildNodes.Count > 0)
        {
            // We are only looking for the top level pages so no need to traverse child pages.
            foreach (var childNode in nodeToTraverse.ChildNodes)
            {
                var node = (PageSiteNode)childNode;
                var hashKey = Common.CalculateMD5Hash(node.Url + menuId.ToString());
                cache.Remove(hashKey);
            }
        }
    }
}

Register Listener


if (e.CommandName == "Bootstrapped")
{
    PageManager.Executing += new EventHandler<Telerik.Sitefinity.Data.ExecutingEventArgs>(Menu.PageManagerExecuting);
}

Darrin Robertson - Sitefinity Developer

Thanks for reading and feel free to comment - Darrin Robertson

If I was really helpful and you would buy me a coffee if you could, yay! You can.


Leave a comment
Load more comments

Make a Comment

recapcha code