EasyCaching: A Simple and Efficient .NET Caching Package

EasyCaching: A Simple and Efficient .NET Caching Package

EasyCaching, as the name suggests, largely explains what it does. Combining 'easy' and 'caching', its ultimate purpose is to make caching operations more convenient for us all.

Last updated 11/5/2023 11:14 PM
Catcher Wong
15 min read
Category
.NET
Tags
.NET C#

Preface

From November 11, 2017, when I first created the EasyCaching repository on GitHub, it has been nearly a year and a half. Most of the work was done after work and during holidays.

Since EasyCaching currently only has English documentation hosted on Read the Docs, and the MkDocs I chose initially does not yet support multiple languages, the Chinese documentation will be planned only after that support is added.

Previously, I saw some people in the group saying they couldn't find an introduction to EasyCaching, which is why I decided to write this blog post.

Let me briefly introduce EasyCaching below.

What is EasyCaching

img

EasyCaching, the name itself largely explains what it does — combining "easy" and "caching" together, its ultimate goal is to make caching operations more convenient for everyone.

Its development has gone through several important milestones:

  1. March 2018: Entered NCC with the help of Uncle Tea
  2. January 2019: Zhen Xi provided many improvement suggestions
  3. March 2019: NopCommerce introduced EasyCaching (see this commit record)
  4. April 2019: Listed in awesome-dotnet-core (I submitted the PR myself, a bit narcissistic...)

Before EasyCaching came out, most people were probably familiar with CacheManager, as they share similar positioning and functionality. Occasionally, I hear people comparing the two.

To help everyone make a better comparison, I will focus on introducing the existing features of EasyCaching below.

Main Features of EasyCaching

EasyCaching mainly provides the following features:

  1. Unified abstract caching interface
  2. Multiple common cache Providers (InMemory, Redis, Memcached, SQLite)
  3. Multiple serialization options for distributed cache data
  4. Hybrid cache (two-level caching)
  5. AOP operations for caching (able, put, evict)
  6. Multi-instance support
  7. Diagnostics support
  8. Redis-specific Provider

Besides these 8, there are some smaller features that I won't list here.

Let me introduce each of the above 8 features below.

Unified Abstract Caching Interface

Caching itself can be considered a data source, involving a set of CRUD operations. Therefore, a unified abstract interface is provided. By programming to interfaces, although EasyCaching provides some simple implementations that may not fully meet your needs, you are free to implement your own provider if you wish.

For cache operations, the following are currently provided, mostly with both synchronous and asynchronous versions:

  • TrySet/TrySetAsync
  • Set/SetAsync
  • SetAll/SetAllAsync
  • Get/GetAsync (with data retriever)
  • Get/GetAsync (without data retriever)
  • GetByPrefix/GetByPrefixAsync
  • GetAll/GetAllAsync
  • Remove/RemoveAsync
  • RemoveByPrefix/RemoveByPrefixAsync
  • RemoveAll/RemoveAllAsync
  • Flush/FlushAsync
  • GetCount
  • GetExpiration/GetExpirationAsync
  • Refresh/RefreshAsync (this will be deprecated later; just use set instead)

From the naming, you can guess what they do, so I won't elaborate further here.

Multiple Common Cache Providers

We divide these providers into two categories: local cache and distributed cache.

Currently, there are five implementations:

  • Local cache: InMemory, SQLite
  • Distributed cache: StackExchange.Redis, csredis, EnyimMemcachedCore

Their usage is very simple. Let's take the InMemory provider as an example.

First, install the corresponding NuGet package.

dotnet add package EasyCaching.InMemory

Then, add the configuration.

