File-Based Cache for Web and non-Web Apps plus Extend ASP.NET 4.0 OutputCacheProvider

Build a File-based Cache for web and non-web applications

Since its first release, ASP.NET has included a powerful in-memory object cache (System.Web.Caching.Cache). The cache implementation has been so popular that it has been used in non-Web applications. However, it may seem awkward for a Windows Forms or WPF application to include a reference to System.Web.dll just to be able to use the ASP.NET object cache.

In Visual Studio 2008 and ASP.NET 2.0, the System.Web.Caching.Cache class is sealed, which makes it impossible to build a custom Cache class that subclasses the ASP.NET Cache. You can still build your own Cache class, but it cannot automatically replace the existing Cache using the Provider Model as we can do with Membership and Roles for example.

To make caching available for all applications, the .NET Framework 4 introduces a new assembly, a new namespace, some base types, and a concrete caching implementation. The new System.Runtime.Caching.dll assembly contains a new caching API in the System.Runtime.Caching namespace. The namespace contains two core sets of classes:

  • Abstract types that provide the foundation for building any type of custom cache implementation.
  • A concrete in-memory object cache implementation (the System.Runtime.Caching.MemoryCache class).


The new MemoryCache class is modeled closely on the ASP.NET cache, and it shares much of the internal cache engine logic with ASP.NET. Although the public caching APIs in System.Runtime.Caching have been updated to support development of custom caches, if you have used the ASP.NET Cache object, you will find familiar concepts in the new APIs.

In addition, the regular System.Web.Caching.Cache class is now no longer sealed, which makes it possible for us to develop a File-Based Cache class that works with Visual Studio 2008, and then we can subclass the new OutputCacheProvider class and use the same code with Visual Studio 2010.


We override the Add, Get, Remove and Set methods, in this case using the identical code I'll show for the 2008 version, and we've substituted our new FileCache. I've even included an indexer so you can use the familiar Cache["test"]=myDataSet, and DataSet myDataSet = (DataSet)Cache["test"] shortcuts.

Then we can specify in an ASP.NET 4.0 application's Web.Config:

<appSettings>
<add key="OutputCachePath" value="~/" />
</appSettings>


<caching>
<outputCache defaultProvider="FileCache">
<providers>
<add name="FileCache" type="FileCache.Cache, FileCache"/>
</providers>
</outputCache>
</caching>

First, let's have a look at the two simple classes used for the FileCache:

We need a CacheItem class to hold our objects:

using System;

namespace FileCache
{
[Serializable]
internal class CacheItem
{
public DateTime Expires;
public object Item;
}
}

And here is the complete code for the FileCache class:

using System;
using System.Configuration;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Web.Security;
namespace FileCache
{
public class Cache
{
private string _cachePath;
private string CachePath
{
get
{
if (!string.IsNullOrEmpty(_cachePath))
return _cachePath;
_cachePath = ConfigurationManager.AppSettings["OutputCachePath"];
if (_cachePath == null) _cachePath = "~/";
HttpContext context = HttpContext.Current;
if (context != null)
{
_cachePath = context.Server.MapPath(_cachePath);
if (!_cachePath.EndsWith("\\"))
_cachePath += "\\";
}
return _cachePath;
}
}
public object this[string cacheKey]
{
get { return Get(cacheKey); }
set { Add(cacheKey, value, DateTime.Now.AddDays(365)); }
}
public object Add(string key, object entry, DateTime utcExpiry)
{
string path = CachePath + FormsAuthentication.HashPasswordForStoringInConfigFile(key , "MD5") +".dat";
if (File.Exists(path))
return entry;
using (FileStream file = File.OpenWrite(path))
{
var item = new CacheItem {Expires = utcExpiry, Item = entry};
var formatter = new BinaryFormatter();
formatter.Serialize(file, item);
}
return entry;
}
public object Get(string key)
{
string path = CachePath + FormsAuthentication.HashPasswordForStoringInConfigFile(key , "MD5") +".dat";
if (!File.Exists(path))
return null;
CacheItem item = null;
using (FileStream file = File.OpenRead(path))
{
var formatter = new BinaryFormatter();
item = (CacheItem) formatter.Deserialize(file);
}
if (item == null || item.Expires <= DateTime.Now.ToUniversalTime())
{
Remove(key);
return null;
}
return item.Item;
}
public void Remove(string key)
{
string path = CachePath + FormsAuthentication.HashPasswordForStoringInConfigFile(key, "MD5") + ".dat";
if (File.Exists(path))
File.Delete(path);
}
public void Set(string key, object entry, DateTime utcExpiry)
{
var item = new CacheItem {Expires = utcExpiry, Item = entry};
string path = CachePath + FormsAuthentication.HashPasswordForStoringInConfigFile(key, "MD5") + ".dat";
using (FileStream file = File.OpenWrite(path))
{
var formatter = new BinaryFormatter();
formatter.Serialize(file, item);
}
}
}
}

