这次的目标是实现通过标注Attribute实现缓存的功能,精简代码,减少缓存的代码侵入业务代码。

缓存内容即为Service查询汇总的内容,不做其他高大上的功能,提升短时间多次查询的响应速度,适当减轻数据库压力。

在做之前,也去看了EasyCaching的源码,这次的想法也是源于这里,AOP的方式让代码减少耦合,但是缓存策略有限。经过考虑决定,自己实现类似功能,在之后的应用中也方便对缓存策略的扩展。

本文内容也许有点不严谨的地方,仅供参考。同样欢迎各位路过的大佬提出建议。

在项目中加入AspectCore

之前有做AspectCore的总结,相关内容就不再赘述了。

在项目中加入Stackexchange.Redis

在stackexchange.Redis和CSRedis中纠结了很久,也没有一个特别的有优势,最终选择了stackexchange.Redis,没有理由。至于连接超时的问题,可以用异步解决。

  • 安装Stackexchange.Redis
Install-Package StackExchange.Redis -Version 2.0.601
  • 在appsettings.json配置Redis连接信息
{
    "Redis": {
        "Default": {
            "Connection": "127.0.0.1:6379",
            "InstanceName": "RedisCache:",
            "DefaultDB": 0
        }
    }
}
  • RedisClient

用于连接Redis服务器,包括创建连接,获取数据库等操作

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();
            }
        }
    }
}
  • 注册服务

Redis是单线程的服务,多几个RedisClient的实例也是无济于事,所以依赖注入就采用singleton的方式。

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

创建一个缓存Key的生成器,以Attribute中的CacheKeyPrefix作为前缀,之后可以扩展批量删除的功能。被拦截方法的方法名和入参也同样作为key的一部分,保证Key值不重复。

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

写一套缓存拦截器

  • CacheAbleAttribute

Attribute中保存缓存的策略信息,包括过期时间,Key值前缀等信息,在使用缓存时可以对这些选项值进行配置。

public class CacheAbleAttribute : Attribute
{
    /// <summary>
    /// 过期时间(秒)
    /// </summary>
    public int Expiration { get; set; } = 300;

    /// <summary>
    /// Key值前缀
    /// </summary>
    public string CacheKeyPrefix { get; set; } = string.Empty;

    /// <summary>
    /// 是否高可用(异常时执行原方法)
    /// </summary>
    public bool IsHighAvailability { get; set; } = true;

    /// <summary>
    /// 只允许一个线程更新缓存(带锁)
    /// </summary>
    public bool OnceUpdate { get; set; } = false;
}
  • CacheAbleInterceptor

接下来就是重头戏,拦截器中的逻辑就相对于缓存的相关策略,不用的策略可以分成不同的拦截器。
这里的逻辑参考了EasyCaching的源码,并加入了Redis分布式锁的应用。

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);
            //缓存值不可用
            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;
        }
    }
}
  • 注册拦截器

在AspectCore中注册CacheAbleInterceptor拦截器,这里直接注册了用于测试的DemoService,
在正式项目中,打算用反射注册需要用到缓存的Service或者Method。

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

测试缓存功能

  • 在需要缓存的接口/方法上标注Attribute
[CacheAble(CacheKeyPrefix = "test", Expiration = 30, OnceUpdate = true)]
public virtual DateTimeModel GetTime()
{
    return new DateTimeModel
    {
        Id = GetHashCode(),
        Time = DateTime.Now
    };
}
  • 测试结果截图

请求接口,返回时间,并将返回结果缓存到Redis中,保留300秒后过期。

相关链接

01-01 10:55