This post is a follow up to to 2 posts: Find/Replace on Render – an MVC alternative to Response Filters and Response.Flush and the crimes against Page Caching.
In the first post I explored a way to parse special tags before rendering a page. I'm working on a CMS that accepts special tags inside content. Those tags are then replaced on render. For example, an author might place a tag like:
<customamazonbestsellers count="5"/>
to display the 5 best selling products on Amazon (we don't support this but you get the point).
I came up with a solution but quickly ran into an issue with caching. In fact, the solution caused Page Caching to be effectively disabled which is very, very bad.
There are a number of solutions to this issue. I'll run through 2 of them and explain why I chose to implement the one I did.
First, the solution I did not implement:
Override Page Render
The issue with Page Caching was caused by the way I was parsing through the page's output. Inside the Render
method of my custom IView
, I had a routine that flushed the Response to a MemoryStream, parsed through that, and then wrote the result back to Response. This is what caused the Page Cache issue but it's also what allowed me to do the parsing in one shot.
A single request can mean rendering a half dozen pages. You might have a Master and a Nested Master page (that makes 2). You might have your main page (which makes 3) that has a bunch of RenderPartial
and/or RenderAction
calls (which can add 1 or more per call).
Overriding Page Render would mean that each time any of these is rendered I would first render to string, then parse out the tags, then add the result to the Response. However, that would mean that every one of my pages would have to Inherit from MySpecialViewPage
instead of System.Web.Mvc.ViewPage
. This creates a bit of a tooling issue since Visual Studio automatically creates Views that inherit from System.Web.Mvc.ViewPage
. Another reason I didn't like this approach was that it felt a little dirty requiring each page to be responsible for rendering its own custom tags.
Back to Response Filters
There is a built in way of manipulating output in ASP.NET. It's called Response Filters. A Response Filter is just a Stream that wraps the Response Output so that you can manipulate the data before sending it downstream. Our original solution used Response Filters to render these special tags. The reason I wanted a different solution in the first place was because we were outputting HTML from the Response Filter Stream (in code, using an HtmlTextWriter!) which is very ugly and unwieldy. It was very painful to create a new tag or change an existing one!
The solution I came up with is the best of both worlds. I'm using most of the code from my original IView-based solution (first link above), but instead of parsing the output on Render (which created the Page Caching issue), I do it inside a Response Filter (which was created expressly for this purpose). And when I encounter a custom tag? I have an ASPX page designed to handle just that tag and I use Server.Execute to let the ASP.NET runtime render that “template” page. BTW, Rick Strahl has a terrific write-up on Server.Execute on his blog.
And now for the code :)
The Reponse Filter (look at the RenderCustomTags method. I removed some logging and other misc stuff):
///
/// This is a generic class that is used to parse all CustomTags
/// It will search for all CustomTagsin the output of an ASPX
/// page and, if there is a Template defined in the TagParsers
/// it renders that template (passing the parameters in the QueryString)
/// it makes MAX_PASSES passes over the HTML to account for
/// CustomTags that output other CustomTags
///
class CustomTagParser : Stream
{
const int MAX_PASSES = 20;
private Stream _OutputStream;
private MemoryStream _BufferStream;
public CustomTagParser(Stream outputStream)
{
_OutputStream = outputStream;
_BufferStream = new MemoryStream();
}
public override bool CanRead
{
get { return false; }
}
public override bool CanSeek
{
get { return false; }
}
public override bool CanWrite
{
get { return true; }
}
public override void Flush()
{
string html;
_BufferStream.Position = 0;
using (StreamReader reader = new StreamReader(_BufferStream, ASCIIEncoding.UTF8))
{
html = reader.ReadToEnd();
}
html = RenderCustomTags(html);
byte[] utf8Bytes = ASCIIEncoding.UTF8.GetBytes(html);
_OutputStream.Write(utf8Bytes, 0, utf8Bytes.Length);
_OutputStream.Flush();
}
private static Dictionary<string, string> GetTagParameters(string tag)
{
Dictionary<string, string> paramDictionary = new Dictionary<string, string>();
// Get param/value pairs
Regex parameterRegex = new Regex("([a-z0-9]+)=\"([\\w\\s#|]*)\"", RegexOptions.IgnoreCase);
foreach (Match paramMatch in parameterRegex.Matches(tag))
{
string paramName = paramMatch.Groups[1].Value;
string paramValue = paramMatch.Groups[2].Value;
paramDictionary.Add(paramName.ToLower(), paramValue);
}
// Get params with no value pair (e.g. "notable" in )
Regex novalueRegex = new Regex("\\s\\b([a-z0-9]+)\\b(?!=)", RegexOptions.IgnoreCase);
foreach (Match paramMatch in novalueRegex.Matches(tag))
{
string paramName = paramMatch.Groups[1].Value;
paramDictionary.Add(paramName.ToLower(), null);
}
return paramDictionary;
}
private static string GetPageAndQueryString(string templateLocation, string innerText, Dictionary<string, string> parameters)
{
StringBuilder pageAndQueryString = new StringBuilder();
pageAndQueryString.Append(templateLocation);
pageAndQueryString.Append("?innerText=");
pageAndQueryString.Append(HttpUtility.UrlEncode(innerText));
foreach (var param in parameters)
{
pageAndQueryString.Append("&");
pageAndQueryString.Append(HttpUtility.UrlEncode(param.Key));
pageAndQueryString.Append("=");
pageAndQueryString.Append(HttpUtility.UrlEncode(param.Value));
}
return pageAndQueryString.ToString();
}
private string RenderCustomTags(string html)
{
StringWriter writer = null;
Regex customTagRegex = new Regex("<(CUSTOM[A-Z0-9]*)([^>]*)>((.*?))?", RegexOptions.IgnoreCase);
for (int i = 0; i <= MAX_PASSES; i++)
{
int currentOffset = 0;
writer = new StringWriter();
MatchCollection customTagMatches = customTagRegex.Matches(html);
if (customTagMatches.Count == 0)
break; // no need to keep going
foreach (Match match in customTagMatches)
{
// Get tag info
string fullTag = match.Value;
string tagName = match.Groups[1].Value;
string innerText = string.Empty;
string parameterString = match.Groups[2].Value;
if (match.Groups.Count == 5 /* has closing tag */)
innerText = match.Groups[4].Value;
// Get template location
string templateLocation;
if (!TagParsers.TryGetValue(tagName, out templateLocation))
continue; // No template defined for this tag
// Write text before this tag
writer.Write(html.Substring(currentOffset, match.Index - currentOffset));
Dictionary<string, string> parameters = GetTagParameters(parameterString);
string pageAndQueryString = GetPageAndQueryString(templateLocation, innerText, parameters);
try
{
HttpContext.Current.Server.Execute(pageAndQueryString.ToString(), writer);
}
catch (Exception ex)
{
// Some logging
}
finally
{
// Update current offset before next pass
currentOffset = match.Index + match.Length;
}
}
// Write remainder of html to writer
writer.Write(html.Substring(currentOffset));
html = writer.ToString();
}
return html;
}
public override long Length
{
get { return _BufferStream.Length; }
}
public override long Position
{
get
{
throw new NotImplementedException();
}
set
{
throw new NotImplementedException();
}
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotImplementedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotImplementedException();
}
public override void SetLength(long value)
{
throw new NotImplementedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
_BufferStream.Write(buffer, offset, count);
}
}
I created a static class called TagParsers that basically acts as a case-insensitive dictionary (so that tags are found regardless of cASe):
public static class TagParsers
{
private static IDictionary<string, string> _TagParsers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
#region IDictionary<string,string> Members
public static void Add(string tagName, string relativeTemplateLocation)
{
_TagParsers.Add(tagName, relativeTemplateLocation);
}
public static bool ContainsKey(string tagName)
{
return _TagParsers.ContainsKey(tagName);
}
public static ICollection<string> Keys
{
get { return _TagParsers.Keys; }
}
public static bool Remove(string tagName)
{
return _TagParsers.Remove(tagName);
}
public static bool TryGetValue(string tagName, out string relativeTemplateLocation)
{
return _TagParsers.TryGetValue(tagName, out relativeTemplateLocation);
}
public static ICollection<string> Values
{
get { return _TagParsers.Values; }
}
#endregion
}
And the last piece of the puzzle was adding all of my custom tags to my TagParsers dictionary (inside Global.asax):
TagParsers.Add("CustomAmazonBestSellers", "~/CustomTagTemplates/AmazonBestSellers.aspx");
The AmazonBestSellers.aspx page has code-behind that just interprets the QueryString parameters (in the fictional Amazon best-selling case above, "count=5")
And just for the sake of completion, attaching the Response Filter goes something like this (don't forget to add the HttpModule it to the Web.Config):
class CustomTagParserHttpModule : System.Web.IHttpModule
{
...
void System.Web.IHttpModule.Init(System.Web.HttpApplication context)
{
context.PostRequestHandlerExecute += new EventHandler(context_PostRequestHandlerExecute);
}
void context_PostRequestHandlerExecute(object sender, EventArgs e)
{
HttpApplication application = (HttpApplication)sender;
application.Response.Filter = new CustomTagParser(application.Response.Filter);
}
}