Note: see the link below for the English version of this article.

https://duongnt.com/stackexchangeredis

Đây là bài số 2 trong loạt 3 bài về distributed caching sử dụng Redis. Xin xem trang này để lấy đường link tới các phần còn lại.

Trong .NET Platform Extension có chứa một interface với tên gọi IDistributedCache. Interface này định nghĩa các hàm để cung cấp tính năng distributed caching cho dữ liệu đã được serialize. Ta có thể dùng interface này với Redis làm backend bằng cách sử dụng package Microsoft.Extensions.Caching.StackExchangeRedis. Ta cài package đó bằng lệnh sau.

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Chú ý là package khác với package StackExchange.Redis.

Thiết lập kết nối

Đầu tiên, ta cần có một server Redis để chạy code trong bài hôm nay. Xin tham khảo phần trước để xem cách thiết lập Redis trong container Docker.

Nhờ StackExchangeRedis việc kết nối tới Redis trở nên rất đơn giản. Ta thiết lập địa chỉ tới server Redis bằng cách tạo đối tượng sau.

var configurationOptions = new ConfigurationOptions
{
    EndPoints = { "localhost:6379" }, // Khác với khi dùng aioredis, ta không cần phần "redis://"
    Ssl = false // Đổi giá trị này thành true nếu server Redis hỗ trợ SSL
};

ConfigurationOptions còn chứa nhiều thiết lập khác, dưới đây là một số thiết lập đáng chú ý.

  • Password: mật khẩu để đăng nhập vào Redis server.
  • ConnectTimeout: thời gian chờ tối đa khi kết nối tới Redis server.
  • AsyncTimout: thời gian tối đa cho một request không đồng bộ (async).
  • SyncTimeout: thời gian tối đa cho một request đồng bộ (sync).

Theo tôi, chúng ta nên đặt giá trị ConnectTimeoutAsyncTimout/SyncTimeout thấp. Bởi vì nếu Redis có sự cố thì việc đợi hết thời gian timeout mặc định (5 giây) sẽ khiến cả hệ thống trở nên quá chậm. Mà cache thì lẽ ra phải nhanh.

Sau đó, ta thêm service Redis vào tập các service của ứng dụng và tạo đối tượng IDistributedCache như sau.

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

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

Bây giờ ta có thể dùng đối tượng cache ở trên để tương tác với server Redis.

Dùng StackExchangeRedis để ghi dữ liệu vào Redis

Ta cần lưu ý là StackExchangeRedis không phải client Redis chuyên dụng cho C# .NET. Nó chỉ là implementation của IDistributedCache với Redis là backend. Vì thế, nó không hỗ trợ hết các kiểu dữ liệu hay tính năng của Redis. StackExchangeRedis chỉ giúp ta đọc và ghi các byte array từ Redis. Ngoài ra, dù StackExchangeRedis có hàm Set nhưng ta chỉ quan tâm tới hàm SetAsync trong bài hôm nay.

Ta dùng hàm SetAsync để ghi byte array vào Redis như sau.

var token = <code để tạo cancellation token> // có thể bỏ qua bước này nếu ta không cần hủy 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_example", Encoding.UTF8.GetBytes("Example"), cacheOptions, token);

Ý nghĩa của từng thiết lập là như sau.

  • AbsoluteExpiration: thời điểm mà giá trị trong cache sẽ hết hạn và bị xóa. Kiểu dữ liệu của thiết lập này là DateTimeOffset. Nó lưu một giá trị DateTime và chênh lệch của DateTime đó với UTC.
  • SlidingExpiration: nếu một giá trị trong cache không được đọc hay cập nhật trong khoảng thời gian lớn hơn giá trị này, giá trị đó sẽ bị xóa. Lưu ý là SlidingExpiration không thể khiến giá trị tồn tại quá thời điểm AbsoluteExpiration.

Chú ý: nếu có ai đọc bài này sau thời điểm 2089/10/17 07:00:00 UTC thì hãy đặt giá trị mới hơn cho AbsoluteExpiration. Nếu AbsoluteExpiration là một thời điểm trong quá khứ thì sẽ xảy ra lỗi. Tôi không nghĩ là đến năm 2089 thì Redis vẫn còn được sử dụng, và có lẽ blog này cũng không còn tồn tại.

Nếu ta không muốn giá trị cache bị hết hạn thì sao?

Trong namespace Microsoft.Extensions.Caching.Abstractions có nhiều hàm extension để giúp việc sử dụng IDistributedCache trở nên dễ dàng hơn. Nếu ta không muốn giá trị trong cache bị hết hạn thì ta có thể dùng hàm này.

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

Hàm đó sẽ tự tạo một đối tượng DistributedCacheEntryOptions với giá trị mặc định. Có nghĩa là giá trị ta ghi vào cache sẽ không bao giờ hết hạn, bởi vì tất cả thời điểm hết hạn đều được đặt là -1 như ta thấy ở đây.

private const long NotPresent = -1;

