65.9K
CodeProject 正在变化。 阅读更多。
Home

轻量级线程安全内存中键控通用缓存集合服务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (4投票s)

2023年4月2日

CPOL

7分钟阅读

viewsIcon

11478

downloadIcon

99

使用集合来管理缓存,而不是实现离散的缓存实例。

引言

在 .NET 中,内存缓存通常实现 System.Runtime.Caching 命名空间中的 MemoryCache 实例。它支持在条目即将被移除时进行回调的策略,以及基于绝对或滑动过期日期和/或缓存大小的逐出/移除策略。更复杂的缓存系统提供持久化,有些处理分布式缓存,但这些功能对于我的需求来说都不是必需的。MemoryCache 的一个缺点是它返回一个对象,需要一个扩展方法将对象转换为泛型类型;示例如 此处。奇怪的是,MemoryCache 没有实现“清除所有”方法——您必须处理掉缓存并重新创建它。看起来微软的实现与缓存状态耦合,特别是它是否已被处理掉。同样,这也不是我关心的问题——缓存可以被清除,但没有理由完全处理掉它。微软在 .NET 3.1 旧平台上的 MemoryCache 也有 这个 警告。

“除非需要,否则不要创建 MemoryCache 实例。如果在客户端和 Web 应用程序中创建缓存实例,则应在应用程序生命周期的早期创建 MemoryCache 实例。您必须只创建应用程序中将使用的缓存实例数量,并将缓存实例的引用存储在可以全局访问的变量中。例如,在 ASP.NET 应用程序中,您可以将引用存储在应用程序状态中。如果您只在应用程序中创建了一个缓存实例,请使用默认缓存,并在需要访问缓存时从 Default 属性获取对其的引用。”

所以看起来,至少对于 Web 应用程序而言,MemoryCache 更倾向于作为单例使用,并期望缓存会很大。我当然打算将此处描述的服务用作“单例”,但要支持几个(不到十个)小型缓存。

缺点之一(可能还有其他,但这个非常显眼)是“获取缓存项”需要键和值的泛型类型参数,例如

service.GetCachedItem<string, int>(CacheKey.Cache1, "test1");

如果键始终是 string,则可以简化代码,使其仅需要缓存值的类型。

此实现的要点是:

  1. 我需要几个缓存来管理不同类型的对象
    1. 我想要类型安全
    2. 我不想有许多离散的实例,而是一个包含所有缓存的容器。
  2. 缓存不会很大。
  3. 除非被显式清除,否则缓存应在应用程序的整个生命周期内存在。
  4. 不需要逐出/移除策略,因为这些策略无法由应用程序确定。
  5. 键控缓存集合及其管理的缓存必须是线程安全的。

因此,本文应运而生。

实现

有一些实现细节很重要,需要加以说明。

线程安全

线程安全是通过使用 ConcurrentDictionary 实例来实现的,因此无需进一步考虑。

实例化键控缓存集合

该服务是 CachingService<Q> 类型,表示所有缓存都由类型 Q 键控。例如,使用 int 作为缓存集合键来实例化该服务

// Our singleton service using an int as the cache collection key.
private CachingService<int> service = new CachingService<int>();

另一个例子是,这段代码在 ASP.NET Core 中声明了一个单例服务,使用 enum 类型

builder.Services.AddSingleton<CachingService<CacheCollectionKey>>();

然后将被传递到控制器和其他服务的构造函数中,例如

private CachingService<CacheCollectionKey> cachingService;

public RootController(CachingService<CacheCollectionKey> cachingService)
{
  this.cachingService = cachingService;
}

设置和获取缓存的键值

