Freigeben über


Using ASP.net Output Caching with WCF Data Services

We all know hitting the database is an expensive operation, adding the cost of serialization on top of that means that caching the output makes even more sense. The fact that WCF Data Services is built on top of the ASP.net platform means you can utilize all of its power to help you build a better service. This post examines the ASP.net output caching and how one would use it on WCF Data Service.

Cache Variation and Static Service Caching

ASP.net applications has the ability to cache the generated output on the server side. When a next matching request comes in, the output will then be delivered straight from the cache, rather than invoking the handler (calling data service). Note that this behavior applies to GET requests only. Note the word “matching” requests. How we match an request to a cached item is essential to delivering correct data to the customers. For a static (non-updating, we’ll take a look at caching for writable services too) WCF data service endpoint, the output will change depending on the URI path, all of the query parameters, and headers as well (accept, charset, etc.). ASP.net output caching has this notion of “VaryBy…”, which essentially means how we match incoming requests to items in the cache table (of course, non-matching items are added to the table). MSDN has an article that discusses asp.net caching in detail, so I won’t repeat what the parameters are here.

Let’s setup our example using a standard Northwind service over Entity Framework, everything is very standard at this point:

 namespace DataServiceCache 
{ 
    [ServiceBehavior(IncludeExceptionDetailInFaults=true)] 
    public class NorthwindService : DataService<NorthwindEntities> 
    { 
        public static void InitializeService(DataServiceConfiguration config) 
        { 
            config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3; 
            config.SetEntitySetAccessRule("*", EntitySetRights.All); 
            config.SetServiceOperationAccessRule("*", ServiceOperationRights.All); 
            config.UseVerboseErrors = true; 
        } 
    } 
}

Next, we override the OnStartProcessingRequest method to set the cache policy:

 protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    base.OnStartProcessingRequest(args);

    HttpContext context = HttpContext.Current;    // set cache policy to this page 
    HttpCachePolicy cachePolicy = HttpContext.Current.Response.Cache;

    // server&private: server and client side cache only 
    cachePolicy.SetCacheability(HttpCacheability.ServerAndPrivate);

    // default cache expire: never 
    cachePolicy.SetExpires(DateTime.MaxValue);

    // cached output depends on: accept, charset, encoding, and all parameters (like $filter, etc) 
    cachePolicy.VaryByHeaders["Accept"] = true; 
    cachePolicy.VaryByHeaders["Accept-Charset"] = true; 
    cachePolicy.VaryByHeaders["Accept-Encoding"] = true; 
    cachePolicy.VaryByParams["*"] = true;

    cachePolicy.SetValidUntilExpires(true); 
}

We assume that this service is static here, (never changes shape), so we set the expire date to never expire (although ASP.net should auto adjust this back to expire in 1 year). Fire up the service now to test the cache – one indicator of whether the feed is cached is by examining the “Updated” timestamp. For debugging and testing purposes, let’s add a call count to OnStartProcessingRequest to make sure data service is never called for subsequent requests:

 private const string processedRequestCount = "ProcessedRequestCount";

protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    if (HttpContext.Current.Application.Get(processedRequestCount) == null) 
    { 
        HttpContext.Current.Application.Set(processedRequestCount, 1); 
    } 
    else 
    { 
        int count = (int)HttpContext.Current.Application.Get(processedRequestCount); 
        HttpContext.Current.Application.Set(processedRequestCount, count + 1); 
    }

    base.OnStartProcessingRequest(args); 
    … 
}

We can retrieve this count via service operation. Note how we can control the service operation variation individually to “no cache”, this will cause the requests to /GetProcessedCount to always go through data service.

 [WebGet] 
public int GetProcessedCount() 
{ 
    HttpContext.Current.Response.Cache.SetCacheability(HttpCacheability.NoCache);

    int count = Convert.ToInt32(HttpContext.Current.Application.Get(processedRequestCount));

    HttpContext.Current.Application.Set(processedRequestCount, 0); 
    return count; 
}

