Note: phiên bản Tiếng Việt của bài này ở link dưới.

https://duongnt.com/stackexchangeredis-vie

This is the second part of a three-part series about Redis-based distributed caching. You can find the other parts from here.

Among .NET Platform Extensions, we have an interface called IDistributedCache. This interface has methods to provide distributed caching to serialized values. To use this interface with Redis as the caching backend, we can use the Microsoft.Extensions.Caching.StackExchangeRedis package. You can install it with this command.

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Note that this package is different from the StackExchange.Redis package.

Set up a connection

First, we need a Redis instance to run all the test code in this article. Please see the first part for how to set up Redis inside a Docker container.

With StackExchangeRedis, integrating with Redis is very simple. We specify the Redis instance with this configuration object.

var configurationOptions = new ConfigurationOptions
{
    EndPoints = { "localhost:6379" }, // Unlike aioredis, we don't need to specify "redis://" here
    Ssl = false // Set this to true if your Redis instance can handle connection using SSL
};

ConfigurationOptions also has other settings, below are some of the more important ones in my opinion.

  • Password: a password to authenticate with Redis.
  • ConnectTimeout: the maximum time to wait to establish a connection to Redis.
  • AsyncTimout: the maximum time allows for an async operation.
  • SyncTimeout: the maximum time allows for a sync operation.

Personally, I think we should always set ConnectTimeout and AsyncTimout/SyncTimeout to a low value. Because if we have trouble connecting to Redis then the default, which is 5,000 milliseconds, can seriously clog up our processing pipeline. And a cache is supposed to be fast anyway.

After that, we can add the Redis cache service to our service collection and request an IDistributedCache instance like this.

var serviceProvider = new ServiceCollection()
    .AddStackExchangeRedisCache(options => options.ConfigurationOptions = configurationOptions)
    .BuildServiceProvider();

var cache = serviceProvider.GetRequiredService<IDistributedCache>();

Then we can use the cache object to communicate with the Redis server.

Write data into Redis with StackExchangeRedis

We should keep in mind that StackExchangeRedis is not a Redis client for C# .NET. Instead, it is just an implementation of IDistributedCache using Redis as the caching backend. Because of that, it does not support all the data types and features of Redis. It only supports reading and writing byte arrays. And even though it supports synchronous operation with the Set method, we only focus on asynchronous operation with SetAsync method in this article.

To write a byte array into Redis, we use the SetAsync method like this.

var token = <code to create a cancellation token> // can omit this if you don't need to cancel the request
cacheOptions =  new DistributedCacheEntryOptions
{
    AbsoluteExpiration = new DateTimeOffset(new DateTime(2089, 10, 17, 07, 00, 00), TimeSpan.Zero),
    SlidingExpiration = TimeSpan.FromSeconds(3600)
})
await cache.SetAsync("string", Encoding.UTF8.GetBytes("Example"), cacheOptions, token);

The meaning of each cache entry option is below.

  • AbsoluteExpiration: the time when this cache entry will expire. This is a DateTimeOffset, which is a DateTime value and an offset compared to UTC.
  • SlidingExpiration: if this entry is inactive (no read/write access) for longer than this value, it will be removed. Note that SlidingExpiration cannot extend the life of an entry beyond AbsoluteExpiration.

Note: if by some miracle you are reading this article after 2089/10/17 07:00:00 UTC then please set the AbsoluteExpiration to a further time. Because if AbsoluteExpiration was in the past then an exception will be thrown. I highly doubt that Redis or my blog would still be a thing in 2089 though.

What if we don’t want our entry to expire?

Inside Microsoft.Extensions.Caching.Abstractions namespace, we have some extension methods to make using IDistributedCache easier. If we don’t care about cache expiration then we can call this method.

await cache.SetAsync("string_2", Encoding.UTF8.GetBytes("Example"), token);

The extension method will automatically create and use a default DistributedCacheEntryOptions. This means our cache entry won’t ever expire because both expiration will be set to -1 as we can see here.

private const long NotPresent = -1;

// bunch of code

await cache.ScriptEvaluateAsync(SetScript, new RedisKey[] { _instance + key },
    new RedisValue[]
    {
            absoluteExpiration?.Ticks ?? NotPresent,
            options.SlidingExpiration?.Ticks ?? NotPresent,
            GetExpirationInSeconds(creationTime, absoluteExpiration, options) ?? NotPresent,
            value
    });

What if we only need to write strings?

If we only need to write strings into Redis then manually converting a string to byte array every time can get tiring. Fortunately, we have an extension method that allows us to write this.

await cache.SetStringAsync("string_3", "Example", cacheOptions, token);

