This tutorial shows how to implement an Azure Cache for Redis using the LEADTOOLS JS Document Service Demo.
Overview | |
---|---|
Summary | This tutorial covers how to use an Azure Redis Cache in a LEADTOOLS Document Library application. |
Completion Time | 30 minutes |
Platform | .NET 6 |
IDE | Visual Studio 2022 |
Runtime License | Download LEADTOOLS |
As a fully-fledged example, it can be used in a production environment. However, it requires saving large binary data directly to the cache. Consequently, it may not be suitable if the documents are to be accessible from multiple servers due to the increase of re-syncing time. In such cases, refer to Implement an Azure Redis Cache with Storage Blobs for a better approach.
Before any functionality from the SDK can be leveraged, a valid runtime license will have to be set.
For instructions on how to obtain a runtime license refer to Obtaining a License.
The functionality in this tutorial is built on the functionality of the .NET Document Service and JS Document Viewer. To open the project, navigate to <INSTALL_DIR>\Examples\Viewers\JS\DocumentViewerDemo
and open the DocumentViewerDemo.sln
solution file.
This example code requires the following NuGet packages:
StackExchange.Redis
(included as part of the ASP.NET DocumentService project) Newtonsoft.Json
(included as part of the ASP.NET DocumentService project)
RedisObjectCache
ClassIn the Solution Explorer window right-click Class1.cs
and select Rename
from the context menu. Type RedisMemoryCache.cs
and press Enter.
Open the RedisObjectCache.cs
file and add the following using statements to the top.
using Leadtools;
using Leadtools.Caching;
using Leadtools.Codecs;
using Leadtools.Svg;
using Newtonsoft.Json;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.IO;
In the Leadtools.Services.Tools.Cache
namespace define the RedisObjectCache
class and add it to the ObjectCache
class derivation list. Your class should look like the following.
/// <summary>
/// Wraps a Redis Cache IDatabase object to be used with the LEADTOOLS Document Library
/// </summary>
namespace Leadtools.Services.Tools.Cache
{
public class RedisObjectCache : ObjectCache
{
}
}
Implement the below functions into the RedisObjectCache
class, as shown below.
public class RedisObjectCache : ObjectCache
{
private CacheItemPolicy _policy;
private RedisObjectCache() { }
/// <summary>
/// Initializes a LEADTOOLS Object Cache wrapper from a Redis cache database object.
/// </summary>
/// <param name="cache">Fully-initialized Redis database object ready to be used.</param>
public RedisObjectCache(StackExchange.Redis.IDatabase cache)
{
this.Cache = cache;
}
/// <summary>
/// The Redis cache database object being used.
/// </summary>
public StackExchange.Redis.IDatabase Cache { get; private set; }
// --------------------------------------------------------------------------------------
// These members must be implemented by our class and are called by the Document toolkit
// --------------------------------------------------------------------------------------
public override string Name
{
get
{
return "Redis Object Cache";
}
}
public override CacheSerializationMode PolicySerializationMode
{
get
{
// Redis do not use this so we will just assume it is binary
return CacheSerializationMode.JSON;
}
set { throw new NotSupportedException(); }
}
public override CacheSerializationMode DataSerializationMode
{
get
{
// Binary meaning we will do our own serialization
return CacheSerializationMode.JSON;
}
set { throw new NotSupportedException(); }
}
public override DefaultCacheCapabilities DefaultCacheCapabilities
{
get
{
// We do not support serialization in .Net6, so set this to "None".
return DefaultCacheCapabilities.None;
}
}
public override CacheItemPolicy GetPolicy(string key, string regionName)
{
return base.GetPolicy(key, regionName);
}
public override CacheItem<T> AddOrGetExisting<T>(CacheItem<T> item, CacheItemPolicy policy)
{
// Method called when a cache item is added.
// Must return the old value
// Resolve the key. Remember, we do not have regions
var resolvedKey = ResolveCacheKey(item.RegionName, item.Key);
CacheItem<T> oldItem = null;
// Get the old value (if any)
if (this.Cache.KeyExists(resolvedKey))
{
RedisValue existingValue = this.Cache.StringGet(resolvedKey);
var oldValue = GetFromCache<T>(existingValue, item.RegionName, item.Key);
oldItem = new CacheItem<T>(item.Key, (T)oldValue, item.RegionName);
}
// Add new value
AddToCache(item, policy);
// Return old item
return oldItem;
}
public override CacheItem<T> GetCacheItem<T>(string key, string regionName)
{
// If we have an item with this key, return it. Otherwise, return null
var resolvedKey = ResolveCacheKey(regionName, key);
CacheItem<T> item = null;
if (this.Cache.KeyExists(resolvedKey))
{
RedisValue value = this.Cache.StringGet(resolvedKey);
var itemValue = GetFromCache<T>(value, regionName, key);
item = new CacheItem<T>(key, (T)itemValue, regionName);
}
return item;
}
public override bool Contains(string key, string regionName)
{
// Check if the key is in the dictionary
var resolvedKey = ResolveCacheKey(regionName, key);
var exists = this.Cache.KeyExists(resolvedKey);
return exists;
}
public override bool UpdateCacheItem<T>(CacheItem<T> item)
{
// Update the item
if (item == null)
throw new ArgumentNullException("item");
var resolvedKey = ResolveCacheKey(item.RegionName, item.Key);
var exists = this.Cache.KeyExists(resolvedKey);
if (exists)
{
AddToCache(item, null);
}
return exists;
}
public override T Remove<T>(string key, string regionName)
{
// Remove if found, return old value
T existingValue = default(T);
var resolvedKey = ResolveCacheKey(regionName, key);
if (this.Cache.KeyExists(resolvedKey))
{
RedisValue value = this.Cache.StringGet(resolvedKey);
existingValue = (T)GetFromCache<T>(value, regionName, key);
}
// Delete it
DeleteItem(key, regionName);
return existingValue;
}
public override void DeleteItem(string key, string regionName)
{
// Delete if found
DeleteFromCache(regionName, key);
}
public override void UpdatePolicy(string key, CacheItemPolicy policy, string regionName)
{
// Redis Cache does not allow us to update the expiration policy of an item.
}
private static string ResolveCacheKey(string regionName, string key)
{
// Both must be non-empty strings
if (string.IsNullOrEmpty(regionName)) throw new InvalidOperationException("Region name must be a non-empty string");
if (string.IsNullOrEmpty(key)) throw new InvalidOperationException("Region key name must be a non-empty string");
// regionName might not be unique, key might not be unique, but combine them and we are guaranteed a unique key
return regionName + "-" + key;
}
// LEADTOOLS Document Library will call us with the following items:
// - Native types that are compatible with Redis: strings, byte arrays and JSON serializable objects. For these, we will just pass the along
// - RasterImage or SvgDocument, these are not compatible with Redis and we must serialize them first
private void AddToCache<T>(CacheItem<T> item, CacheItemPolicy policy)
{
// Get a Redis value from our item data
byte[] blob = null;
bool hasBlob = false;
string json = null;
var typeOfT = typeof(T);
if (typeOfT == typeof(RasterImage))
{
blob = ImageToBlob(item.Value as RasterImage);
hasBlob = true;
}
else if (typeOfT == typeof(SvgDocument))
{
blob = SvgToBlob(item.Value as SvgDocument);
hasBlob = true;
}
else if (typeOfT == typeof(byte[]))
{
blob = item.Value as byte[];
hasBlob = true;
}
else
{
// JSON serialize it
json = JsonConvert.SerializeObject(item.Value);
hasBlob = false;
}
// If the sliding expiration is used, make it the absolute value
TimeSpan? expiry = null;
if (policy != null)
{
var expiryDate = policy.AbsoluteExpiration;
if (policy.SlidingExpiration > TimeSpan.Zero)
{
expiryDate = DateTime.UtcNow.Add(policy.SlidingExpiration);
}
// Now, we have a date, convert it to time span from now (all UTC)
expiry = expiryDate.Subtract(DateTime.UtcNow);
}
var resolvedKey = ResolveCacheKey(item.RegionName, item.Key);
// Set the cache item value
if (hasBlob)
this.Cache.StringSet(resolvedKey, blob, expiry);
else
this.Cache.StringSet(resolvedKey, json, expiry);
}
private object GetFromCache<T>(RedisValue value, string regionName, string key)
{
var typeOfT = typeof(T);
object result = null;
if (typeOfT == typeof(RasterImage) || typeOfT == typeof(SvgDocument) || typeOfT == typeof(byte[]))
{
// Read the blob
byte[] blob = (byte[])value;
if (typeOfT == typeof(RasterImage))
{
result = ImageFromBlob(blob);
}
else if (typeOfT == typeof(SvgDocument))
{
result = SvgFromBlob(blob);
}
else
{
result = (byte[])blob;
}
}
else
{
// JSON deserialize it
result = JsonConvert.DeserializeObject<T>(value);
}
return result;
}
private void DeleteFromCache(string regionName, string key)
{
var resolvedKey = ResolveCacheKey(regionName, key);
if (this.Cache.KeyExists(resolvedKey))
this.Cache.KeyDelete(resolvedKey);
}
// Helper methods to convert RasterImage or SvgDocument objects from/to byte[]
private static byte[] ImageToBlob(RasterImage image)
{
if (image == null)
return null;
// Save as PNG into a memory stream, use the byte[] data
using (var rasterCodecs = new RasterCodecs())
{
using (var ms = new MemoryStream())
{
rasterCodecs.Save(image, ms, RasterImageFormat.Png, 0);
return ms.GetBuffer();
}
}
}
private static RasterImage ImageFromBlob(byte[] blob)
{
if (blob == null || blob.Length == 0)
return null;
// Load to a raster image, using the byte[] data
using (var rasterCodecs = new RasterCodecs())
{
using (var ms = new MemoryStream(blob))
{
return rasterCodecs.Load(ms, 1);
}
}
}
private static byte[] SvgToBlob(SvgDocument svg)
{
if (svg == null)
return null;
using (var ms = new MemoryStream())
{
svg.SaveToStream(ms, null);
return ms.GetBuffer();
}
}
private static SvgDocument SvgFromBlob(byte[] blob)
{
if (blob == null || blob.Length == 0)
return null;
return SvgDocument.LoadFromMemory(blob, 0, blob.Length, null);
}
}
Append the code below to the RedisObjectCache
class to override functions we do not support for the Document Viewer. Each of these functions will throw a NotSupportedException
.
//
// These members must be over-ridden by our class but are never called by the Document toolkit
// So just throw a not supported exception
//
// This is for default region support. We do not have that
public override object this[string key]
{
get
{
throw new NotSupportedException();
}
set
{
throw new NotSupportedException();
}
}
// Delete a region in one shot. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.CacheRegions. Since we do not, the caller is responsible for
// calling DeleteAll passing all the items of the region (which in turn will call DeleteItem for each)
public override void DeleteRegion(string regionName)
{
throw new NotSupportedException();
}
// Begin adding an external resource. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.ExternalResources
public override Uri BeginAddExternalResource(string key, string regionName, bool readWrite)
{
throw new NotSupportedException();
}
// End adding an external resource. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.ExternalResources
public override void EndAddExternalResource<T>(bool commit, string key, T value, CacheItemPolicy policy, string regionName)
{
throw new NotSupportedException();
}
// Get the item external resource. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.ExternalResources
public override Uri GetItemExternalResource(string key, string regionName, bool readWrite)
{
throw new NotSupportedException();
}
// Remove the item external resource. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.ExternalResources
public override void RemoveItemExternalResource(string key, string regionName)
{
throw new NotSupportedException();
}
// Get the item virtual directory path. We do not support that
// Note: This is only called if we have DefaultCacheCapabilities.VirtualDirectory
public override Uri GetItemVirtualDirectoryUrl(string key, string regionName)
{
throw new NotSupportedException();
}
// Getting the number of items in the cache. We do not support that
public override long GetCount(string regionName)
{
throw new NotSupportedException();
}
// Statistics. We do not support that
public override CacheStatistics GetStatistics()
{
throw new NotSupportedException();
}
// Statistics. We do not support that
public override CacheStatistics GetStatistics(string key, string regionName)
{
throw new NotSupportedException();
}
// Getting all the values. We do not support that
public override IDictionary<string, object> GetValues(IEnumerable<string> keys, string regionName)
{
throw new NotSupportedException();
}
// Enumeration of the items. We do not support that
protected override IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
throw new NotSupportedException();
}
// Enumeration of the keys. We do not support that
public override void EnumerateKeys(string region, EnumerateCacheEntriesCallback callback)
{
throw new NotSupportedException();
}
// Enumeration of regions. We do not support that
public override void EnumerateRegions(EnumerateCacheEntriesCallback callback)
{
throw new NotSupportedException();
}
In order to use our cache in the Document Service, we need to create a CacheManager
object to handle the communication between the service and the cache.
In the Solution Explorer window, right-click the Class1.cs
and select Rename from the context menu. Type RedisCacheManager.cs
and press Enter. Add the following references to the top of the class file.
using Leadtools.Caching;
using Leadtools.Document;
using Leadtools.Services.Tools.Helpers;
using StackExchange.Redis;
using System;
using System.Diagnostics;
using System.Xml.Linq;
In the Leadtools.Services.Tools.Cache
namespace define the RedisCacheManger
class and add it to the ICacheManager
derivation list.
namespace Leadtools.Services.Tools.Cache
{
public class RedisCacheManager : ICacheManager
{
}
}
Update your RedisCacheManager
class to match the one shown below.
public class RedisCacheManager : ICacheManager
{
private ObjectCache _objectCache;
private CacheItemPolicy _cacheItemPolicy;
private bool _isInitialized = false;
ConnectionMultiplexer connection;
public string WebRootPath { get; set; }
public const string CACHE_NAME = "RedisCache";
public long RedisCacheSlidingExpiryPolicySeconds { get; set; } = 1 * 60 * 60; //sec, min, hours //1 hour total default
public RedisCacheManager(string WebRootPath, XElement cacheManagerElement)
{
if (string.IsNullOrEmpty(WebRootPath))
throw new ArgumentNullException(nameof(WebRootPath));
_isInitialized = false;
this.WebRootPath = WebRootPath;
ParseXml(cacheManagerElement);
}
public void ParseXml(XElement cacheManagerElement)
{
// Nothing to parse
}
private const string CACHE_MANAGER_NAME = "RedisCacheManager";
//Get name of cache system
public string Name
{
get { return CACHE_MANAGER_NAME; }
}
//Get caches supported by manager
public string[] GetCacheNames()
{
return new string[] { CACHE_NAME };
}
//Checks if cache is ready to be used
public bool IsInitialized
{
get { return _isInitialized; }
}
//Initializes manager from the config file, creating the apporpriate ObjectCaches
// Called from service startup
public void Initialize()
{
Trace.WriteLine("Initializing azure redis cache from configuration");
_objectCache = InitializeRedisCache();
_objectCache.SetName(CACHE_NAME);
//if (_objectCache.Name != CACHE_NAME)
//throw new InvalidOperationException($"ObjectCache implementation {_objectCache.GetType().FullName} does not override SetName");
DocumentFactory.LoadDocumentFromCache += LoadDocumentFromCacheHandler;
_cacheItemPolicy = InitializePolicy(_objectCache);
_isInitialized = true;
}
private void LoadDocumentFromCacheHandler(object sender, ResolveDocumentEventArgs e)
{
// Get the cache for the document if we have it
ObjectCache objectCache = GetCacheForDocument(e.LoadFromCacheOptions.DocumentId);
if (objectCache != null)
e.LoadFromCacheOptions.Cache = objectCache;
}
//Cleanup the manager, called when service is shutdown
public void Cleanup()
{
if (!_isInitialized)
return;
_objectCache = null;
_cacheItemPolicy = null;
_isInitialized = false;
//Deletes all entries from cached
var endpoints = connection.GetEndPoints();
foreach(var point in endpoints)
{
var server = connection.GetServer(point);
server.FlushAllDatabases();
}
}
private ObjectCache InitializeRedisCache()
{
string cacheConfigFile = ServiceHelper.GetSettingValue(ServiceHelper.Key_Cache_ConfigFile);
cacheConfigFile = ServiceHelper.GetAbsolutePath(cacheConfigFile);
if (string.IsNullOrEmpty(cacheConfigFile))
throw new InvalidOperationException($"The cache configuration file location in '{ServiceHelper.Key_Cache_ConfigFile}' in the configuration file is empty");
ObjectCache objectCache = null;
try
{
var configuration = "YOUR_CACHE_ADDRESS,password=YOUR_PASSWORD,abortConnect=false";
var configurationOptions = ConfigurationOptions.Parse(configuration);
// Increase the sync-timeout since we may be storing large binaries.
// Note that this is not required for RedisWithBlobsObjectCache implementation.
configurationOptions.SyncTimeout = 5000;
connection = ConnectionMultiplexer.Connect(configurationOptions);
IDatabase redisDatabase = connection.GetDatabase();
// Create the LEADTOOLS ObjectCache wrapper
objectCache = new RedisObjectCache(redisDatabase);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Cannot load cache configuration from '{cacheConfigFile}'", ex);
}
return objectCache;
}
public CacheItemPolicy InitializePolicy(ObjectCache objectCache)
{
var policy = new CacheItemPolicy();
//policy.AbsoluteExpiration = ObjectCache.InfiniteAbsoluteExpiration;
policy.SlidingExpiration = TimeSpan.FromSeconds(RedisCacheSlidingExpiryPolicySeconds);
return policy;
}
private static void VerifyCacheName(string cacheName, bool allowNull)
{
if (cacheName == null && allowNull)
return;
if (CACHE_NAME != cacheName)
throw new ArgumentException($"Invalid cache name: {cacheName}", nameof(cacheName));
}
//Checks that cache are configured correctly
public void CheckCacheAccess(string cacheName)
{
Console.WriteLine("Made it to check!");
CacheItemPolicy policy = null;
}
//Makes policy for the specified cache by name
public CacheItemPolicy CreatePolicy(string cacheName)
{
VerifyCacheName(cacheName, false);
return _cacheItemPolicy.Clone();
}
//Makes cache item policy for the specifies cache
public CacheItemPolicy CreatePolicy(ObjectCache redisCache)
{
// Get the name of this cache
string cacheName = GetCacheName(redisCache);
if (cacheName == null)
throw new InvalidOperationException("Invalid object cache");
return CreatePolicy(cacheName);
}
// Gets the cache stats
public CacheStatistics GetCacheStatistics(string cacheName)
{
return null;
}
// Cleans the cache items
public void RemoveExpiredItems(string cacheName)
{
VerifyCacheName(cacheName, false);
}
// Return the default cache.
public ObjectCache DefaultCache { get; }
// Gets a cache by name.
public ObjectCache GetCacheByName(string cacheName)
{
VerifyCacheName(cacheName, false);
return _objectCache;
}
// Gets the name of this cache.
public string GetCacheName(ObjectCache objectCache)
{
if (objectCache == null)
throw new ArgumentNullException(nameof(objectCache));
if (objectCache == _objectCache)
return CACHE_NAME;
return null;
}
// Get the cache where this document is stored.
public ObjectCache GetCacheForDocument(string documentId)
{
if (documentId == null)
throw new ArgumentNullException(nameof(documentId));
return _objectCache;
}
// Get the cache where this document is stored. If not found, return the default cache
public ObjectCache GetCacheForDocumentOrDefault(string documentId)
{
ObjectCache objectCache = null;
if (!string.IsNullOrEmpty(documentId))
{
objectCache = GetCacheForDocument(documentId);
}
if (objectCache == null)
objectCache = DefaultCache;
return objectCache;
}
// Get the cache where this document is stored.
public ObjectCache GetCacheForDocument(Uri documentUri)
{
if (documentUri == null)
throw new ArgumentNullException(nameof(documentUri));
// Get the document ID from the URI and call the other version of this function
if (!DocumentFactory.IsUploadDocumentUri(documentUri))
throw new ArgumentException($"{documentUri.ToString()} is not a valid LEAD document URI", nameof(documentUri));
string documentId = DocumentFactory.GetLeadCacheData(documentUri);
return GetCacheForDocument(documentId);
}
// Get the cache where this document is stored.. If not found, return the default cache
public ObjectCache GetCacheForDocumentOrDefault(Uri documentUri)
{
ObjectCache objectCache = null;
if (documentUri != null)
{
objectCache = GetCacheForDocument(documentUri);
}
if (objectCache == null)
objectCache = DefaultCache;
return objectCache;
}
// Get the cache to store a new document, called by LoadFromUri
public ObjectCache GetCacheForLoadFromUri(Uri uri, LoadDocumentOptions loadDocumentOptions)
{
return _objectCache;
}
// Get the cache to store a new document, called by BeginUpload and Convert
public ObjectCache GetCacheForBeginUpload(UploadDocumentOptions uploadDocumentOptions)
{
return _objectCache;
}
// Get the cache to store new virtual document. Called by Create
public ObjectCache GetCacheForCreate(CreateDocumentOptions createDocumentOptions)
{
return _objectCache;
}
}
Note
The default policy expiration time is 1 hour. You can change this by altering the value of
RedisCacheSlidingExpiryPolicySeconds
in theRedisCacheManager
class.
In the InitializeRedisCache()
method update the configuration
variable to contain the string needed to connect to your Azure Cache for Redis configuration. You can find this information on the Azure Portal.
var configuration = "YOUR_CACHE_ADDRESS,password=YOUR_PASSWORD,abortConnect=false";
Add the following code snipping to where you set your cache manager in the Document Service.
public static void CreateCache()
{
// Called by InitializeService the first time the service is run
// Initialize the global ICacheManager object
// ICacheManager cacheManager = null;
RedisCacheManager cacheManager = new RedisCacheManager(WebRootPath, null);
// See if we have a CacheManager configuration file
/*string cacheManagerConfigFile = GetSettingValue(Key_Cache_CacheManagerConfigFile);
cacheManagerConfigFile = GetAbsolutePath(cacheManagerConfigFile);
if (!string.IsNullOrEmpty(cacheManagerConfigFile))
{
using (var stream = File.OpenRead(cacheManagerConfigFile))
cacheManager = CacheManagerFactory.CreateFromConfiguration(stream, WebRootPath);
}
else
{
// Try to create the default ICacheManager directly (backward compatibility)
cacheManager = new DefaultCacheManager(WebRootPath, null);
}
*/
if (cacheManager == null)
throw new InvalidOperationException("Could not find a valid LEADTOOLS cache system configuration.");
cacheManager.Initialize();
_cacheManager = cacheManager;
}
Note
By default the cache variable is set inside the
ServiceHelper
class in theCreateCache
method, so update theCreateCache
method to match the one shown below. If you are setting up this cache without a cache manager, just load theRedisObjectCache
into theObjectCache
variable in your project.
If you followed all these steps correctly you can start up the JS Document Viewer
and load and save documents from the cache.
You can see more information about the caching process by using the console for this Azure Cache for Redis on the Azure Portal.
For your reference, you can download the RedisCacheManager.cs
and RedisObjectCache.cs
files here.
This tutorial showed how to set up an Azure Redis Cache for the LEADTOOLS Document Library in a .NET application.