Extending ASP.NET MVC OutputCache ActionFilterAttribute - Adding substitution

Edit on GitHub

In my previous blog post on ASP.NET MVC OutputCache, not all aspects of "classic" ASP.NET output caching were covered. For instance, substitution of cached pages. Allow me to explain...

When using output caching you might want to have everything cached, except, for example, a user's login name or a time stamp. When caching a full HTTP response, it is not really possible to inject dynamic data. ASP.NET introduced the Substitution control, which allows parts of a cached response to be dynamic. The contents of the Substitution control are dynamically injected after retrieving cached data, by calling a certain static method which returns string data. Now let's build this into my OutputCache ActionFilterAttribute...

UPDATE: Also check Phil Haack's approach to this: http://haacked.com/archive/2008/11/05/donut-caching-in-asp.net-mvc.aspx

1. But... how?

Schematically, the substitution process would look like this:

ASP.NET MVC OutputCache

When te view is rendered, it outputs a special substitution tag (well, special... just a HTML comment which will be recognized by the OutputCache). The OutputCache will look for these substitution tags and call the relevant methods to provide contents. A substitution tag will look like <!--SUBSTITUTION:CLASSNAME:METHODNAME-->.

One side note: this will only work with server-side caching (duh!). Client-side could also be realized, but that would involve some Ajax calls.

2. Creating a HtmlHelper extension method

Every developer loves easy-to-use syntax, so instead of writing an error-prone HTML comment like <!--SUBSTITUTION:CLASSNAME:METHODNAME-->. myself, let's do that using an extension method which allows syntax like <%=Html.Substitution<MvcCaching.Views.Home.Index>("SubstituteDate")%>. Here's an example view:

[code:c#]

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
    AutoEventWireup="true" CodeBehind="Index.aspx.cs"
    Inherits="MvcCaching.Views.Home.Index" %>
<%@ Import Namespace="MaartenBalliauw.Mvc.Extensions" %>

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">
    <h2><%= Html.Encode(ViewData["Message"]) %></h2>

    <p>
        Cached timestamp: <%=Html.Encode(DateTime.Now.ToString())%>
    </p>

    <p>
        Uncached timestamp (substitution):
        <%=Html.Substitution<MvcCaching.Views.Home.Index>("SubstituteDate")%>
    </p>
</asp:Content>

[/code]

The extension method for this will look quite easy. Create a new static class containing this static method:

[code:c#]

public static class CacheExtensions
{
    public static string Substitution<T>(this HtmlHelper helper, string method)
    {
        // Check input
        if (typeof(T).GetMethod(method, BindingFlags.Static | BindingFlags.Public) == null)
        {
            throw new ArgumentException(
                string.Format("Type {0} does not implement a static method named {1}.",
                    typeof(T).FullName, method),
                        "method");
        }

        // Write output
        StringBuilder sb = new StringBuilder();

        sb.Append("<!--");
        sb.Append("SUBSTITUTION:");
        sb.Append(typeof(T).FullName);
        sb.Append(":");
        sb.Append(method);
        sb.Append("-->");

        return sb.ToString();
    }
}

[/code]

What happens is basically checking for the existance of the specified class and method, and rendering the appropriate HTML comment. Our example above will output <!--SUBSTITUTION:MvcCaching.Views.Home.Index:SubstituteDate-->.

One thing to do before substituting data though: defining the SubstituteDate method on the MvcCaching.Views.Home.Index: view codebehind. The signature of this method should be static, returning a string and accepting a ControllerContext parameter. In developer language: static string MyMethod(ControllerContext context);

Here's an example:

[code:c#]

public partial class Index : ViewPage
{
    public static string SubstituteDate(ControllerContext context)
    {
        return DateTime.Now.ToString();
    }
}

[/code]

3. Extending the OutputCache ActionFilterAttribute

Previously, we did server-side output caching by implementing 2 overrides of the ActionFilterAttribute, namely OnResultExecuting and OnResultExecuted. To provide substitution support, we'll have to modify these 2 overloads a little. Basically, just pass all output through the ResolveSubstitutions method. Here's the updated OnResultExecuted overload:

[code:c#]

public override void OnResultExecuted(ResultExecutedContext filterContext)
{
    // Server-side caching?
    if (CachePolicy == CachePolicy.Server || CachePolicy == CachePolicy.ClientAndServer)
    {
        if (!cacheHit)
        {
            // Fetch output
            string output = writer.ToString();

            // Restore the old context
            System.Web.HttpContext.Current = existingContext;

            // Fix substitutions
            output = ResolveSubstitutions(filterContext, output);

            // Return rendered data
            existingContext.Response.Write(output);

            // Add data to cache
            cache.Add(
                GenerateKey(filterContext),
                writer.ToString(),
                null,
                DateTime.Now.AddSeconds(Duration),
                Cache.NoSlidingExpiration,
                CacheItemPriority.Normal,
                null);
        }
    }
}

[/code]

Now how about this ResolveSubstitutions method? This method is passed the ControllerContext and the unmodified HTML output. If no substitution tags are found, it returns immediately. Otherwise, a regular expression is fired off which will perform replaces depending on the contents of this substitution variable.

One thing to note here is that this is actually a nice security hole! Be sure to ALWAYS Html.Encode() dynamic data, as users can inject these substitution tags easily in your dynamic pages and possibly receive useful error messages with context information...

[code:c#]

private string ResolveSubstitutions(ControllerContext filterContext, string source)
{
    // Any substitutions?
    if (source.IndexOf("<!--SUBSTITUTION:") == -1)
    {
        return source;
    }

    // Setup regular expressions engine
    MatchEvaluator replaceCallback = new MatchEvaluator(
        matchToHandle =>
        {
            // Replacements
            string tag = matchToHandle.Value;

            // Parts
            string[] parts = tag.Split(':');
            string className = parts[1];
            string methodName = parts[2].Replace("-->", "");

            // Execute method
            Type targetType = Type.GetType(className);
            MethodInfo targetMethod = targetType.GetMethod(methodName, BindingFlags.Static | BindingFlags.Public);
            return (string)targetMethod.Invoke(null, new object[] { filterContext });
        }
    );
    Regex templatePattern = new Regex(@"<!--SUBSTITUTION:[A-Za-z_\.]+:[A-Za-z_\.]+-->", RegexOptions.Multiline);

    // Fire up replacement engine!
    return templatePattern.Replace(source, replaceCallback);
}

[/code]

How easy was all that? You can download the full soure and an example here.

kick it on DotNetKicks.com

This is an imported post. It was imported from my old blog using an automated tool and may contain formatting errors and/or broken images.

Leave a Comment

avatar

3 responses

  1. Avatar for Troy Goode
    Troy Goode July 1st, 2008

    Very impressive Maarten! This is just what I'm looking for on one of my projects.

  2. Avatar for Steve Sanderson
    Steve Sanderson August 1st, 2008

    Maarten, this is really good, and in some ways more useful than the official built-in [OutputCache] filter introduced in Preview 4.

    However, I think there's a slight mistake! When you use OnRequestExecuting(), you should really be using OnActionExecuting() - otherwise you're running the action method every time, even when it's "cached". I tried you code, changing OnRequestExecuting() to OnActionExecuting(), and it works really well.

    Nice one.

  3. Avatar for Haacked
    Haacked October 18th, 2008

    Slick approach. One thing to note is that if you use the WebFormViewEngine you can use the declarative substitution control. Yes, that would require you to put some code in the code-behind (egads!) and that code would not have access to the ControllerContext, but for really simple donuts where the info you need is all accessible by the HttpContext, that would work.