In web.config, we need to provide a cache path:

<appSettings>
<add key="outputCachePath" value="~/"/>
</appSettings>

If this is not provided, the code will default to the root web application folder.

Finally, for use with Visual Studio 2008, we need a static instance of our FileCache. The easiest way to provide this is in Global.asax:

public class Global : HttpApplication
{
public static Cache FCache = new Cache();

This is now available from anywhere in our web application as "Global.FCache".

The code in the FileCache class is set up to mirror the new .NET 4.0 Provider base class, so that to upgrade this to ASP.NET 4.0 with Visual Studio 2010, we only need to derive it from OutputCacheProvider, and provide the override keyword on the four required methods of Add, Get, Remove and Set. How you actually implement each of these methods is entirely up to you - it could be an in-memory Cache, File Cache, Database Cache (how about MongoDb?) or even a Cloud Cache. Or, it could access an instance of MemCached for a distributed cache.

Cache item expiration is handled automatically in the Get method:

if (item == null || item.Expires <= DateTime.Now.ToUniversalTime())
{
Remove(key);

Included in the download below are two solutions:

1. A Visual Studio 2008 solution which includes a test Web App as well as a test Windows Forms App to show that the FileCache can be used with non-web applications.

2. A Visual Studio 2010 solution that implements the OutputCacheProvider base class and has the required web.config entries as above. With this solution, you do not need a separate instance of your Cache object - when you call Cache, you'll automatically get your own custom implementation just as you would with a custom Membership provider.

The FileCache is very fast and would be useful, for example, in situations where you have frequently requested objects (DataSets, Lists, etc.) for DataBinding that would put an unnecessary load on your database. By storing these items on the filesystem, with an appropriate expiration time, you can provide these objects quickly on request without having to keep going back and hammering your database each time.

Additionally, since this cache is file-based, Cache items survive application restarts and even system reboots - a nice feature to have.

If you use this to serialize DataSets, don't forget to set the RemotingFormat on your DataSet object to Binary - it will cut down the file size by over 85 percent in many cases. I haven't experimented with adding compression to the stored files, but you could easily do this. There is an implementation of the AltSerializer (which I recommend over BinaryFormatter) and MiniLzo for compression, both of which are Silverlight - compatible, on codeplex.com.

UPDATE: As of 3/21/2010, I have added the FireAndForget mechanism to the Cache Add method. The revised code is in the download. Essentially, this makes all cache writes non-blocking method calls that return immediately, even for large objects.

NOTE: If you have rapidly repeating calls using the FireAndForget mechanism, it is possible that reads may be occuring to a Cache item before the FireAndForget background thread has completed writing the item. In this case, you can surround the Add and Get calls with lock semantics like this:

private static readonly object locker = new object();
public static void Add(Cache cache, string key, object entry, DateTime expiry)
{
lock (locker)
{
FireAndForget(new FileCacheDelegate(AddIt), new object[] {cache, key, entry, expiry});
}
}

This will effectively prevent cross-thread access, with only a nominal effect on performance.

You can download the zip file with both solutions here.


By Peter Bromberg   Popularity  (9300 Views)