使用 C# 和 PostSharp 进行简单的缓存






4.96/5 (22投票s)
在 C# 应用程序中轻松支持缓存以提高其性能。
引言
本文介绍了一种使用 PostSharp 的强大功能,在 C# 中实现缓存的简单方法。文章还讨论了使用缓存时可能出现的两个问题,并提出了解决方案。
在本文中,我将使用一个简单的图书存储库示例,并尝试在名为 GetBooks()
的方法上实现缓存,该方法按作者检索图书。显然,为了本文的目的,这是一个人造的例子,可能不需要缓存,因为如果数据库太小,就不需要缓存,而如果数据库太庞大,则缓存可能不切实际。然而,它足以说明本文的观点。
使用 .NET Framework 的 MemoryCache 实现缓存
我想缓存的 GetBooks()
方法的实现如下:
public static IEnumerable<Book> GetBooks(string authorName)
{
Thread.Sleep(1000);
using (var repository = new BookRepository())
{
return repository.Authors
.Where(a => a.Name == authorName)
.SelectMany(a => a.Books)
.ToList();
}
}
请注意,我添加了 1000 毫秒的延迟来模拟一个慢速方法。现在的目标是在此方法中支持缓存,以便在缓存命中时立即返回结果。我们将采用 .NET Framework 的 MemoryCache
对象,并修改我们的函数如下:
public static IEnumerable<Book> GetBooks(string authorName)
{
// Do we have the result in the cache?
var result = MemoryCache.Default.Get(authorName) as IEnumerable<Book>;
if (result != null)
// Yay, we have it!
return result;
// Sadly not, we have to wait for 1 second :(
Thread.Sleep(1000);
using (var repository = new BookRepository())
{
result = repository.Authors
.Where(a => a.Name == authorName)
.SelectMany(a => a.Books)
.ToList();
}
// Stores the result in the cache so that we yay the next time!
MemoryCache.Default.Set(authorName, result,
DateTimeOffset.Now.Add(new TimeSpan(0, 0, 30, 0)));
return result;
}
现在我们已经增强了方法以支持缓存,让我们运行该方法 10 次来查看其性能。我不会在这里浪费空间粘贴测试代码或结果,但简而言之,第一次执行该方法大约需要 1083.0619 秒,而——正如您可能已经猜到的——其他九次执行仅需 0 毫秒。是的,我们的缓存正在工作!遗憾的是,这里存在一些问题和担忧!
重复的恶果
上述代码的主要问题是重复,或者用 Andrew Hunt 和 Dvaid Thomas 在 《实用程序员》 中称之为“重复的恶果”。因为如果我们想在 100 个方法中实现缓存,我们需要复制、粘贴和调整代码 100 次。如果我们在 100 个方法中实现缓存后,决定要对缓存机制进行一些修改呢?或者如果我们发现了一个我们从未想过的 bug 呢?这时 面向切面编程 就派上用场了。遗憾的是,真的非常遗憾,.NET Framework 本身并不支持 AOP(我仍然不知道为什么微软不在 .NET 中实现 AOP;而在 Python 中,例如,使用装饰器可以非常容易地实现 AOP)。但是,使用一个名为 PostSharp 的第三方工具,我们可以在 C#(我猜 VB.NET 也可以)中实现 AOP。
在它众多的功能中,PostSharp 允许您通过使用 MethodInterceptionAspect
属性来拦截方法的行为,当然,您需要继承它来实现您的拦截。下面的示例说明了这个想法:
[Serializable]
class MyInterceptionAttribute : MethodInterceptionAspect
{
public override void OnInvoke(MethodInterceptionArgs args)
{
// Insert some code before the method is called
base.OnInvoke(args);
// Insert some code after the method is called.
}
}
正如您所见,这个想法很简单:重写 OnInvoke()
,进行一些预处理,调用基类的 OnInvoke()
,然后进行一些后处理。正如您可能已经猜到的,只要您手动设置 args.ReturnValue
以便返回值,您就不必调用 base.OnInvoke()
。因此,为了实现缓存,我们所要做的就是检查结果是否已缓存,如果已缓存,则手动设置 args.ReturnValue
,然后从函数返回,而不调用 base.OnInvoke()
。如果我们没有在缓存中找到结果,我们将调用 base.OnInvoke()
,然后将 args.ReturnValue
缓存起来。
在此实现返回正确结果之前,我们需要为传递给方法的不同参数在缓存中使用不同的键,否则在第一次调用后,该方法将始终返回相同的值,而忽略输入参数。PostSharp 在 args.Arguments
中暴露了调用方法的参数,因此我们将使用它来生成正在缓存的结果的唯一键。
[Serializable]
class CacheableResultAttribute : MethodInterceptionAspect
{
public override void OnInvoke(MethodInterceptionArgs args)
{
var cache = MethodResultCache.GetCache(args.Method);
var result = cache.GetCachedResult(args.Arguments);
if (result != null)
{
args.ReturnValue = result;
return;
}
base.OnInvoke(args);
cache.CacheCallResult(args.ReturnValue, args.Arguments);
}
}
正如您所见,我们调用了 GetCacheResult()
方法(其实现稍后在讨论 MethodResultCache
实现时进行),并将 PostSharp 提供的参数传递给它。稍后在方法中,我们调用 CacheCallResult()
,并再次将 PostSharp 提供的参数与结果一起传递,以便为下一次运行缓存结果。此外,我们通过将 args.Method
传递给 MethodResultCache.GetCache()
来检索缓存对象,这将为不同的方法名返回不同的缓存对象。这样,我们就避免了因方法名相同但来自不同类而被缓存结果覆盖的问题。它还有另一个优点,我们将在后面讨论,所以请耐心等待。
既然我们已经实现了一个拦截方法行为以支持缓存的属性,我们所需要做的就是将此属性添加到任何我们想要支持缓存的方法上。将其应用于我们的 GetBooks()
方法,它现在看起来如下:
[CacheableResult]
public static IEnumerable<Book> GetBooks(string authorName)
{
Thread.Sleep(1000);
using (var repository = new BookRepository())
{
return repository.Authors
.Where(a => a.Name == authorName)
.SelectMany(a => a.Books)
.ToList();
}
}
相当简洁和优雅,不是吗?
使缓存结果失效
虽然上面的代码以一种非常简洁的方式实现了缓存,但它存在一个潜在的严重问题。如果我们的数据发生更改而缓存的结果不再正确怎么办?如果您可以接受更改需要一些时间才能反映出来,那么您不必担心这个问题,否则,您将需要使缓存的结果失效,以确保下次调用该方法时,数据将重新从数据库中获取。
解决这个问题不像看起来那么容易,因为我们如何知道需要使哪些缓存结果失效呢?例如,如果我们修改了 Ross L. Finney 所著图书的信息,我们应该尽量避免使 Howard Anton 所著图书的缓存结果失效。我认为解决这个问题是上下文相关的,也就是说,没有通用的解决方案,并且超出了本文的范围,所以我将实现一个简单的解决方案,即在更改发生时使所有缓存结果失效,但将其限制在可能受影响的方法上。例如,如果一本书的 ISBN 发生变化(我不确定这是否真的可能,但假设如此),那么我们需要使 GetBookAuthorByIsbn()
方法的缓存结果失效,而不是 GetBookAuthorByBookName()
方法的缓存结果。为了做到这一点,并且为了避免循环遍历缓存结果并检查它们的键(这可能是一个非常慢的操作),我为每种方法使用一个不同的缓存对象。
例如,让我们实现一个允许我们更改作者姓名的 ClearCachedResults()
方法:
public static void ChangeAuthorName(string oldName, string newName)
{
using (var repository = new BookRepository())
{
repository.Authors.Single(a => a.Name == oldName).Name = newName;
repository.SaveChanges();
MethodResultCache.GetCache(typeof (BookApi).GetMethod("GetBooks")).ClearCachedResults();
}
}
正如您所见,我正在检索 GetBooks()
方法的缓存,然后清空它。我们可以稍微简化这段代码,实现另一个拦截属性,该属性自动检索缓存并调用 ClearCachedResults()
;遗憾的是,我们仍然需要传递一个列表,其中包含将受此方法调用的所有方法的影响。因此,要解决这个问题,我们仍然需要一种方法来传递这些信息。
[AffectedCacheableMethodsAttribute("EasyCaching.APIs.BookApi.GetBooks")]
public static void ChangeAuthorName(string oldName, string newName)
{
using (var repository = new BookRepository())
{
repository.Authors.Single(a => a.Name == oldName).Name = newName;
repository.SaveChanges();
}
}
使用缓存时的身份验证和授权考虑
使用上述 CacheableResultAttribute
时出现的另一个问题与身份验证和授权有关,或者实际上是使用此属性可能破坏 API 安全性的可能性。为了解释这一点,请设想以下场景。用户 A 有权调用 GetBooks()
,而用户 B 没有。因此,我们将 GetBooks()
方法更改为检查登录用户并验证其是否有权调用此方法,如果没有,则抛出例如 AccessDeniedException
。现在,如果用户 A 调用 GetBooks()
方法,安全检查将通过,结果将被检索并缓存。然后,如果用户 B 调用 GetBooks()
方法,CacheableResultAttribute
将拦截调用并返回缓存的结果,因为它已经有了。因此,我们破坏了该方法的安全性!
为了解决这个问题,我们可以简单地更新我们的属性,使用当前登录的用户作为附加参数:
[Serializable]
class CacheableResultAttribute : MethodInterceptionAspect
{
public override void OnInvoke(MethodInterceptionArgs args)
{
var cache = MethodResultCache.GetCache(args.Method);
var arguments = args.Arguments.Union(new[] {WindowsIdentity.GetCurrent().Name}).ToList();
var result = cache.GetCachedResult(arguments);
if (result != null)
{
args.ReturnValue = result;
return;
}
base.OnInvoke(args);
cache.CacheCallResult(args.ReturnValue, arguments);
}
}
这将附加一个新参数,即用户名,作为方法调用的键。例如,调用 GetBooks("Ross L. Finney")
将生成如下键:“EasyCaching.APIs.BookApi.GetBooks(Ross L. Finney, <user name>)
”,而不是“EasyCaching.APIs.BookApi.GetBooks(Ross L. Finney)
”。这样,用户 A 将拥有一个与用户 B 不同的缓存键,我们的问题就解决了!
此解决方案不仅解决了安全问题,还解决了某些方法可能基于登录用户返回不同结果的问题,例如 GetMyBooks()
。
MethodResultCache 类
对于那些有兴趣了解 MethodResultCache
类是如何实现的,但又懒得下载本文附带的源代码的人来说,它的实现方式如下:
class MethodResultCache
{
private readonly string _methodName;
private MemoryCache _cache;
private readonly TimeSpan _expirationPeriod;
private static readonly Dictionary<string, MethodResultCache> MethodCaches =
new Dictionary<string, MethodResultCache>();
public MethodResultCache(string methodName, int expirationPeriod = 30)
{
_methodName = methodName;
_expirationPeriod = new TimeSpan(0, 0, expirationPeriod, 0);
_cache = new MemoryCache(methodName);
}
private string GetCacheKey(IEnumerable<object> arguments)
{
var key = string.Format(
"{0}({1})",
_methodName,
string.Join(", ", arguments.Select(x => x != null ? x.ToString() : "<Null>")));
return key;
}
public void CacheCallResult(object result, IEnumerable<object> arguments)
{
_cache.Set(GetCacheKey(arguments), result, DateTimeOffset.Now.Add(_expirationPeriod));
}
public object GetCachedResult(IEnumerable<object> arguments)
{
return _cache.Get(GetCacheKey(arguments));
}
public void ClearCachedResults()
{
_cache.Dispose();
_cache = new MemoryCache(_methodName);
}
public static MethodResultCache GetCache(string methodName)
{
if (MethodCaches.ContainsKey(methodName))
return MethodCaches[methodName];
var cache = new MethodResultCache(methodName);
MethodCaches.Add(methodName, cache);
return cache;
}
public static MethodResultCache GetCache(MemberInfo methodInfo)
{
var methodName = string.Format("{0}.{1}.{2}",
methodInfo.ReflectedType.Namespace,
methodInfo.ReflectedType.Name,
methodInfo.Name);
return GetCache(methodName);
}
}
这段代码需要注意的地方:
GetCacheKey()
方法接受一个参数的枚举,并使用ToString()
将每个参数转换为字符串。因此,您需要确保所有参数的ToString()
实现都能为不同的值生成不同的字符串,否则,同一方法的不同调用的缓存键可能会生成相同的键。ClearCachedResults()
方法会处置缓存对象并创建一个新的对象。原因是它找不到一个可以清除缓存值的方法。GetCache(MemberInfo methodInfo)
函数使用命名空间和类名来生成方法名,以避免来自不同类的两个同名方法覆盖彼此的缓存值。
结论
正如您所见,使用 PostSharp 可以以一种通用且可重用的方式极大地简化方法的行为更改。我们在这里用它来支持缓存,但它并不局限于此,并且想法是无穷无尽的。例如,您可以实现一个属性,该属性允许您记录方法的执行时间,从而持续分析您的方法。所以,如果您以前没有用过它,是时候用了!