Next, let’s quickly write a client –side app to test this:

 var context = new NorthwindEntities(new Uri("https://localhost:85/NorthwindService.svc"));

int callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Initial call count {0}", callCount);

IQueryable[] queries = new IQueryable[] { 
    context.CreateQuery("Customers").Skip(10).Take(10), 
    context.CreateQuery("Customers") , 
    context.CreateQuery("Orders").Skip(10).Take(10) , 
    context.CreateQuery("Orders") , 
    context.CreateQuery("Customers").Expand("Orders"), 
};

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after batch querying {0}", callCount);

The output is:

Initial call count 1
Call count after batch querying 6

As you can see, invoking the query twice will not hit the data service twice. The second request is served directly from the cache. Also, ASP.net is smart enough to cache a different page for each of the entity set, and for each variation of the parameter.

Cache Dependency and Writable Service Caching

So far we’ve looked up caching for static services. Not many services in the world are truly static, which makes caching kind of pointless if you cannot expire an cached item dynamically. The ASP.net output cache uses the idea of “Dependencies”, namely, an output cache item can take dependency on a data cache item – something you have full control over. When the latter one expires, the output cache will automatically expire too. This gives us a way to signal when we should clear the cache, and enables service writers to fine tune their expiration policy for better performances.

The basic idea is this, we can separate responses into cache-able groups, insert a dummy item into the data cache for each of the group, and then setup dependencies accordingly. Whenever we see a verb that’s not a GET, that means change has happened to the data source. We can then decide which group to refresh by expiring the corresponding item in the data cache. Let’s take the simplest approach for our example here – we expire ALL items related to the service whenever an update happens.

 // group key 
private const string cacheDependencyItemKey = "DataServiceCacheItem";

protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    if (HttpContext.Current.Application.Get(processedRequestCount) == null) 
    { 
        HttpContext.Current.Application.Set(processedRequestCount, 1); 
    } 
    else 
    { 
        int count = (int)HttpContext.Current.Application.Get(processedRequestCount); 
        HttpContext.Current.Application.Set(processedRequestCount, count + 1); 
    }

    base.OnStartProcessingRequest(args);

    HttpContext context = HttpContext.Current;

    if (context.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("MERGE", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) 
    { 
        // if we are making changes to this service, expire all caches 
        context.Cache.Remove(cacheDependencyItemKey); 
    } 
    else 
    { 
        Debug.Assert(context.Request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase)); 
                
        // set cache policy to this page 
        HttpCachePolicy cachePolicy = HttpContext.Current.Response.Cache;

        // server&private: server and client side cache only 
        cachePolicy.SetCacheability(HttpCacheability.ServerAndPrivate);

        // default cache expire: never 
        cachePolicy.SetExpires(DateTime.MaxValue);

        // cached output depends on: accept, charset, encoding, and all parameters (like $filter, etc) 
        cachePolicy.VaryByHeaders["Accept"] = true; 
        cachePolicy.VaryByHeaders["Accept-Charset"] = true; 
        cachePolicy.VaryByHeaders["Accept-Encoding"] = true; 
        cachePolicy.VaryByParams["*"] = true;

        cachePolicy.SetValidUntilExpires(true);

        // output cache has dependency on the data service cache item 
        context.Response.AddCacheItemDependency(cacheDependencyItemKey); 
    }

    if (context.Cache.Get(cacheDependencyItemKey) == null) 
    { 
        // what the cache item value is doesn't really matter 
        context.Cache.Insert(cacheDependencyItemKey, "Item"); 
    } 
}

We can update the client-side application to test this:

 var context = new NorthwindEntities(new Uri("https://localhost:85/NorthwindService.svc"));

var cust = context.CreateQuery("Customers").Take(1).FirstOrDefault(); 
context.UpdateObject(cust); 
context.SaveChanges();

int callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Initial call count {0}", callCount);

IQueryable[] queries = new IQueryable[] { 
    context.CreateQuery("Customers").Skip(10).Take(10), 
    context.CreateQuery("Customers") , 
    context.CreateQuery("Orders").Skip(10).Take(10) , 
    context.CreateQuery("Orders") , 
    context.CreateQuery("Customers").Expand("Orders"), 
};

foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Call count after query {0}", callCount);

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after batch querying {0}", callCount);

context.UpdateObject(cust); 
context.SaveChanges();

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after update and querying {0}", callCount);

And the output is:

Initial call count 3 <—1 update, 1 get, 1 service op
Call count after query 6
Call count after batch querying 1
Call count after update and querying 7

As you can see, requests will always go through data services after an update has occurred. In this example we grouped all items into one big group, that’s probably not the optimal solution for most people. An entity-set based grouping should be sufficient, and you can implement this with Query and Change interceptors.

Total Cache Control: The OutputCacheProvider

Still not satisfied with the level of control you have? You can choose to implement the abstract class OutputCacheProvider, and hook it up via a simple config file change. Note that if you go this route, then you cannot add output page dependencies to data cache items. You’ll have to implement the dependency logic inside your provider. I won’t go into details here but the MSDN documentation and this article should help if you decided to go this route.

Comments

  • Anonymous
    December 20, 2010
    Hi Peter Qian,  Interesting post. I too have doubt in Output Caching with WCF Data Services using asp.net, but now it has been resolved. Your explanation is very clear and informative. Thanks to Peter to share an useful topic with us. Codes are very easy and simole. godwinsblog.cdtech.in/.../requested-page-cannot-be-accessed.html

  • Anonymous
    December 20, 2010
    Hi Rahul, Thanks for the feedback. Hope this will help you boost your service's performance!

  • Anonymous
    March 11, 2011
    Hi Peter, I thing your tinging is so different from others.

  • Anonymous
    May 11, 2011
    Now, how do we use this approach with AppFabric Caching?

  • Anonymous
    May 11, 2011
    I'm not so familiar with AppFabric caching, but I thought they only allowed DataCaches, instead of output level caching. For DataCaching, you can cache the results from database, but you still have to pay the cost of serialization (today there is no mechanism in WCF DS to allow you cache the serializer output). Better caching support is one of the items in our agenda, so hopefully it won't be long until we visit this topic again :)

  • Anonymous
    September 13, 2011
    nice info. but before going ahead with asp.net cache, one has to keep into mind its limitations as well. it holds good in smaller web farms only. but in a larger web garden where number on servers are more then two and data is distributed over these servers, then the use of a distributed cache can be a better option. the basic difference between an asp.net cache and a distributed cache is being the nature of caching i.e cache in asp.net is stand alone and not distributed over multiple servers while in distributed cache it is in-memory and distributed over multiple servers. here is a good study about distributed aching www.alachisoft.com/.../asp-net-cache.html

  • Anonymous
    September 14, 2011
    Hi Peter, Nice post. If I want to cache only certain entities then how to extend current implementation

  • Anonymous
    April 07, 2012
    The comment has been removed

  • Anonymous
    May 12, 2012
    I used the code and it works well in the browser when called... but if I call through using the service reference (as you've shared) or webrequest caching does not work... what's going on?

  • Anonymous
    May 12, 2012
    I do not know what went wrong in your case without debugging it for more information. One way you could find out is through fiddler, trace the client-server communication. It may be something really simple.

  • Anonymous
    July 12, 2012
    I implemented this solution in our WCF data service, and it works perfectly on all the developer machines, however on the main development and production web servers, no caching occurs. The dev web servers are IIS7 and the production servers are II6, and the caching doesn't work on either. However, we're using IIS7 / Windows 7 for development and it's perfect. Any idea of what could cause this? Thanks.