A quick look at the implementation here reveals that this method simply encodes the string using UTF-8 and calls SetAsync with the encoded string.

return cache.SetAsync(key, Encoding.UTF8.GetBytes(value), options, token);

There is also an extension method to handle the case where we want to write a non-expiring string.

await cache.SetStringAsync("string_4", "Example", token);

How does Redis store our data?

Looking at the code of StackExchangeRedis, we can see that it uses the HMSET command to save data into Redis. From this, we can guess that our data is stored as hash.

private const string SetScript = (@"
    redis.call('HMSET', KEYS[1], 'absexp', ARGV[1], 'sldexp', ARGV[2], 'data', ARGV[4])
    if ARGV[3] ~= '-1' then
      redis.call('EXPIRE', KEYS[1], ARGV[3])
    end
    return 1");

We can also check this with redis-cli.

127.0.0.1:6379> type string
hash

And we can also check all fields in the hash.

127.0.0.1:6379> hgetall string
1) "absexp"
2) "659159676000000000"
3) "sldexp"
4) "36000000000"
5) "data"
6) "Example"

We can see that the data itself is stored in the data field. And the two expirations are stored as absexp and sldexp, their values are a little more complicated though.

  • absexp: this is the difference between the absolute expiration time and 0001/01/01 00:00:00 UTC, in ticks. One second is 10,000,000 ticks.
  • sldexp: this is the sliding expiration, also converted into ticks.

Read data from Redis with StackExchangeRedis

Just like when writing data into Redis, we only focus on asynchronous operation with the GetAsync method.

var token = <code to create a cancellation token> // can omit this if you don't need to cancel the request
var stringBytes = await cache.GetAsync("string", token);
Console.WriteLine(Encoding.UTF8.GetString(stringBytes)); // Print "Example"

If we know that our data is a string then we can use the GetStringAsync extension method.

var stringValue = await cache.GetStringAsync("string", token);
Console.WriteLine(stringValue); // Print "Example"

From the code, we can see that all GetStringAsync does is calling GetAsync, decoding the response using UTF-8, and returning the value.

byte[] data = await cache.GetAsync(key, token).ConfigureAwait(false);
if (data == null)
{
    return null;
}
return Encoding.UTF8.GetString(data, 0, data.Length);

In all cases, if the cache key does not exist then the return value will be null.

Refresh a cache entry

As mentioned in a previous section, we can set a SlidingExpiration value so that a cache entry will expire if it is not accessed within a sliding window. Sometimes, we want to reset that sliding window without retrieving or updating the value. In that case, we can use the RefreshAsync method.

var token = <code to create a cancellation token> // can omit this if you don't need to cancel the request
await cache.RefreshAsync("string", token);

Note that if we don’t set the SlidingExpiration value when writing an entry then when we call RefreshAsync on that entry, the method will do nothing.

Implementing a GetOrSetAsync method

Just like in the first part, we will write a GetOrSetAsync extension method.

public async Task<byte[]> GetOrSetAsync(
    this IDistributedCache cache,
    string key,
    Func<Task<byte[]>> factory,
    DistributedCacheEntryOptions cacheOptions = default(DistributedCacheEntryOptions),
    CancellationToken token = default(CancellationToken))
{
    // Use key to retrieve the data from cache
    var cacheValue = await cache.GetAsync(key, token);
    // If the result is not null
    if (cacheValue is object)
    {
        // Then that key already exists, return its value
        return cacheValue;
    }

    // Otherwise, execute the delegate to create a new value
    cacheValue = await factory();
    if (cacheOptions is null)
    {
        cacheOptions = new DistributedCacheEntryOptions();
    }
    // Cache it
    await cache.SetAsync(key, cacheValue, cacheOptions, token);

    // Then return that new value
    return cacheValue;
}

We can use it like this.

var timeStamp = Encoding.UTF8.GetBytes(DateTime.Now.ToString());
await cache.GetOrSetAsync("GetOrSetAsync", () => Task.FromResult(timeStamp));

var rs = await cache.GetAsync("GetOrSetAsync");
Console.WriteLine(Encoding.UTF8.GetString(rs));

No matter how many times we execute the code above, the result won’t change.

<The first time we execute that code>

Conclusion

In this article, we’ve checked out the StackExchangeRedis package and see how it can help us make non-blocking calls to a Redis server. In part three, we will try to exchange data between a Python app and a C# .NET app via Redis.

A software developer from Vietnam and is currently living in Japan.

3 Thoughts on “Using StackExchangeRedis to integrate Redis with a C# .NET app”

Leave a Reply