public void ConfigureServices(IServiceCollection services)
{
    // Add EasyCaching
    services.AddEasyCaching(option =>
    {
        // Simplest configuration using InMemory
        option.UseInMemory("default");

        //// Custom configuration using InMemory
        //option.UseInMemory(options =>
        //{
        //     // DBConfig is specific to each Provider
        //     options.DBConfig = new InMemoryCachingOptions
        //     {
        //         // Expiration scan frequency for InMemory, default is 60 seconds
        //         ExpirationScanFrequency = 60,
        //         // Maximum number of cache items in InMemory, default is 10000
        //         SizeLimit = 100
        //     };
        //     // Prevent all keys from expiring at the same time by adding a random number of seconds to each key's expiration, default is 120 seconds
        //     options.MaxRdSecond = 120;
        //     // Enable logging, default is false
        //     options.EnableLogging = false;
        //     // Lock lifetime, default is 5000 milliseconds
        //     options.LockMs = 5000;
        //     // Sleep time when the mutex lock is not acquired, default is 300 milliseconds
        //     options.SleepMs = 300;
        // }, "m2");

        //// Read configuration from file
        //option.UseInMemory(Configuration, "m3");
    });
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    // If using Memcached or SQLite, you also need the following for initialization
    app.UseEasyCaching();
}

Example configuration file:

"easycaching": {
    "inmemory": {
        "MaxRdSecond": 120,
        "EnableLogging": false,
        "LockMs": 5000,
        "SleepMs": 300,
        "DBConfig":{
            "SizeLimit": 10000,
            "ExpirationScanFrequency": 60
        }
    }
}

Regarding configuration, it is necessary to explain the MaxRdSecond value because it once caused trouble for Brother Laomaozi. Its purpose is to prevent a large number of cache keys from expiring simultaneously. It adds a random number of seconds on top of each key's original expiration time to spread out the expiration times as much as possible. If your application scenario does not need this, you can set it to 0.

Finally, usage.

[Route("api/[controller]")]
public class ValuesController : Controller
{
    // For a single provider, you can directly use IEasyCachingProvider
    private readonly IEasyCachingProvider _provider;

    public ValuesController(IEasyCachingProvider provider)
    {
        this._provider = provider;
    }

    // GET api/values/sync
    [HttpGet]
    [Route("sync")]
    public string Get()
    {
        var res1 = _provider.Get("demo", () => "456", TimeSpan.FromMinutes(1));
        var res2 = _provider.Get<string>("demo");

        _provider.Set("demo", "123", TimeSpan.FromMinutes(1));

        _provider.Remove("demo");

        // others..
        return "sync";
    }

    // GET api/values/async
    [HttpGet]
    [Route("async")]
    public async Task<string> GetAsync(string str)
    {
        var res1 = await _provider.GetAsync("demo", async () => await Task.FromResult("456"), TimeSpan.FromMinutes(1));
        var res2 = await _provider.GetAsync<string>("demo");

        await _provider.SetAsync("demo", "123", TimeSpan.FromMinutes(1));

        await _provider.RemoveAsync("demo");

        // others..
        return "async";
    }
}

Another thing to note is that if you use a Get method with a data retriever, it will perform a locking operation before querying the database when the cache is missed, preventing the same key from querying the database multiple times at the same moment. The lock's lifetime and sleep time are determined by the LockMs and SleepMs configuration values.

Serialization Options for Distributed Cache

When operating with distributed caches, serialization is inevitable.

Currently, this mainly targets Redis and Memcached. Of course, there is a default serialization implementation based on BinaryFormatter, which does not depend on third-party libraries. If no other serializer is specified, this will be used.

In addition to the default implementation, three additional options are provided: Newtonsoft.Json, MessagePack, and Protobuf. Below is an example of using MessagePack with the Redis provider.

services.AddEasyCaching(option=>
{
    // Use Redis
    option.UseRedis(config =>
    {
        config.DBConfig.Endpoints.Add(new ServerEndPoint("127.0.0.1", 6379));
    }, "redis1")
    // Use MessagePack to replace BinaryFormatter
    .WithMessagePack()
    //// Use Newtonsoft.Json to replace BinaryFormatter
    //.WithJson()
    //// Use Protobuf to replace BinaryFormatter
    //.WithProtobuf()
    ;
});

However, note that currently, these serializers are not tied to a specific provider. This means you cannot have one provider using MessagePack and another using JSON. Only one serializer can be used at a time. This may need improvement in the future.

Multi-Instance Support

Some may ask what multi-instance means. Here, it refers to using multiple providers simultaneously in the same project, including multiple providers of the same type or different types.

This might be unclear, so let me give a fictitious example to make it clearer.

