Page instance based EPiServer Output Cache

For a client’s website there was a need to implement EPiServer‘s output cache with different expire times not based on PageType, but rather page instance. After weighing pros and cons we decided on a solution with a number of ContentArea properties on the website’s settings page. Pages dropped in each area would then get different cache timeouts depending on property.

Also, thanks to Svante Seleborg and Anders Brangefält for helping out with this.

EPiServer OutputCache for MVC websites

EPiServer’s implementation of the Asp.Net output cache and what you’re supposed to do to use it differs a bit between WebForms and MVC based websites. This may cause head aches if you’re looking through EPiServer’s source code mixing up which code belongs to which.

What EPiServer does for MVC websites is that they’re extending the existing Asp.Net OutputCacheAttribute creating their own ContentOutputCacheAttribute adding a bit of functionality. Mainly, this has to do with the EPiServer page’s StopPublish functionality and applying their own web.config settings such as the httpCacheTimeout parameter.

Note that EPiServer is using a static method (UseOutputCache) to set up the UseOutputCacheValidator in the attribute constructor. This is the only thing that the MVC version of their implementation is using from the OutputCacheHandler class, the rest is for WebForms.

Page instance specific expiration values for EPiServer’s output cache

The first thing we need for our output cache implementation to work is a way of knowing which timeout will be assigned to which content area. For this, let’s create a custom attribute.

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class OutputCacheExpirationAttribute : Attribute
{
  public OutputCacheExpirationAttribute(int expiration)
  {
    Expiration = expiration;
  }

  /// <summary>
  /// Output cache expiration in minutes.
  /// </summary>
  public int Expiration { get; }
}

This attribute may then be placed on ContentArea properties in your ContentType files.

[Display(
  Name = "5 minutes",
  Description = "Pages with 5 minutes output cache.",
  GroupName = SystemTabNames.Content,
  Order = 30)]
[AllowedTypes(typeof(PageData))]
[OutputCacheExpiration(5)]
public virtual ContentArea OutputCache5 { get; set; }

The cache expiration will be extracted through reflection, so the only thing you need to do to add various timeouts is placing attribute on a ContentArea property, and probably limit the types to page datas.

After this, we will need to extend EPiServer’s ContentOutputCacheAttribute creating our own custom one. This is necessary to enforce our cache timeout over EPiServer’s. There are two methods that we will need to override, the OnActionExecuting and OnResultExecuting. They are both fired at different places in the request cycle.

The only thing EPiServer themselves does in the OnActionExecuting method is filtering on child actions. The output cache attribute should not be used on those. For us on the other hand, this is the perfect place to do a bit of set up. The method is called early enough in the life cycle to make an impact, and it’s also guaranteed to be called every uncached request (unlike an attribute’s constructor).

Disregard the OutputCacheService in the below code for now, we will get to that in a minute. What we need to do in the OnActionExecuting method is determining the proper timeout value, and set a few headers. Attributes are passive objects that are not automatically created when decorating methods. Since the OutputCacheAttribute is an action filter attribute however, we can assume that it will be instantiated at least once somewhere up the Asp.Net pipeline. It is important to keep in mind that the same object instance may be shared between multiple requests though.

Due to the way that EPiServer has implemented their attribute (stateful initialization!), we will need to rewrite parts of their code to ensure that the cache will always work.

public class MyContentOutputCacheAttribute : ContentOutputCacheAttribute
{
  public override void OnActionExecuting(ActionExecutingContext filterContext)
  {
    var service = ServiceLocator.Current .GetInstance<IOutputCacheService>();
    var hasCustomExpiration = service .TryGetCustomExpiration(out var customExpiration);
    var defaultExpiration = Convert .ToInt32(this.ConfigurationSettings.Service.HttpCacheExpiration.TotalSeconds);

    // EPiServer's attr initialization forces us to manually consider default expiration.
    this.Duration = hasCustomExpiration && customExpiration < defaultExpiration ? customExpiration : defaultExpiration;

    this.Location = OutputCacheLocation.Server;
    this.NoStore = false;

    base.OnActionExecuting(filterContext);
  }

  public override void OnResultExecuting(ResultExecutingContext filterContext)
  {
    base.OnResultExecuting(filterContext);
    filterContext.HttpContext.Response.Cache .SetRevalidation(HttpCacheRevalidation.AllCaches);
  }
}

For each request going through the method we must consider both our own cache timeout as well as the one specified in the EPiServer httpCacheExpiration attribute in web.config. Using the lesser one for the Asp.Net attribute’s Duration property will ensure the proper timeout. Also, this is where we set the output cache location and no store headers.

The revalidation header needs a somewhat different approach. There is no mechanism to set it up in neither EPiServer’s nor Asp.Net’s attribute classes, so we need to set it ourselves in the OnResultExecuting method.

public interface IOutputCacheService
{
  bool TryGetCustomExpiration(out int expirationSeconds);
}

The TryGetCustomExpiration method in the OutputCacheService will check if the current page is located in any of the ContentArea properties decorated with our attribute. I added a some comments to the code below explaining a few things.

public class OutputCacheService : IOutputCacheService
{
  private readonly IPageRouteHelper _pageRouteHelper;

  public OutputCacheService(IPageRouteHelper pageRouteHelper)
  {
    _pageRouteHelper = pageRouteHelper ?? throw new ArgumentNullException(nameof(pageRouteHelper));
  }

  public bool TryGetCustomExpiration(out int expirationSeconds)
  {
    expirationSeconds = 0;
    var settings = // Find your settings page instance object.
    
    // Finds all ContentAreas with the attribute
    var expirationAreaInfos = settings.GetType().GetProperties()
      .Where(p => Attribute.IsDefined((MemberInfo)p, typeof(OutputCacheExpirationAttribute)))
      .Where(p => typeof(ContentArea).IsAssignableFrom(p.PropertyType));

    int? expirationMinutes = null;
    foreach (var propertyInfo in expirationAreaInfos)
    {
      var area = propertyInfo.GetValue(settings) as ContentArea;
      if (!HasCurrentPageIn(area))
      {
        continue;
      }

      // Extracts the timeout from the attribute if page is found.
      expirationMinutes = Attribute.GetCustomAttributes(propertyInfo)?
        .OfType<OutputCacheExpirationAttribute>()?
        .FirstOrDefault()?
        .Expiration;
      break;
    }

    if (!expirationMinutes.HasValue)
    {
      return false;
    }

    expirationSeconds = expirationMinutes.Value * 60;
    return true;
  }

  // Checks to see if the area contains a reference to the current page.
  private bool HasCurrentPageIn(ContentArea area)
  {
    if (area?.Items == null)
    {
      return false;
    }
    return area.Items.Any(i => i.ContentLink.CompareToIgnoreWorkID(_pageRouteHelper.PageLink));
  }
}

That’s about it. This will give you ContentAreas in which you can drop pages that should have shorter timeouts than what you have specified in the httpCacheExpiration attribute in web.config. All other pages will default to the EPiServer setting.