Tip/Trick: Implement "Donut Caching" with the ASP.NET 2.0 Output Cache Substitution Feature
Some Background:
One of the most powerful, yet too often under-used, feature areas of ASP.NET is its rich caching infrastructure. ASP.NET's caching features enable you to avoid repeating work on the server for each new request received from clients. Instead, you can generate either html content or data structures once, and then cache/store the results within ASP.NET on the server and re-use them for later web requests. This can dramatically improve performance for your applications, and lower the load on critical backend resources like databases.
Steve Smith wrote a good ASP.NET 1.1 caching article on MSDN a few years ago that covers some of the basics of the ASP.NET 1.1 caching features and provides a good summary of how to use them. If you haven't used ASP.NET caching before, I'd recommend checking it out and giving each feature a try. I'd also highly recommend watching this 15 minute ASP.NET Caching "How Do I" video in the free ASP.NET 2.0 video series to see a live walkthrough of ASP.NET caching in action.
ASP.NET 2.0 has added two very important improvements to the caching feature set that make it even better:
1) SQL Cache Invalidation Support - This enables you to automatically invalidate/re-generate a cached page or data structure when a database table or row it depends on is updated. For example, you can now output cache all of your product listing pages within an e-commerce site - and make sure that anytime that their prices change in the database the pages are immediately re-generated on the next request (and do not show stale pricing data to users).
2) Output Cache Substitution - This nifty feature enables you to implement what I sometimes call "donut caching" -- where you output cache everything on a page except for a few dynamic regions that are contained within cached regions. This enables you to implement full page output caching more aggressively, and not have to split your pages into multiple .ascx user control files to order to implement partial page caching. The below tip/trick tutorial explains the motivation and implementation of this feature better.
Real-World Scenario:
You want to implement a product listing page within your site that lists all products within a given product category. You want to output cache this page so that you don't have to hit the database on each request. You can easily accomplish this by declaratively adding an <%@ OutputCache %> directive to the top of a Products.aspx page that contains an <asp:datalist> control which is databound to product data returned from your middle-tier.
Note below how the page is configured to output cache its contents for 100,000 seconds or until the northwind's products table is updated with new pricing data (in which case it will immediately regenerate the page on the next request). The OutputCache directive also has a "VaryByParam" attribute that tells ASP.NET to store a separate cached version of the page for each unique categoryID (for example: a separate page for Products.aspx?categoryId=1, Products.aspx?categoryId=2, etc).
Products.aspx:
<%@ OutputCache Duration="100000" VaryByParam="CategoryID" SqlDependency="northwind:products" %>
<asp:Content ID="Content1" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
<div class="catalogue">
<asp:DataList ID="DataList1" RepeatColumns="2" runat="server">
<ItemTemplate>
<div class="productimage">
<img src="images/productimage.gif" />
</div>
<div class="productdetails">
<div class="ProductListHead">
<%#Eval("ProductName")%>
</div>
<span class="ProductListItem">
<strong>Price:</strong>
<%# Eval("UnitPrice", "{0:c}") %>
</span>
</div>
</ItemTemplate>
</asp:DataList>
<h3>Generated @ <%=Now.ToLongTimeString()%></h3>
</div>
</asp:Content>
Products.aspx.vb:
Inherits System.Web.UI.Page
Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load
Dim products As New NorthwindTableAdapters.ProductsTableAdapter
DataList1.DataSource = products.GetProductsByCategoryID(Request.QueryString("categoryId"))
DataList1.DataBind()
End Sub
End Class
When accessed by a browser, the below page is returned from the server:
Note that the timestamp at the bottom of the page will only be updated every 100,000 seconds or if the pricing data in the products table has been updated. It will be cached for all the other HTTP requests - allowing us to process 1000s of requests per second on a production server and avoid ever having to hit the database (making things super fast).
Problem:
The one problem we are going to encounter in the above example is with the welcome message and username we output at the top-right of the page (circled in red above). This is currently being generated within our Site.Master master-page file using the new ASP.NET 2.0 <asp:loginname> control like so:
<h1>Caching Samples</h1>
<div class="loginstatus">
<asp:LoginName FormatString="Welcome {0}!" runat="server" />
</div>
</div>
The problem we are going to run into is that because we've added full-page output caching to our page, the username of the first user to hit the site is going to be saved in the cached output from the page - which means that by default the users who hit the site in the 100,000 seconds after that initial request are going to receive back an incorrect welcome message (and worse - an incorrect name!).
Solution:
There are two ways to solve this problem.
The first solution would be to have the overall page be dynamic (so remove the top-level <%@ OutputCache %> directive), and refactor the page contents so that all of the "cacheable" content is encapsulated within ASP.NET User Controls (which are implemented in .ascx files). You'd then add <%@ OutputCache %> directives at the top of each of these .ascx user control files to make them separately cacheable. This avoids you having to hit the database on each request, and ensures that the username is always correctly output (since it is not within a cached user control region). This approach works today with ASP.NET 1.1 and of course can still be done with ASP.NET 2.0.
The downside with this first solution, though, is that it requires us to refactor our code and layout within the page in order to make caching work. If we have only a few places within the page that we want to keep dynamic, this refactoring can be really inconvenient. The good news is that ASP.NET 2.0 has added support for Output Cache Substitution block support that provide a much cleaner way to handle this scenario.
Output Cache Substitution Blocks using the <asp:substitution> control:
Output Cache Substitution blocks enable you to OutputCache an entire page's output -- while leaving a few dynamic region markers to indicate places in the HTML output where you want to dynamically "fill-in" content on later requests (for example: the username message in our sample above). I sometimes call this the "donut caching feature" - since the outer content of a page is all cached, with only a few holes in the middle of the content stream that are dynamic. This is the exact opposite of using user controls with partial page caching - since in the partial page caching case the overall page is dynamic, with cached regions in the middle.
You implement output cache substitution by output caching a page using full page output caching (exactly the same syntax as the Products.aspx code sample above). You can then indicate regions of the page that you want to dynamically fill-in using substitution blocks by adding <asp:substitution> controls to the page like so:
<h1>Caching Samples</h1>
<div class="loginstatus">
<asp:Substitution ID="Substitution1" runat="server" MethodName="LoginText" />
</div>
</div>
The <asp:Substitution> control is unlike any other control in ASP.NET. It registers a callback event with the ASP.NET output-cache that will cause a static method on your page or masterpage to be invoked when the page content is served out on subsequent requests from the ASP.NET Output Cache. This static method will be passed an HttpContext object at runtime that contains the standard ASP.NET Request, Response, User, Server, Session, Application intrinsics, and which you can then use to return a string that ASP.NET will automatically inject into that region of the page before the content is sent back to the client.
For example, to handle the scenario above where we want to dynamically output a welcome message into the output-cached products.aspx page we'd simply add this method to our Site.Master code-behind file and have it be invoked by the <asp:substitution> control above:
Inherits System.Web.UI.MasterPage
Shared Function LoginText(ByVal Context As HttpContext) As String
Return "Hello " & Context.User.Identity.Name
End Function
End Class
Now the entire page will be output cached, except for the contents of the <asp:substitution> control representing the welcome message on the top-right of our page.
We could obviously extend this further if we wanted to include additional personalized information like how many items the user had within their shopping cart, etc. The cool thing is that all other content on the page remains fully cached - and we never have to hit the products database in order to generate it on cached requests (meaning we can process thousands of product pages a second on a single server). In fact, no controls on the page are created during the request, and no code other than the static method above ever runs on later requests - making everything super fast.
Output Cache Substitution Blocks using the Response.WriteSubstitution method:
In addition to using <asp:substitution> controls to indicate replaceable substitution blocks on a page, you can alternatively use the Response.WriteSubstitution method instead. This method takes as a parameter a delegate object to a HttpResponseSubstitutionCallback method that you can implement on any class within your application (it is not limited to only going against static methods on your code-behind class).
The <asp:substitution> control internally uses this method to wire-up delegates in the code-behind classes of pages. You can likewise use it within your own controls or pages for maximum control and flexibility.
Conclusion:
I have yet to find a single ASP.NET application that could not benefit from using the ASP.NET caching features. Because ASP.NET supports full page output caching, partial page output caching, and now donut-level caching - and allows you to vary the cached content based on any parameter or custom logic you want, and now allows you to automatically invalidate/re-generate the cached contents when a database changes, you shouldn't find yourself ever building an application that can't use caching to at least some degree.
I definitely recommend spending time checking all of the ASP.NET caching features out. To find some more caching samples I've done, please download my Tips/Tricks talk from the recent ASP.NET Connections event. Within that presentation I include slides+samples that show how to use full-page caching, partial page caching, substitution block caching, and SQL Cache Invalidation.
For additional ASP.NET Tips/Tricks blog posts of mine, please review my ASP.NET Tips, Tricks and Resources page.
Hope this helps,
Scott