// nhiều code khác

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

Nếu ta chỉ muốn ghi string thì sao?

Nếu ta chỉ muốn ghi string vào Redis thì việc tự encode từng string sang byte array thật là phiền phức. Thật may là có một hàm extension cho phép ta viết code như sau.

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

Khi đọc code của SetStringAsync, ta thấy là hàm đó sẽ encode giá trị string sang byte array rồi gọi hàm SetAsync và truyền vào byte array đó.

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

Cũng có cả hàm extension cho trường hợp ta muốn ghi một string vào cache mà không muốn string đó bị hết hạn.

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

Redis lưu dữ liệu của ta dưới dạng nào?

Khi đọc code của StackExchangeRedis, ta thấy rằng nó dùng lệnh HMSET để lưu dữ liệu vào Redis. Từ đó ta có thể đoán là dữ liệu của ta được lưu dưới dạng 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");

Ta cũng có thể kiểm tra bằng redis-cli.

127.0.0.1:6379> type string_example
hash

Và ta có thể đọc giá trị từng trường trong hash.

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

Có thể thấy là dữ liệu của ta được lưu trong trường data. Và các thời điểm giá trị cache hết hạn được lưu trong absexpsldexp. Ý nghĩa của 2 trường này như sau.

  • absexp: đây là khoảng cách giữa AbsoluteExpiration và thời điểm 0001/01/01 00:00:00 UTC với đơn vị là tick. Một giây bằng 10.000.000 tick.
  • sldexp: đây là giá trị của SlidingExpiration cũng với đơn vị là tick.

Dùng StackExchangeRedis để đọc giá trị từ Redis

Giống như khi ta ghi dữ liệu vào Redis, lúc này ta cũng chỉ quan tâm tới hàm GetAsync.

var token = <code để tạo cancellation token> // có thể bỏ qua bước này nếu ta không cần hủy request
var stringBytes = await cache.GetAsync("string", token);
Console.WriteLine(Encoding.UTF8.GetString(stringBytes)); // In ra "Example"

Nếu ta biết rằng dữ liệu của ta có dạng string thì ta có thể dùng hàm extension GetStringAsync.

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

Từ đoạn code này, ta có thể thấy GetStringAsync gọi hàm GetAsync, dùng UTF-8 để decode byte array trả về, rồi trả về string đã decode.

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

Trong cả 2 trường hợp trên, nếu cache key không tồn tại thì kết quả trả về sẽ là null.

Refresh một giá trị trong cache

Như đã nói ở phần trước, ta có thể đặt thiết lập SlidingExpiration để làm giá trong cache bị hết hạn nếu nó không được sử dụng trong một khoảng thời gian định trước. Đôi khi ta muốn reset đồng hồ đếm ngược này mà lại không muốn đọc hay cập nhật giá trị. Khi đó, ta có thể dùng hàm RefreshAsync.

var token = <code để tạo cancellation token> // có thể bỏ qua bước này nếu ta không cần hủy request
await cache.RefreshAsync("string", token);

Nếu khi ghi giá trị vào cache ta không đặt SlidingExpiration thì hàm RefreshAsync sẽ không có tác dụng gì.

Thử viết hàm GetOrSetAsync method

Giống như trong phần trước, ta sẽ viết hàm GetOrSetAsync.

public async Task<byte[]> GetOrSetAsync(
    this IDistributedCache cache,
    string key,
    Func<Task<byte[]>> factory,
    DistributedCacheEntryOptions cacheOptions = default(DistributedCacheEntryOptions),
    CancellationToken token = default(CancellationToken))
{
    // Dùng key để lấy dữ liệu từ cache
    var cacheValue = await cache.GetAsync(key, token);
    // Nếu giá trị không phải là null
    if (cacheValue is object)
    {
        // Thì key đã tồn tại, trả về giá trị đó
        return cacheValue;
    }

    // Còn nếu key chưa tồn tại thì chạy delegate để tạo dữ liệu mới
    cacheValue = await factory();
    if (cacheOptions is null)
    {
        cacheOptions = new DistributedCacheEntryOptions();
    }
    // Lưu dữ liệu đó vào cache
    await cache.SetAsync(key, cacheValue, cacheOptions, token);

    // Và trả về dữ liệu mới đó
    return cacheValue;
}

Ta dùng hàm extension này như sau.

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));

Dù ta có gọi hàm trên bao nhiêu lần chăng nữa, giá trị trả về cũng không đổi.

<Thời điểm ta gọi hàm lần đầu tiên>

Kết thúc

Trong bài hôm nay, chúng ta đã dùng thử package StackExchangeRedis và xem nó giúp ta thực hiện non-blocking request tới Redis server như thế nào. Trong phần 3, chúng ta sẽ trao đổi dữ liệu giữa ứng dụng Python và ứng dụng C# .NET thông qua Redis.

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

One Thought on “Dùng StackExchangeRedis để tích hợp Redis với ứng dụng C# .NET”

Leave a Reply