Enterprise Library Caching Through a Web Service

 

©2007 Alex Homer, Stonebroom Limited, http://www.stonebroom.com

This article illustrates how you can extend the Enterprise Library Application Blocks to perform specific tasks, or behave in a different way, by creating providers. While this is a topic often covered elsewhere, this article takes a somewhat different approach in that it describes the implementation of a feature that falls more into the category of "I wonder if it's possible?" than the more usual "How do I make it do this?". However, the final outcome is a provider that offers an unusual approach to caching data – and a capability that just may prove useful to someone!   

Contents

Introduction. 2

Why Use a Web Service? 2

The Design of the Web Service Caching Provider 3

Partitioning the Cache. 4

Choosing a Cache Persistence Medium.. 4

Creating a Web Proxy Class for the Caching Provider 4

Generating a Web Proxy Class Dynamically. 5

User-defined or Built-in Proxy? 5

Building the Web Proxy Class 6

Design of the Web Service Interface. 7

Implementing the Interface in a Web Service. 7

Running WSDL to Generate the Proxy Class 8

Modifying the Generated Proxy Class 8

Implementing the Caching Provider 9

The Provider Class Constructor 10

Obtaining a Reference to the Proxy Class 10

Calling the Proxy Class Methods 11

Converting Data Types for the Web Service. 11

Loading Cached Items 13

Configuration and Design Support for the Provider 14

Building the Target Web Service. 16

The Example Caching Web Service. 17

Converting, Persisting, and Updating Cached Items 17

Loading Cached Items 19

Using the Web Service Cache Provider in ASP.NET. 22

Adding Items to the Cache in the Example Application. 24

Retrieving Items from the Cache in the Example Application. 26

Removing Items from the Cache in the Example Application. 27

Summary. 29

Introduction

Sometimes, you find yourself in that "in-between-jobs" situation where you think "I wonder if I can make my application do this...?" It might not immediately seem to be a useful, or even sensible, feature; but most developers tend to be attracted to things that are out of the ordinary, and which offer a challenge. OK, so the topic of this article, and the implementation, may not be ground-breaking stuff, or reveal whole new areas of future development (I suppose, if they did, I would now be retiring with a few million dollars in the bank), but the final result works better than expected – and may just give you some ideas for its use.

It started when I was working with Enterprise Library 2.0, and in particular the Caching Application Block, trying to demonstrate how easy it is to create your own custom providers. I work mainly in ASP.NET, where the requirements for caching differ from most Windows Forms applications. For example, using the default Isolated Storage provider is probably not a realistic option in a Web site, and even less so in a multiple-server Web farm.