public void SetCachedItem<T, R>(Q type, T key, R value)
{
  var dict = GetCache<T, R>(type);
  Assert.NotNull<CachingServiceException>
  (dict, $"Cache of generic type <{typeof(T).Name}, 
  {typeof(R).Name}> does not exist.");
  dict[key] = value;
}

public R GetCachedItem<T, R>(Q type, T key, Func<T, R> creator = null)
{
  var dict = GetCache<T, R>(type);
  Assert.NotNull<CachingServiceException>
  (dict, $"Cache of generic type <{typeof(T).Name}, 
  {typeof(R).Name}> does not exist.");
  var hasKey = dict.TryGetValue(key, out R ret);
  Assert.That<CachingServiceException>(hasKey || creator != null, 
              $"Key {key} does not exist in the cache {type}");

  if (!hasKey)
  {
    ret = creator(key);
    dict[key] = ret;
  }

  return ret;
}

当我们在特定缓存中设置键值时,所有泛型类型都会被推断出来,例如

service.SetCachedItem(CacheKey.Cache1, "test1", 1);

但是,当我们获取缓存项时,我们必须提供键和值的泛型类型,例如

var n = service.GetCachedItem<string, int>(CacheKey.Cache1, "test1");

正如引言中所讨论的,这有点令人头疼,但比显式转换或 as 运算符要好。还要注意

  1. 如果类型为 <T, R> 的缓存与现有类型为 <T, R> 的字典不匹配,此方法将断言。
  2. 但是,如果类型为 <T, R> 的后端字典在集合中不存在,它将被创建。
  3. 如果缓存不包含键且未提供创建函数,此方法将断言。

移除缓存的键

public (bool removed, R) RemoveCachedItem<T, R>(Q type, T key)
{
  var dict = GetCache<T, R>(type);
  Assert.NotNull<CachingServiceException>
  (dict, $"Cache of generic type <{typeof(T).Name}, 
  {typeof(R).Name}> does not exist.");
  var removed = dict.TryRemove(key, out R ret);

  return (removed, ret);
}

此方法将返回一个元组,表明该键已从指定的缓存中移除,并返回已移除的值(如果存在)。如果键不存在,则类型为 R 的返回值将是类型 R 的默认值,对于对象,则为 null

清除缓存

有三种方法可以清除缓存,具体取决于您想做什么

  1. 清除键控缓存集合。
  2. 清除所有缓存但保留键控缓存集合。
  3. 清除特定缓存中的所有条目。
public void ClearKeyedCache()
{
  keyedCacheCollection.Clear();
}

public void ClearAllCaches()
{
  // Clear the caches, not the keyed cache dictionary.
  foreach (var cache in keyedCacheCollection.Values.Cast<IDictionary>())
  {
    cache.Clear();
  }
}

public void ClearCache(Q type)
{
  if (keyedCacheCollection.TryGetValue(type, out object typeCache))
  {
    ((IDictionary)typeCache).Clear();
  }
}

获取缓存中的项目计数

public int Count(Q type)
{
  int count = 0;

  if (keyedCacheCollection.TryGetValue(type, out object typeCache))
  {
    count = ((IDictionary)typeCache).Count;
  }

  return count;
}

请注意,如果键控缓存尚不存在,此方法不会抛出异常。

内部:创建键控缓存

在内部,我们有一个方法,如果键控缓存不存在,它会创建它

private ConcurrentDictionary<T, R> GetCache<T, R>(Q type)
{
  ConcurrentDictionary<T, R> dict;

  if (!keyedCacheCollection.TryGetValue(type, out object typeCache))
  {
    typeCache = new ConcurrentDictionary<T, R>();
    keyedCacheCollection[type] = typeCache;
    dict = typeCache as ConcurrentDictionary<T, R>;
  }
  else
  {
    dict = typeCache as ConcurrentDictionary<T, R>;
  }

  return dict;
}

此方法始终返回缓存实例,无论它创建了一个还是找到了一个现有的。由于 keyedCacheCollection 的值(类型为 object)使用 as 运算符强制转换为 ConcurrentDictionary<T, R>,因此如果泛型参数 <T, R> 存在类型不匹配,将返回 null,因此代码中会出现前面描述的断言。

Assert 类

我不喜欢使用“if”语句来确定是否抛出异常,但我又想抛出一个类型化的异常,所以我有一个简单的辅助类,我到处使用它

namespace Clifton.Lib
{
  public static class Assert
  {
    public static void That<T>(bool condition, string msg) where T : Exception, new()
    {
      if (!condition)
      {
        var ex = Activator.CreateInstance(typeof(T), new object[] { msg }) as T;
        throw ex;
      }
    }

    public static void NotNull<T>(object obj, string msg) where T : Exception, new()
    {
      if (obj == null)
      {
        var ex = Activator.CreateInstance(typeof(T), new object[] { msg }) as T;
        throw ex;
      }
    }
  }
}

因此,此服务有一个特定的异常类

public class CachingServiceException : Exception 
{
  public CachingServiceException() { }
  public CachingServiceException(string message) : base(message) { }
}

单元测试

我认为为该服务编写单元测试是个好主意,也是“记录”其用法的绝佳方式。共有 14 个单元测试

每个单元测试一开始都会清除键控缓存集合,以确保从空白开始

[TestClass]
public class CachingServiceTests 
{
  // Our singleton service using an int as the cache collection key.
  private CachingService<CacheKey> service = new CachingService<CacheKey>();

  [TestInitialize] public void TestInitialize()
  {
    service.ClearKeyedCache();
  }
  ...

并且有一个“单例”服务,为测试中使用的两个缓存实例化一次,并且有一个 enum 来表示这两个缓存

public enum CacheKey
{
  Cache1 = 1,
  Cache2 = 2
}

这里有两个测试来说明它们的写法。这个测试表明清除一个缓存不会影响另一个缓存

[TestMethod]
public void ClearOneCache()
{
  service.SetCachedItem(CacheKey.Cache1, "test1", 1);
  service.SetCachedItem(CacheKey.Cache2, "test2", "2");

  Assert.AreEqual(1, service.Count(CacheKey.Cache1));
  Assert.AreEqual(1, service.Count(CacheKey.Cache2));

  service.ClearCache(CacheKey.Cache1);

  Assert.AreEqual(0, service.Count(CacheKey.Cache1));
  Assert.AreEqual(1, service.Count(CacheKey.Cache2));
}

这个测试验证了移除一个项会返回被移除的项,并且该项确实已被移除

[TestMethod]
public void RemoveItemFromCache1()
{
  service.SetCachedItem(CacheKey.Cache1, "test1", 1);
  var item = service.RemoveCachedItem<string, int>(CacheKey.Cache1, "test1");

  Assert.IsTrue(item.removed);
  Assert.AreEqual(1, item.val);
  Assert.AreEqual(0, service.Count(CacheKey.Cache1));
}

这里,我们测试一个我们期望在缓存中的值不存在

[TestMethod, ExpectedException(typeof(CachingServiceException))]
public void ValueDoesNotExist()
{
  service.GetCachedItem<string, int>(CacheKey.Cache1, "1");
}

这里,我们测试当值在缓存中不存在时,创建值类型和对象类型值的能力

[TestMethod]
public void GetStructWithCreator()
{
  var now = DateTime.Now;
  var n = service.GetCachedItem(CacheKey.Cache1, "1", key => now);

  Assert.AreEqual(now, n);
  Assert.AreEqual(1, service.Count(CacheKey.Cache1));
}

[TestMethod]
public void GetObjectWithCreator()
{
  var obj = new TestObject() { Id = 1 };
  var n = service.GetCachedItem(CacheKey.Cache1, "1", key => obj);

  Assert.AreEqual(obj, n);
  Assert.AreEqual(obj.Id, n.Id);
  Assert.AreEqual(1, service.Count(CacheKey.Cache1));
}

还有测试来验证在特定缓存的键和值类型之间发生类型不匹配时会抛出异常

[TestMethod, ExpectedException(typeof(CachingServiceException))]
public void GetGenericKeyIsWrongType()
{
  service.SetCachedItem(CacheKey.Cache1, "1", 1);
  service.GetCachedItem<int, int>(CacheKey.Cache1, 1);
}

[TestMethod, ExpectedException(typeof(CachingServiceException))]
public void GetGenericValueIsWrongType()
{
  service.SetCachedItem(CacheKey.Cache1, "1", 1);
  service.GetCachedItem<string, string>(CacheKey.Cache1, "1");
}

结论

理想情况下,写这样一篇文章的目的就是让读者告诉我为什么他们不会使用这个实现,以及为什么 MemoryCache 即使在我简化了需求的情况下仍然是更好的解决方案。回顾我的轻量级需求

  1. 我需要几个缓存来管理不同类型的对象
    1. 我想要类型安全
    2. 我不想有许多离散的实例,而是一个包含所有缓存的容器。
  2. 缓存不会很大。
  3. 除非被显式清除,否则缓存应在应用程序的整个生命周期内存在。
  4. 不需要逐出/移除策略,因为这些策略无法由应用程序确定。
  5. 键控缓存集合及其管理的缓存必须是线程安全的。

当然,对于这个实现可以提出一些论点

  1. 为每种类型使用离散缓存有什么问题?
  2. MemoryCache 返回的对象转换为所需类型或使用 as 转换有什么问题?
  3. 使用参考文献中提到的扩展方法有什么问题?
  4. 不处理掉缓存并重新创建它来清除缓存内容有什么问题?
  5. 使用 MemoryCache 有什么问题?

答案是,真的没有什么问题!我在这里提出的代码,针对我的五个问题,是:

  1. 我不想使用离散缓存。
  2. 我不想进行转换或使用 as...
  3. 尽管我很喜欢 Cynthia 在参考文献中链接和发布的解决方案。
  4. 无法在不处理并重新创建的情况下清除缓存确实让我感到困扰。
  5. 因为我不需要过期/逐出策略,而且我只管理少量缓存数据。

我期待您的反馈!

参考文献

public static T AddOrGetExisting<T>
(ObjectCache cache, string key, Func<(T item, CacheItemPolicy policy)> addFunc)
{
  object cachedItem = cache.Get(key);
  if (cachedItem is T t)
    return t;
  (T item, CacheItemPolicy policy) = addFunc();
  cache.Add(key, item, policy);
  return item;
}

历史

  • 2023 年 4 月 2 日:初始版本
© . All rights reserved.