Redis cache in AOP mode using AspectCore

Posted by esukf on Thu, 14 Nov 2019 06:40:27 +0100

This time, the goal is to implement caching by labeling Attribute s, streamline code, and reduce code intrusion into business code.

Cached content is the summary content of Service queries, without other high-level functions, to improve the response speed of multiple queries in a short time, and to properly reduce the pressure on the database.

Before you do that, you've also looked at EasyCaching's source code, and this time the idea comes from this, where AOP reduces code coupling, but caching strategies are limited.After consideration, you decide to implement similar functions yourself, which will facilitate the expansion of caching strategies in future applications.

The content of this article may be somewhat inaccurate for reference only.You are also welcome to make suggestions from passing gangsters.

Add AspectCore to the project

Previously, AspectCore was summarized, so we won't go into that again.

Add Stackexchange.Redis to the project

After a long tangle with stackexchange.Redis and CSRedis, and without a particular advantage, stackexchange.Redis was chosen for no reason.The problem of connection timeouts can be resolved asynchronously.

  • Install Stackexchange.Redis
Install-Package StackExchange.Redis -Version 2.0.601
  • Configuring Redis connection information in appsettings.json
{
    "Redis": {
        "Default": {
            "Connection": "127.0.0.1:6379",
            "InstanceName": "RedisCache:",
            "DefaultDB": 0
        }
    }
}
  • RedisClient

Used to connect to Redis servers, including creating connections, getting databases, and so on

public class RedisClient : IDisposable
{
    private string _connectionString;
    private string _instanceName;
    private int _defaultDB;
    private ConcurrentDictionary<string, ConnectionMultiplexer> _connections;
    public RedisClient(string connectionString, string instanceName, int defaultDB = 0)
    {
        _connectionString = connectionString;
        _instanceName = instanceName;
        _defaultDB = defaultDB;
        _connections = new ConcurrentDictionary<string, ConnectionMultiplexer>();
    }

    private ConnectionMultiplexer GetConnect()
    {
        return _connections.GetOrAdd(_instanceName, p => ConnectionMultiplexer.Connect(_connectionString));
    }

    public IDatabase GetDatabase()
    {
        return GetConnect().GetDatabase(_defaultDB);
    }

    public IServer GetServer(string configName = null, int endPointsIndex = 0)
    {
        var confOption = ConfigurationOptions.Parse(_connectionString);
        return GetConnect().GetServer(confOption.EndPoints[endPointsIndex]);
    }

    public ISubscriber GetSubscriber(string configName = null)
    {
        return GetConnect().GetSubscriber();
    }

    public void Dispose()
    {
        if (_connections != null && _connections.Count > 0)
        {
            foreach (var item in _connections.Values)
            {
                item.Close();
            }
        }
    }
}
  • Registration Services

Redis is a single-threaded service, and several more instances of RedisClient are useless, so dependency injection takes the singleton approach.

public static class RedisExtensions
{
    public static void ConfigRedis(this IServiceCollection services, IConfiguration configuration)
    {
        var section = configuration.GetSection("Redis:Default");
        string _connectionString = section.GetSection("Connection").Value;
        string _instanceName = section.GetSection("InstanceName").Value;
        int _defaultDB = int.Parse(section.GetSection("DefaultDB").Value ?? "0");
        services.AddSingleton(new RedisClient(_connectionString, _instanceName, _defaultDB));
    }
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.ConfigRedis(Configuration);
    }
}
  • KeyGenerator

Create a generator for cache keys prefixed with CacheKeyPrefix in Attribute, which can then extend the bulk deletion functionality.The method name and entry of the intercepted method are also part of the key, ensuring that the key value is not duplicated.

public static class KeyGenerator
{
    public static string GetCacheKey(MethodInfo methodInfo, object[] args, string prefix)
    {
        StringBuilder cacheKey = new StringBuilder();
        cacheKey.Append($"{prefix}_");
        cacheKey.Append(methodInfo.DeclaringType.Name).Append($"_{methodInfo.Name}");
        foreach (var item in args)
        {
            cacheKey.Append($"_{item}");
        }
        return cacheKey.ToString();
    }

    public static string GetCacheKeyPrefix(MethodInfo methodInfo, string prefix)
    {
        StringBuilder cacheKey = new StringBuilder();
        cacheKey.Append(prefix);
        cacheKey.Append($"_{methodInfo.DeclaringType.Name}").Append($"_{methodInfo.Name}");
        return cacheKey.ToString();
    }
}

Write a set of cache interceptors

  • CacheAbleAttribute

Attribute stores cached policy information, including expiration time, Key value prefix, and so on, which can be configured when using the cache.

public class CacheAbleAttribute : Attribute
{
    /// <summary>
    ///Expiration time (seconds)
    /// </summary>
    public int Expiration { get; set; } = 300;

    /// <summary>
    /// Key value prefix
    /// </summary>
    public string CacheKeyPrefix { get; set; } = string.Empty;

    /// <summary>
    ///High Availability (Execute original method on exception)
    /// </summary>
    public bool IsHighAvailability { get; set; } = true;

    /// <summary>
    ///Allow only one thread to update cache (with lock)
    /// </summary>
    public bool OnceUpdate { get; set; } = false;
}
  • CacheAbleInterceptor

The next big thing is that the logic in interceptors can be divided into different interceptors relative to the cache-related strategies.
The logic here refers to EasyCaching's source code and incorporates the application of Redis distributed locks.

