Skip to main content

Fixing the Asp.Net MVC 3 OutputCacheAttribute for Partial Views to Honor some web config settings

Greg Roberts

Greg Roberts

Greg Roberts

While working today I thought I would try out caching a partial view action in my controller. Seems like an easy enough thing and was described as one of the nice new features of MVC 3 release. I quickly ran into issues in my assumption of how this feature would work, and went to the Usual Suspects for trying to figure out what happened.

Now lets get one thing out… The feature works, but it has a couple hidden gotchas that took a while to figure out.

Gotcha #1#

The only supported options are Duration and VaryByParam. This seems reasonable at first, but there is a big one missing which is CacheProfile. Taken from the MSDN site itself, and I quote:

Using a Cache Profile To avoid code duplication, you can set a cache profile in the Web.config file instead of setting cache values individually in pages. You can then reference the profile by using theCacheProfile property of the OutputCache attribute. The following example shows a section of a Web.config file that sets a cache profile. This cache profile will apply to all pages unless the page overrides these settings.

<system.web>
<caching>
<outputCacheSettings>
<outputCacheProfiles>
<add name="MyProfile" duration="60" varyByParam="*" />
</outputCacheProfiles>
</outputCacheSettings>
</caching>
</system.web>

Seems like the right way to go, who would want to hard code all of those values in the attribute if you could modify them in the web.config... Sold. If you try this in your controller on a partial view or “child action”, you will get a very nice error saying that the “Duration must be a positive number”. Um ok.

Gotcha #2#

When using this feature it will not honor the outputCache setting enableOutputCache. But Greg, didn’t you just say you wanted that controller cached, why would you turn it off? Well in short, I want it all off in debug mode, but I want it enabled everywhere else. This is perfect example of a setting you modify in a web.config transform.

What’s even more frustrating is that all these settings are enabled for normal controller actions, just not partial views. Of course, I understand that there is a big difference, but you are using the same attribute.

Ok, Ok, so what do I do if I want to enable cacheprofiles and the enableOutputCache setting? Well, crack open the MVC source and prepare for writing your own version of the OutputCacheAttribute…

I’m going to show just the changes that enable this setting, and then I’ll post the whole class for you. Keep in mind some of the classes in the source were internal and so we had to copy those too. If I had more time maybe this could be done a different way, but I just want it to work:

