I originally published this post on my blog a couple of weeks ago. It's part of a content collaboration with Alachisoft, NCache creators.
As we learned last time, when I covered in-memory caching with ASP.NET Core, a cache is a storage layer between an application and an external resource (a database, for example) used to speed up future requests to that resource. In this post, let's use ASP.NET Core IDistributedCache
abstractions to write a data caching layer using NCache.
What's NCache?
From NCache official page, "NCache is an Open Source in-memory distributed cache for .NET, Java, and Node.js applications."
Among other things, we can use NCache as a database cache, NHibernate 2nd-level cache, Entity Framework cache, and web cache for sessions and responses.
NCache comes in three editions: Open Source, Professional, and Enterprise. The Open Source version supports up to two nodes and its cache server is only available for .NET Framework version 4.8. For a complete list of differences, check NCache edition comparison.
One of the NCache key features is performance. Based on their own benchmarks, "NCache can linearly scale to achieve 2 million operations per second with a 5-server cache cluster."
How to install NCache on a Windows machine?
Let's see how to install an NCache server on a Windows machine. For this, we need a Windows installer and have a trial license key. Let's install NCache Enterprise, version 5.2 SP1.
After running the installer, we need to select the installation type from three options: Cache server, remote client, and Developer/QA. Let's choose Cache Server.
Then, we need to enter a license key. Let's make sure to have a license key for the same version we're installing. Otherwise, we will get an "invalid license key" error. We receive the license key in a message sent to the email address we used during registration.
Next, we need to enter the full name, email, and organization we used to register ourselves while requesting the trial license.
Then, we need to select an IP address to bind our NCache server to. Let's stick to the defaults.
Next, we need to choose an account to run NCache. Let's use the Local System Account.
Once the installation finishes, our default browser will open with the Web Manager. By default, NCache has a default cache named demoCache
.
Next time, we can fire the Web Manager by navigating to http://localhost:8251
.
NCache's official site recommends a minimum of two servers for redundancy purposes. But, for our sample app, let's use a single-node server for testing purposes.
So far, we have covered the installation instructions for a Windows machine. But, we can also install NCache in Linux and Docker containers. And, we can use NCache as virtual machines in Azure and AWS.
How to add and retrieve data from an NCache cache?
Now, we're ready to start using our NCache server from a .NET app. In Visual Studio, let's create a solution with a .NET 6 "MSTest Test Project" and a class file to learn the basic caching operations with NCache.
Connecting to an NCache cache
Before connecting to our NCache server, we need to first install the client NuGet package: Alachisoft.NCache.SDK
. Let's use the latest version: 5.2.1
.
To start a connection, we need the GetCache()
method with a cache name. For our sample app, let's use the default cache: demoCache
.
Let's start writing a test to add and retrieve movies from a cache.
using Alachisoft.NCache.Client;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace NCacheDemo.Tests;
[TestClass]
public class NCacheTests
{
[TestMethod]
public void AddItem()
{
var cacheName = "demoCache";
ICache cache = CacheManager.GetCache(cacheName);
// We will fill in the details later
}
}
If you're new to unit testing, start looking at how to write your first unit test in C# with MSTest.
Notice we didn't have to use a connection string to connect to our cache. We only used a cache name. The same one as in the Web Manager: demoCache
.
NCache uses a client.ncconf
file instead of connection strings. We can define this file at the application or installation level. For our tests, we're relying on the configuration file at the installation level. That's why we only needed the cache name.
Adding items
To add a new item to the cache, we need to use the Add()
and AddAsync()
methods with a key
and a CacheItem
to cache. The key is an identifier and the item is a wrapper for the object to cache.
Every item to cache needs an expiration. The CacheItem
has an Expiration
property for that.
There are two basic expiration types: Absolute
and Sliding
.
A cached item with Absolute
expiration expires after a given time. Let's say, a few seconds. But, an item with Sliding
expiration gets renewed every time it's accessed. If within the sliding time, we don't retrieve the item, it expires.
Let's update our test to add a movie to our cache.
using Alachisoft.NCache.Client;
using Alachisoft.NCache.Runtime.Caching;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;
namespace NCacheDemo.Tests;
[TestClass]
public class NCacheTests
{
private const string CacheName = "demoCache";
[TestMethod]
public async Task AddItem()
{
var movie = new Movie(1, "Titanic");
var cacheKey = movie.ToCacheKey();
var cacheItem = ToCacheItem(movie);
ICache cache = CacheManager.GetCache(CacheName);
// Let's add Titanic to the cache...
await cache.AddAsync(cacheKey, cacheItem);
// We will fill in the details later
}
private CacheItem ToCacheItem(Movie movie)
=> new CacheItem(movie)
{
Expiration = new Expiration(ExpirationType.Absolute, TimeSpan.FromSeconds(1))
};
}
[Serializable]
public record Movie(int Id, string Name)
{
public string ToCacheKey()
=> $"{nameof(Movie)}:{Id}";
}
Notice, we used two helper methods: ToCacheKey()
to create the key from every movie and ToCacheItem()
to create a cache item from a movie.
We used records from C# 9.0 to create our Movie
class. Also, we needed to annotate it with the [Serializable]
attribute.
Retrieving items
After adding items, let's retrieve them. For this, we need the Get<T>()
method with a key.
Let's complete our first unit test to retrieve the object we added.
using Alachisoft.NCache.Client;
using Alachisoft.NCache.Runtime.Caching;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;
namespace NCacheDemo.Tests;
[TestClass]
public class NCacheTests
{
private const string CacheName = "demoCache";
[TestMethod]
public async Task AddItem()
{
var movie = new Movie(1, "Titanic");
var cacheKey = movie.ToCacheKey();
var cacheItem = ToCacheItem(movie);
ICache cache = CacheManager.GetCache(CacheName);
await cache.AddAsync(cacheKey, cacheItem);
// Let's bring Titanic back...
var cachedMovie = cache.Get<Movie>(cacheKey);
Assert.AreEqual(movie, cachedMovie);
}
private CacheItem ToCacheItem(Movie movie)
=> new CacheItem(movie)
{
Expiration = new Expiration(ExpirationType.Absolute, TimeSpan.FromSeconds(1))
};
}
[Serializable]
public record Movie(int Id, string Name)
{
public string ToCacheKey()
=> $"{nameof(Movie)}:{Id}";
}
Updating items
If we try to add an item with the same key using the Add()
or AddAsync()
methods, they will throw an OperationFailedException
. Try to add a unit test to prove that.
To either add a new item or update an existing one, we should use the Insert()
or InserAsync()
methods instead. Let's use them in another test.
using Alachisoft.NCache.Client;
using Alachisoft.NCache.Runtime.Caching;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;
namespace NCacheDemo.Tests;
[TestClass]
public class NCacheTests
{
// Our previous test is the same
[TestMethod]
public async Task UpdateItem()
{
ICache cache = CacheManager.GetCache(CacheName);
var movie = new Movie(2, "5th Element");
var cacheKey = movie.ToCacheKey();
var cacheItem = ToCacheItem(movie);
// Let's add the 5th Element here...
await cache.AddAsync(cacheKey, cacheItem);
var updatedMovie = new Movie(2, "Fifth Element");
var updatedCacheItem = ToCacheItem(updatedMovie);
// There's already a cache item with the same key...
await cache.InsertAsync(cacheKey, updatedCacheItem);
var cachedMovie = cache.Get<Movie>(cacheKey);
Assert.AreEqual(updatedMovie, cachedMovie);
}
// Rest of the file...
}
Notice we used the InsertAsync()
method to add an item with the same key. When we retrieved it, it contained the updated version of the item.
There's another basic method: Remove()
and RemoveAsync()
. We can guess what they do. Again, try to write a test to prove that.
How to use ASP.NET Core IDistributedCache with NCache?
Up to this point, we have NCache installed and know how to add, retrieve, update, and remove items.
Let's revisit our sample application from our post about using a Redis-powered cache layer.
Let's remember the example from that last post. We had an endpoint that uses a service to access a database, but it takes a couple of seconds to complete. Let's think of retrieving complex object graphs or doing some computations with the data before returning it.
Something like this,
using DistributedCacheWithNCache.Responses;
namespace DistributedCacheWithNCache.Services;
public class SettingsService
{
public async Task<SettingsResponse> GetAsync(int propertyId)
{
// Beep, boop...Aligning satellites...
await Task.Delay(3 * 1000);
return new SettingsResponse
{
PropertyId = propertyId,
Value = "Anything"
};
}
}
Notice we emulated a database call with a 3-second delay.
Also, we wrote a set of extensions methods on top of the IDistributedCache
to add and retrieve objects from a cache.
There were the extension methods we wrote last time,
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;
namespace DistributedCacheWithNCache.Services;
public static class DistributedCacheExtensions
{
public static readonly DistributedCacheEntryOptions DefaultDistributedCacheEntryOptions
= new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60),
SlidingExpiration = TimeSpan.FromSeconds(10),
};
public static async Task<TObject> GetOrSetValueAsync<TObject>(this IDistributedCache cache,
string key,
Func<Task<TObject>> factory,
DistributedCacheEntryOptions options = null)
where TObject : class
{
var result = await cache.GetValueAsync<TObject>(key);
if (result != null)
{
return result;
}
result = await factory();
await cache.SetValueAsync(key, result, options);
return result;
}
private static async Task<TObject> GetValueAsync<TObject>(this IDistributedCache cache,
string key)
where TObject : class
{
var data = await cache.GetStringAsync(key);
if (data == null)
{
return default;
}
return JsonConvert.DeserializeObject<TObject>(data);
}
private static async Task SetValueAsync<TObject>(this IDistributedCache cache,
string key,
TObject value,
DistributedCacheEntryOptions options = null)
where TObject : class
{
var data = JsonConvert.SerializeObject(value);
await cache.SetStringAsync(key, data, options ?? DefaultDistributedCacheEntryOptions);
}
}
Notice we used Newtonsoft.Json to serialize and deserialize objects.
NCache and the IDistributedCache interface
Now, let's use a .NET 6 "ASP.NET Core Web App," those extension methods on top of IDistributedCache
, and NCache to speed up the SettingsService
.
First, we need to install the NuGet package NCache.Microsoft.Extensions.Caching
. This package implements the IDistributedCache
interface using NCache, of course.
After installing that NuGet package, we need to add the cache into the ASP.NET dependencies container in the Program.cs
file. To achieve this, we need the AddNCacheDistributedCache()
method.
// Program.cs
using Alachisoft.NCache.Caching.Distributed;
using DistributedCacheWithNCache;
using DistributedCacheWithNCache.Services;
var (builder, services) = WebApplication.CreateBuilder(args);
services.AddControllers();
// We add the NCache implementation here...
services.AddNCacheDistributedCache((options) =>
{
options.CacheName = "demoCache";
options.EnableLogs = true;
options.ExceptionsEnabled = true;
});
services.AddTransient<SettingsService>();
var app = builder.Build();
app.MapControllers();
app.Run();
Notice, we continued to use the same cache name: demoCache
. And, also we relied on a Deconstruct
method to have the builder
and services
variables deconstructed. I took this idea from Khalid Abuhakmeh's Adding Clarity To .NET Minimal Hosting APIs.
Back in the SettingsService
, we can use the IDistributedCache
interface injected into the constructor and the extension methods in the DistributedCacheExtensions
class. Like this,
using DistributedCacheWithNCache.Responses;
using Microsoft.Extensions.Caching.Distributed;
namespace DistributedCacheWithNCache.Services
{
public class SettingsService
{
private readonly IDistributedCache _cache;
public SettingsService(IDistributedCache cache)
{
_cache = cache;
}
public async Task<SettingsResponse> GetAsync(int propertyId)
{
var key = $"{nameof(propertyId)}:{propertyId}";
// Here we wrap the GetSettingsAsync method around the cache logic
return await _cache.GetOrSetValueAsync(key, async () => await GetSettingsAsync(propertyId));
}
private static async Task<SettingsResponse> GetSettingsAsync(int propertyId)
{
// Beep, boop...Aligning satellites...
await Task.Delay(3 * 1000);
return new SettingsResponse
{
PropertyId = propertyId,
Value = "Anything"
};
}
}
}
Notice, we wrapped the GetSettingsAsync()
with actual retrieving logic around the caching logic in the GetOrSetValueAsync()
. At some point, we will have the same data in our caching and storage layers.
With the caching in place, if we hit one endpoint that uses that service, we will see faster response times after the first call delayed by 3 seconds.
Also, if we go back to NCache Web Manager, we should see some activity in the server.
In this scenario, all the logic to add and retrieve items is abstracted behind the IDistributedCache
interface. That's why we didn't need to directly call the Add()
or Get<T>()
method. Although, if we take a look at the NCache source code, we will find those methods here and here.
Voilà! That's NCache and how to use it with the IDistributedCache
interface. With NCache, we have a distributed cache server with few configurations and a dashboard out-of-the-box. Also, we can add all the caching logic into decorators and have our services as clean as possible.
To follow along with the code we wrote in this post, check my Ncache Demo repository over on GitHub.
To read more content, check my Unit Testing 101 series to learn from how to write your first unit tests to mocks.
Happy coding!