对于web 网站来说,10多年前的项目重构asp.net缓存让网站跑得更快,利用好缓存一直是一种简单粗爆提高网站性能的最佳方法,无论技术怎样更新升级,对后端技术来讲,要让网站拥有更好的吞吐量,承载更多的用户使用的话,除了服务器资源合理配置外,缓存一直是个永恒的话题。
因我们的技术体系还是使用的ASP.NET WebForm,而它也为我们提供了相应的API接口。ASP.NET 中的缓存是一个很棒的 API,可以让您将经常访问的数据存储在内存中,以便将来可以更快地检索这些数据。因此,您几乎可以立即获取数据,而不是对数据库进行相对缓慢的调用或访问其它API。
使用缓存面临的痛点
使用缓存时,缓存的有效期控制是一大难题,缓存失效是计算机科学中仅有的两个难题之一,缓存失效只是意味着确定存储的数据何时需要刷新。但这很困难,因为我们无法预测未来,数据有可能需要在 2 分钟内刷新,也可能在整周内有效。
缓存刷新的问题,ASP.NET 的Cache接口为我们提供了相应的Api,不过这些远远不够,我们可不想每次都手动的去调用,最好是自动为我们刷新缓存,保持缓存数据的最新。
常见缓存代码
在 ASP.NET 中自带的缓存接口可以存储任何类型的对象,来看看我们项目中是如何使用缓存的。下面的代码来自于微软官网的示例:
public DataTable GetCustomers(bool BypassCache)
{
string cacheKey = "CustomersDataTable";
object cacheItem = Cache[cacheKey] as DataTable;
if((BypassCache) || (cacheItem == null))
{
cacheItem = GetCustomersFromDataSource();
Cache.Insert(cacheKey, cacheItem, null,
DateTime.Now.AddSeconds(GetCacheSecondsFromConfig(cacheKey),
TimeSpan.Zero);
}
return (DataTable)cacheItem;
}
上面的代码,是大多数人会写出来的代码,逻辑非常的简单,先从缓存里面取出代表客户集合的数据并转换为DataTable,如果缓存里面有数据,直接返回,否则进入if 逻辑,执行从数据库查询的操作返回数据,并将其写入缓存。
以上代码是不是很经典的一段代码呢?但你有没有想过,当我们的项目中有成千上万的这种代码时,是不是看起来很糟糕了呢?随着项目的经年日久,屎山就这样堆起来了。
再来看看来自于我们真实项目中使用的Redis缓存的示例,当然本文的要义是讨论asp.net 缓存,事实上你明白了本文推荐的做法,可以引申到类似的业务场景代码中。
public class ServiceRep : BaseRep<Service>
{
private readonly string CacheKey = typeof(Service).FullName;
public new IEnumerable<Service> Query()
{
if (CacheValues.Enabled)
{
List<Service> services = CacheManager.GetCache<List<Service>>(CacheKey);
if (services != null && services.Count > 0)
{
return services;
}
services = base.Query().ToList();
CacheManager.SetCache(CacheKey, services);
return services;
}
return base.Query();
}
//其它代码。。。
public new int Save(Service service)
{
try
{
int res = base.Save(service);
RemoveCache();
return res;
}
catch (Exception e)
{
Console.WriteLine(e);
throw e;
}
}
public new int Delete(Guid id)
{
int res = base.Delete(id);
if (CacheValues.Enabled)
{
CacheManager.RemoveCache(CacheKey);
Query();
}
return res;
}
private void RemoveCache()
{
if (!CacheValues.Enabled)
{
return;
}
CacheManager.RemoveCache(CacheKey);
}
}
不难从上面的示例代码中发现Query()、Save()、Delete()、RemoveCache()等方法,分别对应着缓存操作的写入,移除操作等;假如项目中有几百张表都这样编写一个数据访问类并且缓存数据的话,那么这种千篇一律的代码将会是对你体力的一个考验。
以上的示例代码,我们可以运用编码实践中的【单一职责原则】进行封装重构,让来自于数据库的数据放入缓存不需要在相关的方法里面编写相同逻辑的代码,即大量的if else 等逻辑代码。
重构asp.net缓存
随着项目的增大,类文件的增多,如果靠体力再来编写这种重复代码,是不是浪费了工程师的头衔呢?那么我们该怎样封装才能让缓存的代码从我们的业务代码里面消失呢?答案就是让需要的数据自动加载到缓存即可,当你要移除缓存时,也是自动的处理就OK了。
让我们先来看一看封装后的终极效果吧,之后再来介绍是如何做的。
终极效果一览
缓存数据的代码:
[AutoCache(CacheKey = nameof(GlobalRuntimeCache.jhrs.com), IsNullSaveDefault = true)] public new IEnumerable<Service> Query() => base.Query();
移除缓存的代码:
[AutoRemoveCache(CacheKey = nameof(GlobalRuntimeCache.jhrs.com))] public new int Save(Service service) => base.Save(service);
通过上面展示的终极封装示,实现了让查询数据的只关注查询数据,保存功能的代码只关注保存数据即可,而缓存的逻辑处理,交给了2个特性AutoCache和AutoRemoveCache分别来实现。当某个方法需要应用缓存时,直接加上这相应的特性即可,真正实现让业务方法只关注业务即可,也就满足了【单一职责原则】,对于单元测试来说也相当友好。
开始封装
当你在某个拥有返回值的方法上标记上了AutoCache这个特性就可以让该方法返回值存入缓存,下次再调用该方法时,自动将缓存的值返回即可,无须再进行真实的业务处理,如查询数据库、网络接口调用等。
缓存键的处理,也是在该特性内部自动生成,底层会根据被调用方法的参数名,参数值进行MD5生成缓存key(即调用GetKey方法)。编写AutoCacheAttribute类,实现IMethodAdvice接口,该接口需要通过Nuget引用【MrAdvice】类库,该库是一个开源免费的面向切面的组件,本文稍后会简单的介绍一下什么是面向切面编程,以及它能为我们解决什么问题。
自动缓存实现:
/// <summary>
/// 用AOP来实现自动缓存
/// </summary>
public class AutoCacheAttribute : Attribute, IMethodAdvice
{
/// <summary>
/// 缓存键值
/// </summary>
public string CacheKey { get; set; }
/// <summary>
/// 缓存区域,标记属于什么模块/业务的缓存,方便快速清除
/// </summary>
public string CacheArea { get; set; }
/// <summary>
/// 滑动过期
/// </summary>
public bool EnableSliding { get; set; }
/// <summary>
/// 缓存时间,分钟
/// </summary>
public int CacheMinutes { get; set; }
/// <summary>
/// 如果缓存值是空值,自动初始化该类型对象存入缓存
/// </summary>
public bool IsNullSaveDefault { get; set; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="cacheKey">缓存key</param>
/// <param name="cacheArea">缓存区域,标记属于什么模块/业务的缓存,方便快速清除</param>
/// <param name="cacheMinutes">缓存时间,分钟,默认0分钟(表示永久缓存)</param>
/// <param name="enableSliding">使用滑动过期缓存控制策略</param>
/// <param name="isNullSaveDefault">如果空值,缓存默认值</param>
public AutoCacheAttribute(string cacheKey = "", string cacheArea = "", int cacheMinutes = 0, bool enableSliding = false, bool isNullSaveDefault = false)
{
CacheKey = cacheKey;
CacheArea = cacheArea;
EnableSliding = enableSliding;
CacheMinutes = cacheMinutes;
IsNullSaveDefault = isNullSaveDefault;
}
/// <summary>
/// AOP组件拦截方法,用于实现自动缓存,有缓存时直接返回;
/// 没有缓存时,调用被拦截方法后,有返回值则将数据自动缓存起来
/// </summary>
/// <param name="context"></param>
public void Advise(MethodAdviceContext context)
{
var key = CacheKey.IsNullOrEmpty() ? GetKey(context) : CacheKey;
if (!CacheArea.IsNullOrEmpty()) key = $"{CacheArea}_{key}";
int cacheMinutes = 0 >= CacheMinutes ? 0 : CacheMinutes;
if (context.HasReturnValue && key.TryGetCache(out object m))
{
context.ReturnValue = m;
}
else
{
context.Proceed();
if (context.HasReturnValue)
{
if (IsNullSaveDefault && context.ReturnValue == null)
{
var type = context.TargetType;
var d = Activator.CreateInstance(type);
if (cacheMinutes == 0)
d.SetCache(key);
else
d.SetCache(key, cacheMinutes, EnableSliding);
}
if (context.ReturnValue != null)
{
if (cacheMinutes == 0)
context.ReturnValue.SetCache(key);
else
context.ReturnValue.SetCache(key, cacheMinutes, EnableSliding);
}
}
}
}
/// <summary>
/// 获取缓存key,key的规则为: md5(类全名|方法名|参数列表拆分数组|参数值的json数组),这样可以保证唯一
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private string GetKey(MethodAdviceContext context)
{
var array = context.TargetMethod.GetParameters();
var key = array.Select(x => { return context.Arguments[x.Position].ToJson(); });
var cacheKey = $"{context.Target}|{context.TargetName}|{string.Join("_", array.Select(x => x.Name))}|{string.Join("_", key)}".ToMd5Hash();
return cacheKey;
}
}
自动移除缓存实现:
/// <summary>
/// 自动移除缓存
/// </summary>
public class AutoRemoveCacheAttribute : Attribute, IMethodAdvice
{
/// <summary>
/// 待移除缓存key
/// </summary>
public string CacheKey { get; set; }
/// <summary>
/// 待移除缓存区域
/// </summary>
public string CacheArea { get; set; }
/// <summary>
/// 自动移除缓存
/// </summary>
/// <param name="cacheKey">缓存key</param>
/// <param name="cacheArea">缓存区域</param>
public AutoRemoveCacheAttribute(string cacheKey = "", string cacheArea = "")
{
CacheKey = cacheKey;
CacheArea = cacheArea;
}
/// <summary>
/// AOP组件拦截方法,用于实现自动缓存,有缓存时直接返回;
/// 没有缓存时,调用被拦截方法后,有返回值则将数据自动缓存起来
/// </summary>
/// <param name="context"></param>
public void Advise(MethodAdviceContext context)
{
context.Proceed();
if (!CacheKey.IsNullOrEmpty()) CacheKey.RemoveCache();
if (!CacheArea.IsNullOrEmpty()) CacheArea.RemoveAreaCache();
CacheKey.RemoveRemoteCache(CacheArea);
}
}
以上2个继承自Attribute类并且实现IMethodAdvice接口的类为我们实现了优雅的封装缓存相关的操作,让我们在需要调用缓存或者刷新的地方,只需要标记打上相应的特性就可以了,这样做可以让其它方法保持单一职责,只需要做自己需要做的事情即可。
面向切面编程
本文封装缓存的实现运用了面向切面编程的思想,避免了大量的重复代码,并且增强代码可读性,那么什么是面向切面编程呢?
面向切面的程序设计(Aspect-oriented programming,AOP,又译作面向方面的程序设计、剖面导向程序设计),是计算机科学中的一种程序设计思想,旨在将横切关注点与业务主体进行进一步分离,以提高程序代码的模块化程度。
.net平台有著名的Postsharp(商业组件),不过我们选用开源免费的MrAdvice类库;java有spring,同样可以实现AOP。
结论
我们运用AOP封装了缓存操作,事实上像日志,权限,监控等这些常见的非业务核心功能,都可以运用这种思想进行封装,减少大量重复代码。
当我们埋头耕耘的时候,被各种烦杂的业务缠身不得脱时,偶尔抬头仰望一下星空并且思考一会儿,一个好的点子就出来了;开发一个组件,一个功能是很有趣的,如果能用最少的代码实现最稳定的功能,何必每天Ctrl+C和Ctrl+V呢?