Suppose our product cache is in Redis cluster 1, user information is in Redis cluster 2, product reviews are in a Memcached cluster, and some simple configuration information is in the local cache of the application server.

In this scenario, it's obviously impossible for us to directly operate all these different caches simply through IEasyCachingProvider.

To handle multiple different caches simultaneously, we use IEasyCachingProviderFactory to specify which provider to use.

This factory obtains the provider by its name.

Let’s look at an example.

First, add two InMemory caches with different names:

services.AddEasyCaching(option =>
{
    // Specify the current provider name as m1
    option.UseInMemory("m1");

    // Specify the current provider name as m2
    config.UseInMemory(options =>
    {
        options.DBConfig = new InMemoryCachingOptions
        {
            SizeLimit = 100
        };
    }, "m2");
});

When using:

[Route("api/[controller]")]
public class ValuesController : Controller
{
    private readonly IEasyCachingProviderFactory _factory;

    public ValuesController(IEasyCachingProviderFactory factory)
    {
        this._factory = factory;
    }

    // GET api/values
    [HttpGet]
    [Route("")]
    public string Get()
    {
        // Get the provider named m1
        var provider_1 = _factory.GetCachingProvider("m1");
        // Get the provider named m2
        var provider_2 = _factory.GetCachingProvider("m2");

        // provider_1.xxx
        // provider_2.xxx

        return $"multi instances";
    }
}

In this example, provider_1 and provider_2 do not interfere with each other because they are different providers!

Intuitively, this is somewhat like the concept of regions, but strictly speaking, it is not a region.

AOP Operations for Caching

When talking about AOP, the first thing that comes to mind might be logging — logging parameters and results.

In caching operations, AOP also simplifies things.

Typically, we might operate on the cache like this:

public async Task<Product> GetProductAsync(int id)
{
    string cacheKey = $"product:{id}";

    var val = await _cache.GetAsync<Product>(cacheKey);

    if(val.HasValue)
        return val.Value;

    var product = await _db.GetProductAsync(id);

    if(product != null)
        _cache.Set<Product>(cacheKey, product, expiration);

    return val;
}

If caching is used extensively, this can become tedious.

We can use AOP to simplify this.

public interface IProductService
{
    [EasyCachingAble(Expiration = 10)]
    Task<Product> GetProductAsync(int id);
}

public class ProductService : IProductService
{
    public Task<Product> GetProductAsync(int id)
    {
        return Task.FromResult(new Product { ... });
    }
}

As you can see, we only need to add an attribute to the interface definition.

Of course, without proper configuration, just adding the attribute won't make it work. Below is an example using EasyCaching.Interceptor.AspectCore:

public IServiceProvider ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IProductService, ProductService>();

    services.AddEasyCaching(options =>
    {
        options.UseInMemory("m1");
    });

    return services.ConfigureAspectCoreInterceptor(options =>
    {
        // You can specify which provider to use here
        // Or specify it on the attribute
        options.CacheProviderName = "m1";
    });
}

With these two steps, when you call a method, it will first try the cache. If no cache exists, it will execute the method.

Now let's discuss the parameters of the three attributes.

First, three common configurations:

Configuration Name Description
CacheKeyPrefix Specifies the prefix for generating cache keys, typically used for update/delete operations
CacheProviderName Specifies a custom provider name
IsHightAvailability Whether business methods should continue to execute when cache-related operations throw exceptions

EasyCachingAble and EasyCachingPut also have the following common configuration:

Configuration Name Description
Expiration Key expiration time in seconds

EasyCachingEvict has two special configurations:

Configuration Name Description
IsAll Should be used with CacheKeyPrefix; deletes all keys with that prefix
IsBefore Whether to delete the cache before or after executing the business method

Diagnostics Support

To facilitate integration with third-party APM tools, Diagnostics support is provided for tracing.

Below is an example from our company's Jaeger integration:

img

Hybrid Cache (Two-Level Caching)

Two-level or multi-level caching is quite important in the caching world.

One of the most headache-inducing issues is how to achieve near real-time synchronization between different cache levels.

In EasyCaching, the hybrid cache implementation logic is roughly represented by the following diagram:

img

If the local cache on one server is modified, the cache bus will notify other servers to remove the corresponding local cache.