public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
var configSection = (OutputCacheSection)ConfigurationManager.GetSection("system.web/caching/outputCache");
if (configSection.EnableOutputCache)
{
if (filterContext.IsChildAction)
{
ValidateChildActionConfiguration();

Here we are just adding the check if caching is enabled or not. Next we will modify the validate configuration method:

private void ValidateChildActionConfiguration()
{
if (!String.IsNullOrWhiteSpace(CacheProfile))
{
var cacheSettings =
(OutputCacheSettingsSection)
ConfigurationManager.GetSection("system.web/caching/outputCacheSettings");
if ((cacheSettings != null) && (cacheSettings.OutputCacheProfiles.Count > 0))
{
var profile = cacheSettings.OutputCacheProfiles[CacheProfile];
if (profile == null)
{
throw new HttpException("Cache Profile Not found");
}
if (!string.IsNullOrWhiteSpace(profile.SqlDependency) ||
!String.IsNullOrWhiteSpace(profile.VaryByContentEncoding) ||
!string.IsNullOrWhiteSpace(profile.VaryByControl) ||
!String.IsNullOrWhiteSpace(profile.VaryByCustom) || profile.NoStore)
{
throw new InvalidOperationException("OutputCacheAttribute ChildAction UnsupportedSetting");
}
//overwrite the parameters
VaryByParam = profile.VaryByParam;
Duration = profile.Duration;
}
else
{
throw new HttpException("Cache settings and profile not found");
}
if (!String.IsNullOrWhiteSpace(SqlDependency) ||
!String.IsNullOrWhiteSpace(VaryByContentEncoding) ||
!String.IsNullOrWhiteSpace(VaryByHeader) ||
_locationWasSet || _noStoreWasSet)
{
throw new InvalidOperationException("OutputCacheAttribute ChildAction UnsupportedSetting");
if (Duration <= 0)
{
throw new InvalidOperationException("Invalid Duration");
if (String.IsNullOrWhiteSpace(VaryByParam))
{
throw new InvalidOperationException("Invalid vary by param");
}
}

Key point here is that we first try to get the options from a profile if it is set, and if we don’t find it or it’s using unsupported attributes then we throw. The other issue is that we reversed the checking for invalid properties followed by required properties, this was the main issue in the original source that would give you bad exceptions.


Final Solution#

And here is the final class, now it still has the limitations of only allowing Duration and VaryByParam, but it allows the settings to be pulled from the web.config and most importantly it honors whether caching is on or not.

[SuppressMessage("Microsoft.Performance", "CA1813:AvoidUnsealedAttributes", Justification = "Unsealed so that subclassed types can set properties in the defaultconstructor.")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
public class CustomOutputCacheAttribute : ActionFilterAttribute, IExceptionFilter
{
private OutputCacheParameters _cacheSettings = new OutputCacheParameters { VaryByParam = "*" };
private const string _cacheKeyPrefix = "_MvcChildActionCache_";
private static ObjectCache _childActionCache;
private Func<ObjectCache> _childActionCacheThunk = () => ChildActionCache;
private static object _childActionFilterFinishCallbackKey = new object();
private bool _locationWasSet;
private bool _noStoreWasSet;
public CustomOutputCacheAttribute()
{
}
internal CustomOutputCacheAttribute(ObjectCache childActionCache)
{
_childActionCacheThunk = () => childActionCache;
}
public string CacheProfile
{
get
{
return _cacheSettings.CacheProfile ?? String.Empty;
}
set
{
_cacheSettings.CacheProfile = value;
}
}
internal OutputCacheParameters CacheSettings
{
get
{
return _cacheSettings;
}
}
public static ObjectCache ChildActionCache
{
get
{
return _childActionCache ?? MemoryCache.Default;
}
set
{
_childActionCache = value;
}
}
private ObjectCache ChildActionCacheInternal
{
get
{
return _childActionCacheThunk();
}
}
public int Duration
{
get
{
return _cacheSettings.Duration;
}
set
{
_cacheSettings.Duration = value;
}
}
public OutputCacheLocation Location
{
get
{
return _cacheSettings.Location;
}
set
{
_cacheSettings.Location = value;
_locationWasSet = true;
}
}
public bool NoStore
{
get
{
return _cacheSettings.NoStore;
}
set
{
_cacheSettings.NoStore = value;
_noStoreWasSet = true;
}
}
public string SqlDependency
{
get
{
return _cacheSettings.SqlDependency ?? String.Empty;
}
set
{
_cacheSettings.SqlDependency = value;
}
}
public string VaryByContentEncoding
{
get
{
return _cacheSettings.VaryByContentEncoding ?? String.Empty;
}
set
{
_cacheSettings.VaryByContentEncoding = value;
}
}
public string VaryByCustom
{
get
{
return _cacheSettings.VaryByCustom ?? String.Empty;
}
set
{
_cacheSettings.VaryByCustom = value;
}
}
public string VaryByHeader
{
get
{
return _cacheSettings.VaryByHeader ?? String.Empty;
}
set
{
_cacheSettings.VaryByHeader = value;
}
}
[SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Param", Justification = "Matches the @ OutputCache page directive.")]
public string VaryByParam
{
get
{
return _cacheSettings.VaryByParam ?? String.Empty;
}
set
{
_cacheSettings.VaryByParam = value;
}
}
private static void ClearChildActionFilterFinishCallback(ControllerContext controllerContext)
{
controllerContext.HttpContext.Items.Remove(_childActionFilterFinishCallbackKey);
}
private static void CompleteChildAction(ControllerContext filterContext, bool wasException)
{
Action<bool> callback = GetChildActionFilterFinishCallback(filterContext);
if (callback != null)
{
ClearChildActionFilterFinishCallback(filterContext);
callback(wasException);
}
}
private static Action<bool> GetChildActionFilterFinishCallback(ControllerContext controllerContext)
{
return controllerContext.HttpContext.Items[_childActionFilterFinishCallbackKey] as Action<bool>;
}
internal string GetChildActionUniqueId(ActionExecutingContext filterContext)
{
StringBuilder uniqueIdBuilder = new StringBuilder();
// Start with a prefix, presuming that we share the cache with other users
uniqueIdBuilder.Append(_cacheKeyPrefix);
// Unique ID of the action description
uniqueIdBuilder.Append(filterContext.ActionDescriptor.UniqueId);
// Unique ID from the VaryByCustom settings, if any
uniqueIdBuilder.Append(MyDescriptorUtil.CreateUniqueId(VaryByCustom));
if (!String.IsNullOrEmpty(VaryByCustom))
{
string varyByCustomResult = filterContext.HttpContext.ApplicationInstance.GetVaryByCustomString(HttpContext.Current, VaryByCustom);
uniqueIdBuilder.Append(varyByCustomResult);
}
// Unique ID from the VaryByParam settings, if any
uniqueIdBuilder.Append(GetUniqueIdFromActionParameters(filterContext, SplitVaryByParam(VaryByParam)));
// The key is typically too long to be useful, so we use a cryptographic hash
// as the actual key (better randomization and key distribution, so small vary
// values will generate dramtically different keys).
using (SHA256 sha = SHA256.Create())
{
return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(uniqueIdBuilder.ToString())));
}
}
private static string GetUniqueIdFromActionParameters(ActionExecutingContext filterContext, IEnumerable<string> keys)
{
// Generate a unique ID of normalized key names + key values
var keyValues = new Dictionary<string, object>(filterContext.ActionParameters, StringComparer.OrdinalIgnoreCase);
keys = (keys ?? keyValues.Keys).Select(key => key.ToUpperInvariant())
.OrderBy(key => key, StringComparer.Ordinal);
return MyDescriptorUtil.CreateUniqueId(keys.Concat(keys.Select(key => keyValues.ContainsKey(key) ? keyValues[key] : null)));
}
public static bool IsChildActionCacheActive(ControllerContext controllerContext)
{
return GetChildActionFilterFinishCallback(controllerContext) != null;
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
// Complete the request if the child action threw an exception
if (filterContext.IsChildAction && filterContext.Exception != null)
{
CompleteChildAction(filterContext, wasException: true);
}
}
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
var configSection = (OutputCacheSection)ConfigurationManager.GetSection("system.web/caching/outputCache");
if (configSection.EnableOutputCache)
{
if (filterContext.IsChildAction)
{
ValidateChildActionConfiguration();
// Already actively being captured? (i.e., cached child action inside of cached child action)
// Realistically, this needs write substitution to do properly (including things like authentication)
if (GetChildActionFilterFinishCallback(filterContext) != null)
{
throw new InvalidOperationException("Cannot nest child cache");
}
// Already cached?
string uniqueId = GetChildActionUniqueId(filterContext);
string cachedValue = ChildActionCacheInternal.Get(uniqueId) as string;
if (cachedValue != null)
{
filterContext.Result = new ContentResult() { Content = cachedValue };
return;
}
// Swap in a new TextWriter so we can capture the output
StringWriter cachingWriter = new StringWriter(CultureInfo.InvariantCulture);
TextWriter originalWriter = filterContext.HttpContext.Response.Output;
filterContext.HttpContext.Response.Output = cachingWriter;
// Set a finish callback to clean up
SetChildActionFilterFinishCallback(filterContext, wasException =>
{
// Restore original writer
filterContext.HttpContext.Response.Output = originalWriter;
// Grab output and write it
string capturedText = cachingWriter.ToString();
filterContext.HttpContext.Response.Write(capturedText);
// Only cache output if this wasn't an error
if (!wasException)
{
ChildActionCacheInternal.Add(uniqueId, capturedText, DateTimeOffset.UtcNow.AddSeconds(Duration));
}
});
}
}
}
public void OnException(ExceptionContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
if (filterContext.IsChildAction)
{
CompleteChildAction(filterContext, wasException: true);
}
}
public override void OnResultExecuting(ResultExecutingContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
if (!filterContext.IsChildAction)
{
// we need to call ProcessRequest() since there's no other way to set the Page.Response intrinsic
using (OutputCachedPage page = new OutputCachedPage(_cacheSettings))
{
page.ProcessRequest(HttpContext.Current);
}
}
}
public override void OnResultExecuted(ResultExecutedContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
if (filterContext.IsChildAction)
{
CompleteChildAction(filterContext, wasException: filterContext.Exception != null);
}
}
private static void SetChildActionFilterFinishCallback(ControllerContext controllerContext, Action<bool> callback)
{
controllerContext.HttpContext.Items[_childActionFilterFinishCallbackKey] = callback;
}
private static IEnumerable<string> SplitVaryByParam(string varyByParam)
{
if (String.Equals(varyByParam, "none", StringComparison.OrdinalIgnoreCase))
{ // Vary by nothing
return Enumerable.Empty<string>();
}
if (String.Equals(varyByParam, "*", StringComparison.OrdinalIgnoreCase))
{ // Vary by everything
return null;
}
return from part in varyByParam.Split(';') // Vary by specific parameters
let trimmed = part.Trim()
where !String.IsNullOrEmpty(trimmed)
select trimmed;
}
private void ValidateChildActionConfiguration()
{
if (!String.IsNullOrWhiteSpace(CacheProfile))
{
var cacheSettings =
(OutputCacheSettingsSection)
ConfigurationManager.GetSection("system.web/caching/outputCacheSettings");
if ((cacheSettings != null) && (cacheSettings.OutputCacheProfiles.Count > 0))
{
var profile = cacheSettings.OutputCacheProfiles[CacheProfile];
if (profile == null)
{
throw new HttpException("Cache Profile Not found");
}
if (!string.IsNullOrWhiteSpace(profile.SqlDependency) ||
!String.IsNullOrWhiteSpace(profile.VaryByContentEncoding) ||
!string.IsNullOrWhiteSpace(profile.VaryByControl) ||
!String.IsNullOrWhiteSpace(profile.VaryByCustom) || profile.NoStore)
{
throw new InvalidOperationException("OutputCacheAttribute ChildAction UnsupportedSetting");
}
//overwrite the parameters
VaryByParam = profile.VaryByParam;
Duration = profile.Duration;
}
else
{
throw new HttpException("Cache settings and profile not found");
}
}
if (!String.IsNullOrWhiteSpace(SqlDependency) ||
!String.IsNullOrWhiteSpace(VaryByContentEncoding) ||
!String.IsNullOrWhiteSpace(VaryByHeader) ||
_locationWasSet || _noStoreWasSet)
{
throw new InvalidOperationException("OutputCacheAttribute ChildAction UnsupportedSetting");
}
if (Duration <= 0)
{
throw new InvalidOperationException("Invalid Duration");
}
if (String.IsNullOrWhiteSpace(VaryByParam))
{
throw new InvalidOperationException("Invalid vary by param");
}
}
private sealed class OutputCachedPage : Page
{
private OutputCacheParameters _cacheSettings;
public OutputCachedPage(OutputCacheParameters cacheSettings)
{
// Tracing requires Page IDs to be unique.
ID = Guid.NewGuid().ToString();
_cacheSettings = cacheSettings;
}
protected override void FrameworkInitialize()
{
// when you put the <%@ OutputCache %> directive on a page, the generated code calls InitOutputCache() from here
base.FrameworkInitialize();
InitOutputCache(_cacheSettings);
}
}
}
internal static class MyDescriptorUtil
{
private static void AppendPartToUniqueIdBuilder(StringBuilder builder, object part)
{
if (part == null)
{
builder.Append("[-1]");
}
else
{
string partString = Convert.ToString(part, CultureInfo.InvariantCulture);
builder.AppendFormat("[{0}]{1}", partString.Length, partString);
}
}
public static string CreateUniqueId(params object[] parts)
{
return CreateUniqueId((IEnumerable<object>)parts);
}
public static string CreateUniqueId(IEnumerable<object> parts)
{
// returns a unique string made up of the pieces passed in
StringBuilder builder = new StringBuilder();
foreach (object part in parts)
{
// We can special-case certain part types
MemberInfo memberInfo = part as MemberInfo;
if (memberInfo != null)
{
AppendPartToUniqueIdBuilder(builder, memberInfo.Module.ModuleVersionId);
AppendPartToUniqueIdBuilder(builder, memberInfo.MetadataToken);
continue;
}
ICustomUniquelyIdentifiable uniquelyIdentifiable = part as ICustomUniquelyIdentifiable;
if (uniquelyIdentifiable != null)
{
AppendPartToUniqueIdBuilder(builder, uniquelyIdentifiable.UniqueId);
continue;
}
AppendPartToUniqueIdBuilder(builder, part);
}
return builder.ToString();
}
public static TDescriptor[] LazilyFetchOrCreateDescriptors<TReflection, TDescriptor>(ref TDescriptor[] cacheLocation, Func<TReflection[]> initializer,Func<TReflection, TDescriptor> converter)
{
// did we already calculate this once?
TDescriptor[] existingCache = Interlocked.CompareExchange(ref cacheLocation, null, null);
if (existingCache != null)
{
return existingCache;
}
TReflection[] memberInfos = initializer();
TDescriptor[] descriptors = memberInfos.Select(converter).Where(descriptor => descriptor != null).ToArray();
TDescriptor[] updatedCache = Interlocked.CompareExchange(ref cacheLocation, descriptors, null);
return updatedCache ?? descriptors;
}
}
internal interface ICustomUniquelyIdentifiable
{
string UniqueId { get; }
}