In a previous article (see "Extending Enterprise Library with Custom Providers" at http://www.daveandal.net/articles/EntLibASPNET/) I described the process for interfacing a custom provider with the Caching Application Block, and adding support for the Enterprise Library configuration tools. The custom provider described there simply writes the cached data to disk files in a folder you specify in the configuration. This provides opportunities for caching in a way more suited to ASP.NET, although it still does not provide a truly "shared" caching mechanism due to the way that the Caching Application Block works internally.

To achieve a flexible and shared caching mechanism, you need a central store for holding cached data, and an approach that you may consider is using the Database Caching Provider supplied with Enterprise Library. However, one issue that all these approaches face is connectivity between the application and the cache repository.

Why Use a Web Service?

It is clear from the provider mechanism within the Caching Application Block that you can create a custom provider that stores the cached data anywhere you want. It was this that made me wonder if there was a possibility of caching within, or through, a Web Service. This would allow the provider to cache its data almost anywhere – remotely or locally – without having to write specific code that is directly integrated within Enterprise Library.

The principle is simple enough. Instead of having the backing store provider within the Caching Application Block interact directly with the backing store itself (the usual approach, as implemented in the Isolated Storage provider and Database provider), the backing store provider simply packages up the data and sends it to a Web Service.

The Web Service can then cache or manipulate the data in any way you need. And, if the backing store provider is sufficiently configurable, you can change the URL of the target Web Service any time you like. In addition, you can add more than one Cache Manager and backing store provider to an application, allowing it to cache the data through multiple Web Services. Finally, adding support for "partitions" within the provider means that you can implement multiple separate caches within the target backing store.

The Design of the Web Service Caching Provider

Figure 1 shows a high-level view of the approach. As you can see, the core principle is simple enough, although the implementation proved somewhat trickier than first expected. However, while having some limitations, the result does provide the features I initially wanted to achieve.

Figure 1 – A high-level view of the Web Service Caching Provider and associated mechanism for the Caching Application Block

Issues that you may want to visit are:

Despite the limitations, remote caching does work reasonably well with small or medium sized data items, and over a reasonably fast network link. The Caching Application Block uses an in-memory cache to provide fast local performance when reading cached data, and only uses the backing store provider to implement a persistent store. Therefore, the only interactions with the backing store are:

As you can see from the list, large volumes of data move over the wire to the Web Service only when the application starts up. Adding an item to the cache only involves moving the data for that item over the wire one way: into the backing store. Other operations just send or receive small SOAP packets that include items such as the cache key, an updated "last accessed time", or an integer value.

Partitioning the Cache

One feature I chose to include, as you can see in Figure 1, is the ability to define a partition or filter value that the Web Service can use to implement multiple separate caches without requiring you to implement multiple Web Services. In the configuration of the Web Service Cache Provider, you can specify a name for the partition that this provider will use.

The partition name passes to the Web Service with every call from the Caching Application Block, allowing code in the Web Service to store the cached items in different folders on disk, in different database tables, or under a different key – depending on how you implement the persistent storage of cached items within your Web Service.

The example provider stores the cached data as disk files (using the same approach as the previous article indicated earlier), placing them in a subfolder under the root caching folder that corresponds to the partition name.

Two ways that you could extend the example Web Service Cache Provider are:

Choosing a Cache Persistence Medium

The example target Web Service provided in the samples for this article stores cached data as disk files. By default, it uses a local folder, though – providing the account you run the Web Service under has the relevant permissions – there is no reason why the cache root should not be on a mapped or remote drive. Another possibility is to use the Web Service to store the cached data in a database, or in any other persistent storage you require.

You may even decide to implement a separate component, business logic layer, or data tier to handle the actual storage. In this way, the Web Service is simply a communication conduit that interfaces the Caching Application Block with a local or remote backing store of your choosing.

Creating a Web Proxy Class for the Caching Provider

The first, and in fact the most tricky issue in creating the provider turned out to be implementation of a suitable Web Service proxy. Initially, I wanted to implement a system where the user could create a custom proxy and install it with the Web Service backing store provider simply by specifying the proxy class type within the Enterprise Library configuration.

However, this turned out to be complicated, not least by the fact that the backing store provider would need to instantiate the proxy without previously knowing the type. While the Activator.CreateInstance method can achieve this, figuring out how to integrate varying classes, types, and properties from a custom proxy class seemed to just complicate the matter.

Generating a Web Proxy Class Dynamically

One approach to creating the proxy would be to generate it dynamically from the Web Service Description Language (WSDL) document exposed by the target Web site. There are well-known techniques for this; described, for example, in the MSDN article "Calling an Arbitrary Web Service" at http://msdn2.microsoft.com/en-us/library/aa730835(vs.80).aspx. Another useful source of information on this technique is a comprehensive and highly readable article by Roman Kiss at http://www.codeproject.com/cs/webservices/webservicecallback.asp. There is even a ready-built component available from GotDotNet at http://www.gotdotnet.com/Community/UserSamples/Details.aspx?SampleGuid=35c47ebb-d806-4995-8797-a42251a8ace3.

However, it soon became clear that this was also overkill. The interface for the proxy, and the corresponding interface of the Web Service, is fixed by the requirements of a Caching Application Block backing store provider. So there is no need to dynamically generate the proxy because the WSDL will be the same every time.

User-defined or Built-in Proxy?

Another issue is allowing for configurability within the backing store provider and the Web Service proxy. This relates mainly to how much variability and configurability should be available for the target Web Service, as this affects the proxy implementation. For example, should the configuration allow for the use of a user-defined namespaces for the Web Service methods? Or should it be possible to specify the namespace and the target URL at runtime?

In the end, after much consideration, it became clear that the proxy does not actually need to be user configurable except for the target Web Service URL. Hard-coding the namespace binding into the proxy is not unreasonable, as the user can specify the required namespace binding within their custom Web Service class.

In addition, as the interface is fixed (based on the methods that a custom backing store must implement), it seemed easier to create a public Interface class that describes the interface. The user can then implement the outline for the interface automatically in their Web Service using the features of Visual Studio.

This allows the proxy to be a permanent class complied within Enterprise Library. The user then only has to build the Web Service by implementing the same interface, and using the specified namespace binding.

 

Figure 2 shows the final design of the Web Service-based caching mechanism as I chose to implement it. You can see the custom backing store provider with its configuration data storage and design support classes, and the proxy class and its interface, all complied within the Caching Application Block.

The Caching Web Service class used in the example persists cached data directly (annotated as "A" in Figure 2). However, there is no reason why it could not communicate with other components or services (annotated as "B" in Figure 2), and use them to cache the data as required.

Figure 2 – The overall design and components for the Web Service-based caching mechanism

Building the Web Proxy Class

The easiest way to create a Web Service proxy class is to use the WSDL tool provided with Visual Studio. You may need to extract wsdl.exe from your Visual Studio setup files, as it is not installed automatically with the standard installation of Visual Studio 2005. You need the two files wsdl.exe and wsdl.exe.config (it is a command-line executable and has no GUI).

To create a proxy class with WSDL, you must provide an XML schema or a WSDL document as the source. The process I followed was:

Design of the Web Service Interface

The interface required for communication between the backing store provider and the Web Service is basically the set of methods that the backing store provider must implement, such as LoadDataFromStore, Remove, AddNewItem, and Count (implemented as CachedItemCount). I modified the signatures to include the name of the cache partition, and chose to change the types to simplify transmission over the wire by using mainly Base64-encoded String and String Array types. However, this is an arbitrary decision, and you can choose the types that best suit your requirements.

This listing shows the Interface class (named ICustomCacheWebService) that defines the communication interface:

using System;

using System.Collections;

 

namespace Microsoft.Practices.EnterpriseLibrary.Caching

{

  public interface ICustomCacheWebService

  {

    Int32 CachedItemCount(String partitionName);

    String AddNewItem(String partitionName, String storageKey,

                      String[] cachedItemInfo, String base64ItemBytes);

    String Remove(String partitionName, String storageKey);

    String RemoveOldItem(String partitionName, String storageKey);

    String UpdateLastAccessedTime(String partitionName, String storageKey,

                                  DateTime timestamp);

    String Flush(String partitionName);

    String[] LoadDataFromStore(String partitionName);

    // String array:

    // @ Index + 0 = cache key

    // @ Index + 1 = last accessed date/time

    // @ Index + 2 = duration (seconds)

    // @ Index + 3 = Base64 encoded data

    // repeats in multiples of 4

  }

}

Implementing the Interface in a Web Service

Once you have the interface class, you can copy it into the App_Code folder of a new C# Web Service project in Visual Studio – note that the language of the interface and the Web Service must be the same. Then add the interface to the class declaration (you may need to include the full namespace-qualified names):

public class Service : WebService, ICustomCacheWebService

 

Now right-click on the interface name and select Implement Interface, then click Implement Interface in the fly-out menu. This creates all the outline methods required for the Web Service and the Web Service proxy.

 

Next, change the value of the Namespace property of the WebService attribute within the new Web Service from its default (http://tempuri.org/) to the namespace binding you want to use for communication between your backing store provider and the target Web Service(s): 

[WebService(Namespace = "uri:mycacheservice/somequalifier/samples")]

Running WSDL to Generate the Proxy Class

Once the outline Web Service is available, open a Command window and execute the WSDL tool to generate the required proxy class. To see all the options for WSDL, type:

wsdl.exe /?

 

If you are happy with the default language for the proxy class (C#), all you need enter to generate the class is:

wsdl.exe http://path-to-your-web-service/your-web-service-file-name.asmx

 

Insert the /language:VB option before the URL if you want the proxy class in Visual Basic.NET instead of C#, and /out:file-path-and-name if you want to generate the class file with a specific name, or in a specific folder instead of the same folder as the WSDL tool. The example proxy provided with the samples is named CustomCacheWebServiceProxy.

Modifying the Generated Proxy Class

The generated proxy class will contain the methods exposed by the Web Service, for example this method that takes the partition name and the storage key of a cached item:

public string Remove(string partitionName, string storageKey)

 

However, you must make a few minor changes to the class before you can use it. By default, the WSDL tool does not add a namespace to the proxy class (though you can specify one as an option if you wish). However, if you want to locate the proxy class within a namespace in your modified version of the Enterprise Library Caching Application Block, you can simply add it to the class. This is the namespace used by the Caching Application Block:

namespace Microsoft.Practices.EnterpriseLibrary.Caching

{

  ... WSDL generated code here ...

}

 

Next, add the name of the interface for the custom class and Web Service so that you can access the proxy in your code using the interface type instead of requiring the proxy class type:

public partial class CustomCacheWebServiceProxy

     : System.Web.Services.Protocols.SoapHttpClientProtocol,

       ICustomCacheWebService

 

To allow users to configure the URL of the proxy to point to their custom Caching Web Service, modify the constructor of the proxy class to accept the URL as a parameter, and set the Url property of the class to this value instead of the fixed value that WSDL generates within the constructor:

public CustomCacheWebServiceProxy(String webServiceUrl)

{

  this.Url = webServiceUrl;

}

 

Finally, if you want to use a different namespace binding to that specified in your temporary outline Web Service, change the value of the namespace in the WebServiceBindingAttribute near the start of the class, and in all of the SoapDocumentMethodAttribute instances on the members of the proxy class.

Implementing the Caching Provider

With the proxy class in place, you can now create the provider for the Caching Application Block. The principles are the same as in the previously referenced article (see "Extending Enterprise Library with Custom Providers" at http://www.daveandal.net/articles/EntLibASPNET/), and the techniques for implementing design support for the Enterprise Library configuration tools are also the same.

The only real differences are that the Web Service Caching provider has three custom properties that you can set in the application configuration. These are:

Figure 3 shows how the design classes for the custom provider enable configuration through the Enterprise Library Configuration Console. The DefaultCacheDuration and PartitionName are set automatically to the default values shown in Figure 3, but the user can edit them as required. 

Figure 3 – Configuring the custom Web Service Caching Provider. You can see the three custom properties (in addition to the Name property) that it exposes.

In reality, the backing store provider (CustomWebServiceBackingStore.cs in the Caching\BackingStoreImplementations subfolder) only has to pass calls from the Caching Application Block to the proxy class methods. Therefore, the bulk of the work within the methods is converting the values passed to the provider into the correct types for the proxy class.

The class declaration specifies the configuration element type for the class as an instance of the CustomWebServiceCacheStorageData class (discussed later). Notice that the provider class inherits BaseBackingStore. Both of these features are requirements for a simple custom cache backing store provider:

[ConfigurationElementType(typeof(CustomWebServiceCacheStorageData))]

public class CustomWebServiceBackingStore : BaseBackingStore

{

  ...

The Provider Class Constructor

The constructor within the provider takes the values passed from its configuration data storage class and stores them in local variables so that it can pass them to the appropriate proxy methods. This is the class constructor and the declaration of the local variables:

// name of the name/value pairs declared in the application

// configuration file <backingStores> section

private const String serviceUrlAttribute = "webServiceUrl";

private const String servicePartitionNameAttribute = "partitionName";

private const String serviceDefaultDurationAttribute

                      = "defaultCacheDuration";

 

// local variables

private ICustomCacheWebService serviceProxy = null;

private String wsUrl = String.Empty;

private String wsPartition = String.Empty;

private int wsDefaultDuration = 0;

 

public CustomWebServiceBackingStore(String serviceUrl,

                      String partitionName, int defaultCacheDuration)

{

  if (serviceUrl != String.Empty && partitionName != String.Empty

                 && defaultCacheDuration != 0)

  {

    wsUrl = serviceUrl;

    wsPartition = partitionName;

    wsDefaultDuration = defaultCacheDuration;

  }

  else

  {

    throw new Exception("Error in application configuration");

  }

}

Obtaining a Reference to the Proxy Class

Every property and method in the provider must obtain a reference to the proxy so that it can call its methods. It makes sense to hang on to the reference where possible. This is true even in ASP.NET applications, where each page load will instantiate the block and the provider, because the block often calls more than one method as part of a caching operation (for example, it calls the RemoveOldItem method before calling the AddNewItem method).

Therefore, the provider includes a private method that returns a new or existing instance of the proxy class. This instance (as an ICustomCacheWebService type) is stored locally in the variable named serviceProxy:

// get a new or existing instance of the Web Service Proxy object

private ICustomCacheWebService GetWebServiceProxy()

{

  if (serviceProxy == null)

  {

    try

    {

      serviceProxy = new CustomCacheWebServiceProxy(wsUrl);

    }

    catch (Exception ex)

    {

      throw new Exception("Cannot create Web Service Proxy", ex);

    }

  }

  return serviceProxy;

}

Calling the Proxy Class Methods

Every method and property must pass the partition name to the proxy so that the target Web Service can access each item in the correct location. For example, the Flush method obtains a reference to the proxy and then passes the partition name to the Flush method of the proxy:  

public override void Flush()

{

  ICustomCacheWebService proxy = GetWebServiceProxy();

  String errMessage = String.Empty;

  try

  {

    errMessage = proxy.Flush(wsPartition);

  }

  catch (Exception ex)

  {

    throw new Exception("Failed to execute Flush method", ex);

  }

  if (errMessage != String.Empty)

  {

    throw new Exception("Cannot flush Web Service cache partition '"

              + wsPartition + "'. Web Service error: " + errMessage);

  }

}

 

To provide enhanced debugging and error handling capabilities (always a good idea when working with Web Services), the method catches any exceptions and provides details of what went wrong. If an error arises within the Web Service, the service passes the error message back to the proxy, and on to the provider, as a String value that the provider can return to the user. If there is no error, the Web Service returns an empty string.

Converting Data Types for the Web Service

The data types chosen for the example provider proxy and Web Service interface are different from those exposed by the Caching Application Block when it calls the backing store provider. Therefore, some of the methods in the provider must perform type conversion. For example, the AddNewItem method must extract the individual values from the CacheItem instance passed to it and match these to the types specified in the proxy interface.

The next listing shows how the AddNewItem method builds a String Array named infoData containing the item key, the "last accessed" date/time, and the sliding time cache duration in seconds:

protected override void AddNewItem(int storageKey, CacheItem newItem)

{

  // set up information array values

  String[] infoData = new String[3];

  infoData[0] = newItem.Key;

  infoData[1] = newItem.LastAccessedTime.ToString();

  // see if there is a cache sliding expiration duration specified

  ICacheItemExpiration[] cie = newItem.GetExpirations();

  if ((cie.Length > 0) && (cie[0] is SlidingTime))

  {

    // get sliding time value and convert to a string for the info array

    SlidingTime slidingDuration

                = (SlidingTime)newItem.GetExpirations().GetValue(0);

    infoData[2] = slidingDuration.ItemSlidingExpiration.ToString();

  }

  else

  {

    // no duration specified, so use the default value

    infoData[2] = wsDefaultDuration.ToString();

  }

  ...

 

 

Next, the code converts the cached item to a Base64-encoded string. Enterprise Library core contains the SerializationUtility class that you can use to serialize and de-serialize objects:

  ...

  // serialize object and convert to Base64 encoding

  Byte[] itemBytes = SerializationUtility.ToBytes(newItem.Value);

  String itemString = Convert.ToBase64String(itemBytes);

  ...

 

Finally, the method can get a reference to the proxy class and call its AddNewItem method. The code handles any exceptions and generates error messages in the same way as you saw earlier for the Flush method:

  ...

  // get proxy instance, call method, and check for errors

  ICustomCacheWebService proxy = GetWebServiceProxy();

  String errMessage = String.Empty;

  try

  {

    errMessage = proxy.AddNewItem(wsPartition, storageKey.ToString(),

                                 infoData, itemString);

  }

  catch (Exception ex)

  {

    throw new Exception("Failed to execute AddNewItem method", ex);

  }

  if (errMessage != String.Empty)

  {

    throw new Exception("Cannot add new item to cache partition '"

              + wsPartition + "'. Web Service error: " + errMessage);

  }

}

Loading Cached Items

When the Caching Application Block initializes, it loads any persisted cache items from the backing store by calling the LoadDataFromStore method of the provider. This is perhaps the most complex of the methods, because it must convert the String Array returned from the Web Service proxy that contains all the cached items into a Hashtable of CacheItem instances.

The first stage of the method, shown in the next listing, creates a Hashtable, gets a reference to the proxy, and calls its LoadFromDataStore method; specifying as usual the partition name defined for this provider instance. Then it can check if there are any cached items, and – if not – return the empty Hashtable.

Note that the LoadFromDataStore method of the Web Service has to return a String Array of size 1 (instead of a simple String), when an error occurs. The code in the LoadDataFromStore method of the provider can check for an error, and return the details to the user:

protected override System.Collections.Hashtable LoadDataFromStore()

{

  Hashtable cacheItems = new Hashtable();

  ICustomCacheWebService proxy = GetWebServiceProxy();

  String[] serviceCachedItems = proxy.LoadDataFromStore(wsPartition);

  // see if cache partition is empty

  if ((serviceCachedItems == null) || (serviceCachedItems.Length == 0))

  {

    return cacheItems;

  }

  // see if the Web Service returned an error

  if (serviceCachedItems.Length == 1)

  {

    throw new Exception("Cannot load items from cache partition '"

        + wsPartition + "'. Web Service error: " + serviceCachedItems[0]);

  }

  ...

 

Providing that there is no error, the next section of the method code iterates through the String Array extracting the values for the cached item. Each cached item is stored as four consecutive values in the array, and so the code indexes into the array to get each value for the cached item, and then skips four places to the next cached item.

For each cached item in the array, the code extracts the key, the "last accessed" date/time, the sliding duration (in seconds), and the Base64-encoded string containing the cached object or value. Using these values, after converting them to the appropriate types, the code can generate a new CacheItem instance, add it to the Hashtable, and go on to the next cached item in the array:

  ...

  // read the cached item data into the Hashtable

  Int32 itemIndex = 0;

  while (itemIndex < (serviceCachedItems.Length - 1))

  {

    String itemKey = serviceCachedItems[itemIndex];

    DateTime lastAccessed

        = DateTime.Parse(serviceCachedItems[itemIndex + 1]);

    TimeSpan slidingDuration

        = TimeSpan.Parse(serviceCachedItems[itemIndex + 2]);

    // deserialize object from .cachefile file

    Byte[] itemBytes

        = Convert.FromBase64String(serviceCachedItems[itemIndex + 3]);

    Object itemValue = SerializationUtility.ToObject(itemBytes);

    // create CacheItem and add to Hashtable

    CacheItem item = new CacheItem(lastAccessed, itemKey,

                         itemValue, CacheItemPriority.Normal, null,

                         new SlidingTime(slidingDuration));

    cacheItems.Add(itemKey, item);

    itemIndex += 4;

  }

  return cacheItems;

}

 

The remaining methods in the provider class, Count (which calls the CachedItemCount method of the proxy), Remove, RemoveOldItem, and UpdateLastAccessedTime work in much the same way as those you have just seen. Each one passes the partition name, the integer hash of the cache key (called the storage key), and any other required parameters to the proxy class methods. In addition, for each one except the CachedItemCount method, the Web Service returns any error message as a String that the method can use to generate an appropriate error message. Because the CachedItemCount method returns an integer value, the error indicator in this case is -1. You can open the file CustomWebServiceBackingStore.cs to see all of the methods.

Configuration and Design Support for the Provider

The custom Web Service Cache provider requires a configuration data class that exposes the values in the application configuration to the provider. This class, defined in the file CustomWebServiceCacheStorageData.cs (in the Caching\Configuration subfolder), looks and works much like the equivalent for the custom caching provider discussed in previous articles. The only difference is that it exposes the three custom properties for this provider, as well as the provider type and name.

To implement design support for the Enterprise Library configuration tools, you must also:

The example code includes a folder named EntLibSourceFiles that contains the files and code required to implement design support. The contents of this folder and its subfolders are:

To understand how these classes work, and exactly how you add or update them within Enterprise Library, see the previous articles "Extending Enterprise Library with Custom Providers" (see http://www.daveandal.net/articles/EntLibASPNET/) and "A Custom Policy Injection Application Block Handler" (see http://www.daveandal.net/articles/piab-handler/).

With the custom handler, proxy class, and design support classes complete, it's time to compile the Caching Application Block and copy the assemblies to the required location. The easy way is to run the BuildLibraryAndCopyAssemblies.bat file located in the EntLibSrc\App Blocks subfolder of the Enterprise Library source files. All the final assemblies, and the Configuration Console, reside in the EntLibSrc\App Blocks\bin subfolder.

Remember that, when using the unsigned version of the Enterprise Library assemblies in the EntLibSrc\App Blocks\bin folder, you must use the version of the Configuration Console (EntLibConfig.exe) located in this folder to configure your applications.

Building the Target Web Service

Once you have updated Enterprise Library, as discussed in the preceding sections of this article, the only remaining task is to build the Web Service that will communicate with the Caching Application Block and persist the cached items. How it does this depends on your own requirements.

The main restraints and requirements for the Web Service are:

The example Web Service, implemented by the class SampleCachingWebService in the SampleCacheWebService\App_Code subfolder of the examples, fulfils these requirements. To reference the ICustomCacheWebService interface, you must add a reference to the Caching Application Block assembly (or the namespace where you defined the interface) to your project and class:

// in C#:

using Microsoft.Practices.EnterpriseLibrary.Caching;

 

' in Visual Basic.NET:

Imports Microsoft.Practices.EnterpriseLibrary.Caching

 

You define the namespace of the Web Service in the WebService attribute that decorates the class, and you should include the WebServiceBinding attribute that specifies conformance with the Basic Profile 1.1 as shown here:

// in C#:

[WebService(Namespace="your-proxy-class-namespace")]

[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]

 

' in Visual Basic.NET:

<WebService(Namespace:="your-proxy-class-namespace")> _

<WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)> _

 

The class itself will inherit System.Web.Services.WebService, and you add the interface declaration for ICustomCacheWebService here as well:

// in C#:

public class SampleCachingWebService : System.Web.Services.WebService,

                                       ICustomCacheWebService

 

' in Visual Basic.NET:

Public Class SampleCachingWebService

  Inherits System.Web.Services.WebService

  Implements ICustomCacheWebService

 

The Example Caching Web Service

The remainder of the Web Service class is the code to implement the methods defined in the ICustomCacheWebService interface, and any helper methods or other code you require for persisting the cached items. In the example Web Service, the code defines a root cache folder on the hard disk, and generates subfolders within this root folder for each partition name as the user caches data through the custom backing store provider.

Therefore, the code first declares the root folder path, and the file extensions it will use to store the cached data. The information about the cached item (the key, "last accessed" date/time, and cache duration in seconds) reside in an "information" text file as a series of String values that can easily be read into or written from the String Array passed to the Web Service methods. The serialized item, passed to the Web Service as a Base64 encoded String, is converted to a Byte Array and stored as a binary "data" file:  

// root folder for cached data files

private const String cacheRootFilePath = @"C:\Temp\";

 

// file extensions for the cached object and cache information files

private const String dataExtension = ".cachedata";

private const String infoExtension = ".cacheinfo";

Converting, Persisting, and Updating Cached Items

The methods in the example Web Service have two main tasks to perform. They must convert the values received from the proxy within the Caching Application Block into the appropriate types for the chosen storage medium, and then persist or manipulate them as required. In fact, the types chosen for exposure by the proxy are well suited to most storage mediums, such as disk files or database tables.

As an example of the methods, the CachedItemCount method uses the partition name to build a path to the cached file location, and uses the Directory.Exists method to see if there is a folder with this partition name. If not, it returns zero. If the folder does exist, the code creates a suitable search string to locate all files with the "data" file extension, and calls the Directory.GetFiles method to get an array of their names. It can then return the length of this array as the number of cached items. If an exception occurs, the code just returns the error indicator value -1:

[WebMethod(Description="Returns the number of items in the

                       specified cache partition.")]

public Int32 CachedItemCount(String partitionName)

{

  // create partition folder path and check if it exists

  String filePath = Path.Combine(cacheRootFilePath, partitionName);

  if (Directory.Exists(filePath))

  {

    // create file specification to search for cache files

    String searchString = String.Concat("*", dataExtension);

    try

    {

      String[] cacheFiles = Directory.GetFiles(filePath, searchString,

                                      SearchOption.TopDirectoryOnly);

      return cacheFiles.Length;

    }

    catch

    {

      return -1;  // error indicator

    }

  }

  else

  {

    return 0;    // partition folder does not exist, so no cached items

  }

}

 

To add a new item to the cache, the AddNewItem method in the Web Service must first convert the values passed to the method into the correct types. The first stage is to check that the target partition subfolder exists, and create it if not. Then the code creates the paths and names for the "information" and "data" files (the file name is the storage key – a hash of the actual cache key specified by the user):

[WebMethod(Description="Adds a new item to the specified cache partition.")]

public String AddNewItem(string partitionName, string storageKey,

                         string[] cachedItemInfo, string base64ItemBytes)

{

  // create partition folder path and check if it exists

  String filePath = Path.Combine(cacheRootFilePath, partitionName);

  if (!Directory.Exists(filePath))

  {

    try

    {

      Directory.CreateDirectory(filePath);

    }

    catch (Exception ex)

    {

      return "Cannot create partition folder" + ex.Message;

    }

  }

  // create path and file names for information and data files

  String infoFile = Path.Combine(filePath,

                    String.Concat(storageKey.ToString(), infoExtension));

  String dataFile = Path.Combine(filePath,

                    String.Concat(storageKey.ToString(), dataExtension));

  ...

 

The next stage is to write the files to the disk. The "information" file is easy because the File.WriteAllLines method accepts a String Array and writes it as a series of text lines separated by carriage returns. For the "data" file, the code first converts the incoming Byte64-encoded string to a Byte Array, and then calls the File.WriteAllBytes method:

  ...

  try

  {

    // create information file

    if (File.Exists(infoFile))

    {

      File.Delete(infoFile);

    }

    File.WriteAllLines(infoFile, cachedItemInfo);

  }

  catch (Exception ex)

  {

    return "Cannot create information file" + ex.Message;

  }

  // decode object from Base64 string and write to data file

  Byte[] itemBytes = Convert.FromBase64String(base64ItemBytes);

  try

  {

    // create data file

    if (File.Exists(dataFile))

    {

      File.Delete(dataFile);

    }

    File.WriteAllBytes(dataFile, itemBytes);

    return String.Empty;

  }

  catch (Exception ex)

  {

    return "Cannot create data file '" + dataFile + "'. " + ex.Message;

  }

}

 

Notice that the code first deletes any existing file with the same name (storage key), and creates the "information" file before the "data" file. As the methods to count and retrieve cached items look for the "data" files, it will not affect the caching mechanism if an error writing the "data" file leaves an orphaned "information" file in the cache. Meanwhile, should any errors occur, the method generates suitable message and returns this as a String to the Caching Application Block proxy.

As you can guess, removing an item from the cache simply means deleting the appropriate "information" and "data" files. To comply with the rules for cache backing store providers, the Remove method reports an error if the specified cache item does not exist, while the RemoveOldItem method does not. The Flush method deletes all files in the specified partition (subfolder).

The UpdateLastAccessedTime method also has a simple task. It takes the DateTime instance passed from the backing store provider, reads the array of information strings from the "information" file, updates the relevant item in the array, and writes it back to the disk. This is the core section of code for the process:

// get existing information array and update it

String[] infoData = File.ReadAllLines(infoFile);

infoData[1] = timestamp.ToString();

File.Delete(infoFile);

File.WriteAllLines(infoFile, infoData);

 

Therefore, even when cached items are being accessed regularly by the client, the bandwidth and processing requirements of the Web Service Caching mechanism are minor.

Loading Cached Items

As you saw in the example backing store provider earlier in this article, the most arduous task that the system must perform is loading all the cached items when the Caching Application Block first initializes. The example Caching Web Service must read all the "information" and "data" files containing cached items from the specified partition subfolder, and generate a String Array in the format documented earlier in this article.

However, LoadDataFromStore is also the first method called by the backing store provider when it initializes, and so the code within this method must create the specified subfolder if it does not already exist. This also indicates to the client if there is a problem (such as the folder being read-only or unavailable, or the Web Service account not having sufficient permissions) right at the start of the process before the user attempts to cache any data:

[WebMethod(Description="Returns a Srting array containing all the cached

                        items for the specified partition.")]

public string[] LoadDataFromStore(string partitionName)

{

  // create String array to hold error message

  String[] errorMessage = new String[1];

  // create partition folder path and check if it exists

  String filePath = Path.Combine(cacheRootFilePath, partitionName);

  if (!Directory.Exists(filePath))

  {

    // this method is called when the Caching App Block first

    // accesses the cache, and so the code must create the directory

    // for the specified partition to allow the block to work with it

    try

    {

      Directory.CreateDirectory(filePath);

    }

    catch (Exception ex)

    {

      errorMessage[0] = "Cannot create partition folder" + ex.Message;

      return errorMessage;

    }

  }

  ...

 

Notice how the code creates a one-dimensional String Array to hold any error message at the start of the process, and returns this if an error occurs. The backing store provider uses the array size as an indicator that an error occurred in the Web Service, and extracts the message it contains.

The next stage is to get a list of all the cached items using the Directory.GetFiles method:

  ...

  // create file specification to search for cache files

  String searchString = String.Concat("*", dataExtension);

  String[] cacheFiles;

  try

  {

    // get a list of cache data files

    cacheFiles = Directory.GetFiles(filePath, searchString,

                                    SearchOption.TopDirectoryOnly);

  }

  catch (Exception ex)

  {

    errorMessage[0] = "Cannot access cache partition" + ex.Message;

    return errorMessage;

  }

  ...

 

Now the code iterates through the array of file names extracting the required data from each one and adding it to a new String Array that it will return from the method. The "information" file provides an array containing the key, "last-accessed" date/time, and cache duration that the code can insert directly into the return String Array. The contents of the "data" file are Base64-encoded and then inserted into the array. When all the cached items have been processed, the code returns the array to the backing store provider:

  ...

  if (cacheFiles.Length > 0)

  {

    try

    {

      // create String array to contain cached item information and data

      // array contains four entries for each cache item and repeats

      // in multiples of 4:

      // @ Index + 0 = cache key

      // @ Index + 1 = last accessed date/time

      // @ Index + 2 = duration (seconds)

      // @ Index + 3 = Base64 encoded data

      String[] cachedItems = new String[cacheFiles.Length * 4];

      Int32 itemIndex = 0;

      // iterate through cached files adding data to String array

      foreach (String cacheFile in cacheFiles)

      {

        // create path and file name for information file

        String infoName = String.Concat(

               Path.GetFileNameWithoutExtension(cacheFile), infoExtension);

        String infoPath = Path.Combine(filePath, infoName);

        // read from information file

        // does not support callbacks or priorities - uses standard values

        String[] infoData = File.ReadAllLines(infoPath);

        cachedItems[itemIndex] = infoData[0];

        cachedItems[itemIndex + 1] = infoData[1];

        cachedItems[itemIndex + 2] = infoData[2];

        // base64-encode object from .cachefile file

        Byte[] itemBytes

               = File.ReadAllBytes(Path.Combine(filePath, cacheFile));

        cachedItems[itemIndex + 3] = Convert.ToBase64String(itemBytes);

        itemIndex += 4;

      }

      return cachedItems;

    }

    catch (Exception ex)

    {

      errorMessage[0] = "Error reading cached items" + ex.Message;

      return errorMessage;

    }

  }

  else

  {

    return null;   // no cached items

  }

}

 

After you finish building the Web Service, you can test it by opening it in a browser, as shown in Figure 4. You can test all of the methods except for the AddNewItem method, although with no cached items you will generally only get error messages. However, the LoadDataFromStore method will create the specified partition subfolder even though there are no items to return.

Figure 4 – Viewing the Caching Web Service in a browser, showing the methods it exposes.

Using the Web Service Cache Provider in ASP.NET

The example code for this article contains a simple ASP.NET application that you can use to test the Web Service-based caching mechanism. The default configuration specifies two instances of the custom Web Service Caching provider, with different values for the partition name.

 

This is the relevant section of the Web.config file for the test Web site, showing the values for the two Web Service Cache backing store providers (note that some lines have wrapped in the listing due to the limitations of page width):

<backingStores>

  <add webServiceUrl="http://localhost/SampleCacheWebService

                      /SampleCachingWebService.asmx"

       partitionName="FirstTestPartition" defaultCacheDuration="7200"

       encryptionProviderName=""

       type="Microsoft.Practices.EnterpriseLibrary.Caching

             .BackingStoreImplementations.CustomWebServiceBackingStore,

              Microsoft.Practices.EnterpriseLibrary.Caching,

              Version=3.0.0.0, Culture=neutral, PublicKeyToken=null"

       name="CustomWebServiceManager_1" />

  <add webServiceUrl=" http://localhost/SampleCacheWebService

                      /SampleCachingWebService.asmx "

       partitionName="AnotherPartition" defaultCacheDuration="600"

       encryptionProviderName=""

       type="Microsoft.Practices.EnterpriseLibrary.Caching

             .BackingStoreImplementations.CustomWebServiceBackingStore,

              Microsoft.Practices.EnterpriseLibrary.Caching,

              Version=3.0.0.0, Culture=neutral, PublicKeyToken=null"

       name="CustomWebServiceManager_2" />

</backingStores>

 

The page Default.aspx (in the TestWebSite subfolder of the samples) contains two option buttons that allow you to select which of the two configured instances of the custom Web Service Caching provider you want to use. This is followed by a series of buttons and text boxes where you can enter the name of the cache key and (for a text or numeric type) the value to cache.

The values and objects you can cache are a String, a Double, an Object Array, and a DataSet. Each has different cache durations, with the Object Array using the default duration setting of the selected provider.

When you click one of the buttons to cache a value or an object, the page displays the number of items currently in the selected cache partition, as you can see in Figure 5.

Figure 5 – The example ASP.NET application that uses the custom Web Service-based caching mechanism.

The test page also allows you to retrieve and display an item you previously cached, or remove it from the cache, and the page again displays the number of items currently in the cache. You can also remove all items from the cache. If you try to retrieve or remove an item that does not exist (either because you already removed it or it has expired), the page displays a suitable error message. It also displays details of any other exceptions that may occur – for example, if you specify an incorrect URL for a provider or the provider encounters an error.

Adding Items to the Cache in the Example Application

The code in the code-behind file of the example page that adds items to the cache is relatively simple. For example, this is the handler for the first button on the page, which caches a String value:

protected void btn_CacheString_Click(object sender, EventArgs e)

{

  try

  {

    // create the selected cache manager instance

    CacheManager cm

           = CacheFactory.GetCacheManager(optCacheManager.SelectedValue);

    // cache the item with a short sliding expiry duration

    cm.Add(txtStringKey.Text, txtStringValue.Text, CacheItemPriority.Normal,

           null, new SlidingTime(new TimeSpan(0, 0, 20)));

    // display number of items in cache and set list items

    DisplayResultsAndUpdateButtonLists(cm.Count, txtStringKey.Text);

  }

  catch (Exception ex)

  {

    lblResults.Text = "<b>ERROR</b>: " + ex.Message;

  }

}

 

The code creates an instance of the appropriate CacheManager by using the value selected in the RadioButtonList control at the top of the page. It then calls the Add method, specifying the name of the cache key and the value from the text boxes on the page, the cache priority (although this implementation of the provider always uses Normal priority, irrespective of the value you specify here), null for the call-back parameter, and a 20 second sliding time expiry value.

The final step is a call to the DisplayResultsAndUpdateButtonLists routine located at the end of the code for the page, passing to it the number of items in the cache (obtained from the Count property of the CacheManager) and the name of the key for the item just added. The routine, shown next, uses these values to display the number of items in the cache, update the two lists of cache keys for the "Retrieve" and "Remove" buttons, and set the Enabled property of these two buttons to the appropriate value:

private void DisplayResultsAndUpdateButtonLists(Int32 cacheCount,

                                                String keyText)

{

  // display number of items in cache

  lblResults.Text = String.Format("Cache '{0}' contains {1} item(s).",

                    optCacheManager.SelectedValue, cacheCount.ToString());

  // add the current key to the "Retrieve" list

  if (! lstRetrieveItem.Items.Contains(new ListItem(keyText)))

  {

    lstRetrieveItem.Items.Add(keyText);

  }

  // set enabled state of "Retrieve" button

  btn_Retrieve.Enabled = (lstRetrieveItem.Items.Count != 0);

  // add the current key to the "Remove" list

  if (!lstRemoveItem.Items.Contains(new ListItem(keyText)))

  {

    lstRemoveItem.Items.Add(keyText);

  }

  // set enabled state of "Remove" button

  btn_Remove.Enabled = (lstRemoveItem.Items.Count != 0);

}

 

The button handlers that add a numeric value (a Double), an Object Array, and a DataSet to the cache are much the same as that for the String value. The handler for a numeric value converts the String from the text box into a Double type using the Double.Parse method, while the code in the handler that caches an Object Array just creates this array before caching it. Notice that it takes advantage of the short form of the Add method, which uses the default values for the cache priority and duration:

...

// populate an Object Array

Object[] vals = new Object[3];

vals[0] = "Some text";

vals[1] = 42;

vals[2] = DateTime.Now;

// create the selected cache manager instance

CacheManager cm

    = CacheFactory.GetCacheManager(optCacheManager.SelectedValue);

// cache the item with the default sliding expiry duration

cm.Add(txtArrayKey.Text, vals);

...

 

The code that caches a DataSet creates the DataSet by loading an XML schema and an XML document from the xmldata subfolder of the examples.

Retrieving Items from the Cache in the Example Application

The handler for the button that retrieves items from the cache is somewhat more complex, because it must discover the type of the cached item and convert it for display. The GetData method of the CacheManager class returns all cached objects as an Object reference type.

After creating the appropriate CacheManager instance, the code first checks if an item having the key selected in the drop-down list next to the "Retrieve" button actually is in the cache. This saves a call to the backing store methods if it is not already in the synchronized in-memory cache. If it does exist, the code calls the GetData method, and then uses a multiple if statement to check the type and display it in the appropriate way:

protected void btn_Retrieve_Click(object sender, EventArgs e)

{

  try

  {

    // create the selected cache manager instance

    CacheManager cm

        = CacheFactory.GetCacheManager(optCacheManager.SelectedValue);

    // retrieve the item with the specified key

    String itemKey = lstRetrieveItem.SelectedValue;

    // see if the item is in the cache

    if (cm.Contains(itemKey))

    {

      // check the type and display as appropriate

      Object item = cm.GetData(itemKey);

      if (item is DataSet)

      {

        // bind to GridView control

        GridView1.DataSource = item as DataSet;

        GridView1.DataBind();

      }

      else if (item is Object[])

      {

        // iterate array displaying values

        StringBuilder builder

            = new StringBuilder("Retrieved item values are: ");

        foreach (Object arrayItem in (Object[])item)

        {

          builder.Append(arrayItem.ToString());

          builder.Append(", ");

        }

        lblResults.Text = builder.ToString();

      }

      else

      {

        // display the value

        lblResults.Text = String.Format("Retrieved item is '{0}'",

                                         item.ToString());

      }

    }

    else

    {

      lblResults.Text = String.Format("Cache does not contain an item"

                      + " with key '{0}'.", itemKey);

    }

  }

  catch (Exception ex)

  {

    lblResults.Text = "<b>ERROR</b>: " + ex.Message;

  }

}

 

Figure 6 shows the result of retrieving the Object Array from the cache.

Figure 6 – Retrieving a cached Object Array in the example ASP.NET application that uses the custom Web Service-based caching mechanism.

Removing Items from the Cache in the Example Application

The example application allows you to remove individual items from the cache by specifying a cache key, or flush the cache to remove all items. As you will have guessed, the code in the handlers for the "Remove" and "Remove all items" buttons just has to call the appropriate method of the CacheManager.

To remove a single item, the code creates an instance of the specified CacheManager, calls the Contains method to see if the item is in the cache, and – if it is – calls the Remove method with the cache key selected in the list next to this button:

...

// create the selected cache manager instance

CacheManager cm

    = CacheFactory.GetCacheManager(optCacheManager.SelectedValue);

// remove the item with the specified key

String itemKey = lstRemoveItem.SelectedValue;

if (cm.Contains(itemKey))

{

  cm.Remove(itemKey);

}

else

{

  lblResults.Text = String.Format("Cache does not contain an item "

                                  + "with key '{0}'.", itemKey);

}

// display number of items in cache

lblResults.Text = String.Format("Cache '{0}' contains {1} item(s).",

                  optCacheManager.SelectedValue, cm.Count.ToString());

...

 

Code in the handler for the "Remove all items" button just creates an instance of the specified CacheManager and calls the Flush method:

...

// create the selected cache manager instance

CacheManager cm

    = CacheFactory.GetCacheManager(optCacheManager.SelectedValue);

// clear the cache

cm.Flush();

// display number of items in cache

lblResults.Text = String.Format("Cache '{0}' contains {1} item(s).",

                  optCacheManager.SelectedValue, cm.Count.ToString());

// disable "Retrieve" and "Remove" buttons

btn_Retrieve.Enabled = false;

btn_Remove.Enabled = false;

...

 

Notice that, in all the routines in the test Web Site, there is no need to specify the cache partition name. The partition name, like the URL of the target Caching Web Service, is part of the backing store provider configuration. Therefore, by specifying the backing store provider you want to use in the GetCacheManager method, the system automatically reads from and writes to the correct cache partition.

You can add multiple Cache Managers and Cache Backing Store providers to an application configuration, including providers of different types, to meet almost any combination of caching requirements. Where you need to support multiple Caching Web Services, and multiple cache partitions, you just add a separate Web Service Caching Provider for each combination.

Summary

This article describes the implementation of a mechanism for extending the capabilities of the Enterprise Library Caching Application Block to support almost any scenario for caching data remotely. While the project was originally a "blue sky" concept implemented purely to see if it was possible, it does provide an interesting (and possibly useful) mechanism that you may like to take advantage of in your applications.

The article describes the implementation of a custom Cache Backing Store provider that communicates with a Web Service through a custom proxy integrated into Enterprise Library. The Web Service implements an interface similar to that of the standard Caching Application Block backing store providers, but allows you to perform the actual caching in any location accessible to the Web Service.

While there are some limitations in the current implementation, such as a restricted set of cache priorities and the requirement for anonymous Web Service access, you could easily add the features you require to the base model described here. 

 

©2007 Alex Homer, Stonebroom Limited, http://www.stonebroom.com