public class CacheAbleInterceptor : AbstractInterceptor
{
    [FromContainer]
    private RedisClient RedisClient { get; set; }

    private IDatabase Database;

    private static readonly ConcurrentDictionary<Type, MethodInfo> TypeofTaskResultMethod = new ConcurrentDictionary<Type, MethodInfo>();

    public async override Task Invoke(AspectContext context, AspectDelegate next)
    {
        CacheAbleAttribute attribute = context.GetAttribute<CacheAbleAttribute>();

        if (attribute == null)
        {
            await context.Invoke(next);
            return;
        }

        try
        {
            Database = RedisClient.GetDatabase();

            string cacheKey = KeyGenerator.GetCacheKey(context.ServiceMethod, context.Parameters, attribute.CacheKeyPrefix);

            string cacheValue = await GetCacheAsync(cacheKey);

            Type returnType = context.GetReturnType();

            if (string.IsNullOrWhiteSpace(cacheValue))
            {
                if (attribute.OnceUpdate)
                {
                    string lockKey = $"Lock_{cacheKey}";
                    RedisValue token = Environment.MachineName;

                    if (await Database.LockTakeAsync(lockKey, token, TimeSpan.FromSeconds(10)))
                    {
                        try
                        {
                            var result = await RunAndGetReturn(context, next);
                            await SetCache(cacheKey, result, attribute.Expiration);
                            return;
                        }
                        finally
                        {
                            await Database.LockReleaseAsync(lockKey, token);
                        }
                    }
                    else
                    {
                        for (int i = 0; i < 5; i++)
                        {
                            Thread.Sleep(i * 100 + 500);
                            cacheValue = await GetCacheAsync(cacheKey);
                            if (!string.IsNullOrWhiteSpace(cacheValue))
                            {
                                break;
                            }
                        }
                        if (string.IsNullOrWhiteSpace(cacheValue))
                        {
                            var defaultValue = CreateDefaultResult(returnType);
                            context.ReturnValue = ResultFactory(defaultValue, returnType, context.IsAsync());
                            return;
                        }
                    }
                }
                else
                {
                    var result = await RunAndGetReturn(context, next);
                    await SetCache(cacheKey, result, attribute.Expiration);
                    return;
                }
            }
            var objValue = await DeserializeCache(cacheKey, cacheValue, returnType);
            //Cache value not available
            if (objValue == null)
            {
                await context.Invoke(next);
                return;
            }
                context.ReturnValue = ResultFactory(objValue, returnType, context.IsAsync());
        }
        catch (Exception)
        {
            if (context.ReturnValue == null)
            {
                await context.Invoke(next);
            }
        }
    }

    private async Task<string> GetCacheAsync(string cacheKey)
    {
        string cacheValue = null;
        try
        {
            cacheValue = await Database.StringGetAsync(cacheKey);
        }
        catch (Exception)
        {
            return null;
        }
        return cacheValue;
    }

    private async Task<object> RunAndGetReturn(AspectContext context, AspectDelegate next)
    {
        await context.Invoke(next);
        return context.IsAsync()
        ? await context.UnwrapAsyncReturnValue()
        : context.ReturnValue;
    }

    private async Task SetCache(string cacheKey, object cacheValue, int expiration)
    {
        string jsonValue = JsonConvert.SerializeObject(cacheValue);
        await Database.StringSetAsync(cacheKey, jsonValue, TimeSpan.FromSeconds(expiration));
    }

    private async Task Remove(string cacheKey)
    {
        await Database.KeyDeleteAsync(cacheKey);
    }

    private async Task<object> DeserializeCache(string cacheKey, string cacheValue, Type returnType)
    {
        try
        {
            return JsonConvert.DeserializeObject(cacheValue, returnType);
        }
        catch (Exception)
        {
            await Remove(cacheKey);
            return null;
        }
    }

    private object CreateDefaultResult(Type returnType)
    {
        return Activator.CreateInstance(returnType);
    }

    private object ResultFactory(object result, Type returnType, bool isAsync)
    {
        if (isAsync)
        {
            return TypeofTaskResultMethod
                .GetOrAdd(returnType, t => typeof(Task)
                .GetMethods()
                .First(p => p.Name == "FromResult" && p.ContainsGenericParameters)
                .MakeGenericMethod(returnType))
                .Invoke(null, new object[] { result });
        }
        else
        {
            return result;
        }
    }
}
  • Register Interceptor

Register the CacheAbleInterceptor interceptor in AspectCore, where DemoService for testing is registered directly.
In a formal project, you plan to register a Service or Method that requires a cache with reflection.

public static class AspectCoreExtensions
{
    public static void ConfigAspectCore(this IServiceCollection services)
    {
        services.ConfigureDynamicProxy(config =>
        {
            config.Interceptors.AddTyped<CacheAbleInterceptor>(Predicates.Implement(typeof(DemoService)));
        });
        services.BuildAspectInjectorProvider();
    }
}

Test Cache Function

  • Label Attribute s on interfaces/methods that require caching
[CacheAble(CacheKeyPrefix = "test", Expiration = 30, OnceUpdate = true)]
public virtual DateTimeModel GetTime()
{
    return new DateTimeModel
    {
        Id = GetHashCode(),
        Time = DateTime.Now
    };
}
  • Screenshot of test results

Request the interface, return the time, and cache the returned results in Redis, which expires after 300 seconds.

Related Links

Topics: ASP.NET Redis Attribute Database github