Here is a simple usage example.

First, install the NuGet packages:

dotnet add package EasyCaching.InMemory
dotnet add package EasyCaching.Redis
dotnet add package EasyCaching.HybridCache
dotnet add package EasyCaching.Bus.Redis

Then, add the configuration:

services.AddEasyCaching(option =>
{
    // Add two basic providers
    option.UseInMemory("m1");
    option.UseRedis(config =>
    {
        config.DBConfig.Endpoints.Add(new Core.Configurations.ServerEndPoint("127.0.0.1", 6379));
        config.DBConfig.Database = 5;
    }, "myredis");

    // Use hybrid cache
    option.UseHybrid(config =>
    {
        config.EnableLogging = false;
        // Cache bus subscription topic
        config.TopicName = "test_topic";
        // Local cache provider name
        config.LocalCacheProviderName = "m1";
        // Distributed cache provider name
        config.DistributedCacheProviderName = "myredis";
    });

    // Use Redis as the cache bus
    option.WithRedisBus(config =>
    {
        config.Endpoints.Add(new Core.Configurations.ServerEndPoint("127.0.0.1", 6379));
        config.Database = 6;
    });
});

Finally, usage:

[Route("api/[controller]")]
public class ValuesController : Controller
{
    private readonly IHybridCachingProvider _provider;

    public ValuesController(IHybridCachingProvider provider)
    {
        this._provider = provider;
    }

    // GET api/values
    [HttpGet]
    [Route("")]
    public string Get()
    {
        _provider.Set(cacheKey, "val", TimeSpan.FromSeconds(30));

        return $"hybrid";
    }
}

If you find it unclear, you can check this complete example: EasyCachingHybridDemo.

Redis-Specific Provider

As everyone knows, Redis supports various data structures and atomic increment/decrement operations. To support these operations, EasyCaching provides a separate interface, IRedisCachingProvider.

This interface currently supports about 60-70% of common Redis operations; less frequently used ones are not included.

Similarly, this interface also supports multiple instances, and you can obtain different provider instances via IEasyCachingProviderFactory.

During injection, no additional steps are needed beyond adding Redis. The difference is that when using it, you use IRedisCachingProvider instead of IEasyCachingProvider.

Here is a simple usage example:

[Route("api/mredis")]
public class MultiRedisController : Controller
{
    private readonly IRedisCachingProvider _redis1;
    private readonly IRedisCachingProvider _redis2;

    public MultiRedisController(IEasyCachingProviderFactory factory)
    {
        this._redis1 = factory.GetRedisProvider("redis1");
        this._redis2 = factory.GetRedisProvider("redis2");
    }

    // GET api/mredis
    [HttpGet]
    public string Get()
    {
        _redis1.StringSet("keyredis1", "val");

        var res1 = _redis1.StringGet("keyredis1");
        var res2 = _redis2.StringGet("keyredis1");

        return $"redis1 cached value: {res1}, redis2 cached value : {res2}";
    }
}

In addition to these basic features, there are also some extensibility features. I would like to thank yrinleung for integrating EasyCaching with projects like WebApiClient and CAP. If interested, check out this project: EasyCaching.Extensions.

Final Thoughts

These are the features currently supported by EasyCaching. If you encounter any issues while using it, I hope you can provide active feedback to help EasyCaching become even better.

If you are interested in this project, feel free to give it a Star on GitHub, or join us in development and maintenance.

Recently, I opened an Issue to record users and cases currently using EasyCaching. If you are using EasyCaching and don't mind sharing your information, feel free to reply to that issue.

img

img

If you found this article helpful or insightful, please click the [Recommend] button at the bottom right, as your support is my greatest motivation to continue writing and sharing!

Author: Catcher Wong (Huang Wenqing)

Source: http://catcher1994.cnblogs.com/

Disclaimer: This article is copyrighted by the author and Blog Park. You may reprint it, but you must keep this statement and provide the original link in a prominent location on the article page; otherwise, the right to pursue legal action is reserved. If you find errors in the blog or have better suggestions or ideas, please contact me in time! To chat privately, you can send a private message or add my WeChat.

Keep Exploring

Related Reading

More Articles