首页 > 代码库 > 一步步实现一个基本的缓存模块

一步步实现一个基本的缓存模块

一步步实现一个基本的缓存模块

    1. 前言
    2.  请求级别缓存
    2.1 多线程
    3.  进程级别缓存
    3.1 分区与计数
    3.2 可空缓存值
    3.3 封装与集成
    4.  小结

1. 前言

  • 面向读者:初、中级用户;
  • 涉及知识:HttpContext、HttpRuime.Cache、DictionaryEntry、Unit Test等;
  • 文章目的:这里的内容不会涉及 Memcached、Redies 等进程外缓存的使用,只针对包含WEB应用的常见场景,实现一个具有线程安全、分区、过期特性的缓存模块,略微提及DI等内容。
  • jusfr 原创,转载请注明来自博客园。


2.  请求级别缓存

如果需要线程安全地存取数据,System.Collections.Concurrent 命名空间下的像 ConcurrentDictionary 等实现是首选;更复杂的特性像过期策略、文件依赖等就需要其他实现了。ASP.NET中的HttpContext.Current.Items 常常被用作自定义数据容器,注入工具像Unity、Autofac 等便借助自定义 HttpModule 将容器挂接在 HttpContext.Current 上以进行生命周期管理。

基本接口 ICacheProvider,请求级别的缓存从它定义,考虑到请求级别缓存的运用场景有限,故只定义有限特性;

1     public interface ICacheProvider {2         Boolean TryGet<T>(String key, out T value);3         T GetOrCreate<T>(String key, Func<T> function);4         T GetOrCreate<T>(String key, Func<String, T> factory);5         void Overwrite<T>(String key, T value);6         void Expire(String key);7     }

HttpContext.Current.Items 从 IDictionary 定义,存储 Object-Object 键值对,出于便利与直观,ICacheProvider 只接受String类型缓存键,故HttpContextCacheProvider内部使用 BuildCacheKey(String key) 方法生成真正缓存键以避免键值重复;

同时 HashTable 可以存储空引用作为缓存值,故 TryGet() 方法先进行 Contains() 判断存在与否,再进行类型判断,避免缓存键重复使用;  

 1 public class HttpContextCacheProvider : ICacheProvider { 2         protected virtual String BuildCacheKey(String key) { 3             return String.Concat("HttpContextCacheProvider_", key); 4         } 5  6         public Boolean TryGet<T>(String key, out T value) { 7             key = BuildCacheKey(key); 8             Boolean exist = false; 9             if (HttpContext.Current.Items.Contains(key)) {10                 exist = true;11                 Object entry = HttpContext.Current.Items[key];12                 if (entry != null && !(entry is T)) {13                     throw new InvalidOperationException(String.Format("缓存项`[{0}]`类型错误, {1} or {2} ?",14                         key, entry.GetType().FullName, typeof(T).FullName));15                 }16                 value =http://www.mamicode.com/ (T)entry;17             }18             else {19                 value = http://www.mamicode.com/default(T);20             }21             return exist;22         }23 24         public T GetOrCreate<T>(String key, Func<T> function) {25             T value;26             if (TryGet(key, out value)) {27                 return value;28             }29             value =http://www.mamicode.com/ function();30             Overwrite(key, value);31             return value;32         }33 34         public T GetOrCreate<T>(String key, Func<String, T> factory) {35             T value;36             if (TryGet(key, out value)) {37                 return value;38             }39             value =http://www.mamicode.com/ factory(key);40             Overwrite(key, value);41             return value;42         }43 44         public void Overwrite<T>(String key, T value) {45             key = BuildCacheKey(key);46             HttpContext.Current.Items[key] = value;47         }48 49         public void Expire(String key) {50             key = BuildCacheKey(key);51             HttpContext.Current.Items.Remove(key);52         }53     }

这里使用了 Func<T> 委托的运用,合并查询、判断和添加缓存项的操作以简化接口调用;如果用户期望不同类型缓存值可以存储到相同的 key 上,则需要重新定义 BuildCacheKey() 方法将缓存值类型作为参数参与生成缓存键,此时 Expire() 方法则同样需要了。测试用例:

 1 [TestClass] 2     public class HttpContextCacheProviderTest { 3         [TestInitialize] 4         public void Initialize() { 5             HttpContext.Current = new HttpContext(new HttpRequest(null, "http://localhost", null), new HttpResponse(null)); 6         } 7  8         [TestMethod] 9         public void NullValue() {10             var key = "key-null";11             HttpContext.Current.Items.Add(key, null);12             Assert.IsTrue(HttpContext.Current.Items.Contains(key));13             Assert.IsNull(HttpContext.Current.Items[key]);14         }15 16         [TestMethod]17         public void ValueType() {18             var key = "key-guid";19             ICacheProvider cache = new HttpContextCacheProvider();20             var id1 = Guid.NewGuid();21             var id2 = cache.GetOrCreate(key, () => id1);22             Assert.AreEqual(id1, id2);23 24             cache.Expire(key);25             Guid id3;26             var exist = cache.TryGet(key, out id3);27             Assert.IsFalse(exist);28             Assert.AreNotEqual(id1, id3);29             Assert.AreEqual(id3, Guid.Empty);30         }31     }
View Code

引用类型测试用例忽略。


2.1 多线程

异步等情况下,HttpContext.Current并非无处不在,故异步等情况下 HttpContextCacheProvider 的使用可能抛出空引用异常,需要被处理,对此园友有过思考 ,这里贴上A大的方案 ,有需求的读者请按图索骥。

3.  进程级别缓存

HttpRuntime.Cache 定义在 System.Web.dll 中,System.Web 命名空间下,实际上是可以使用在非 Asp.Net 应用里的;另外 HttpContext 对象包含一个 Cache 属性,它们的关系可以阅读 HttpContext.Cache 和 HttpRuntime.Cache;

HttpRuntime.Cache 为 System.Web.Caching.Cache 类型,支持滑动/绝对时间过期策略、支持缓存优先级、缓存更新/过期回调、基于文件的缓存依赖项等,功能十分强大,这里借用少数特性来实现进程级别缓存,更多文档请自行检索。

从 ICacheProvider 定义 IHttpRuntimeCacheProvider,添加相对过期与绝对过期、添加批量的缓存过期接口 ExpireAll();

1     public interface IHttpRuntimeCacheProvider : ICacheProvider {2         T GetOrCreate<T>(String key, Func<T> function, TimeSpan slidingExpiration);3         T GetOrCreate<T>(String key, Func<T> function, DateTime absoluteExpiration);4         void Overwrite<T>(String key, T value, TimeSpan slidingExpiration);5         void Overwrite<T>(String key, T value, DateTime absoluteExpiration);6         void ExpireAll();7     }

System.Web.Caching.Cache 只继承 IEnumerable,内部使用 DictionaryEntry 存储Object-Object 键值对,但 HttpRuntime.Cache 只授受字符串类型缓存键及非空缓存值,关于空引用缓存值的问题,我们在3.2中讨论;

故 TryGet() 与 HttpContextCacheProvider.TryGet() 具有显著差异,前者需要拿出值来进行非空判断,后者则是使用 IDictionary.Contains() 方法;

除了 TryGet() 方法与过期过期参数外的差异外,接口实现与 HttpContextCacheProvider 类似;

 1     public class HttpRuntimeCacheProvider : IHttpRuntimeCacheProvider { 2         private static readonly Object _sync = new Object(); 3  4         protected virtual String BuildCacheKey(String key) { 5             return String.Concat("HttpRuntimeCacheProvider_", key); 6         } 7  8         public Boolean TryGet<T>(String key, out T value) { 9             key = BuildCacheKey(key);10             Boolean exist = false;11             Object entry = HttpRuntime.Cache.Get(key);12             if (entry != null) {13                 exist = true;14                 if (!(entry is T)) {15                     throw new InvalidOperationException(String.Format("缓存项[{0}]类型错误, {1} or {2} ?",16                         key, entry.GetType().FullName, typeof(T).FullName));17                 }18                 value =http://www.mamicode.com/ (T)entry;19             }20             else {21                 value = http://www.mamicode.com/default(T);22             }23             return exist;24         }25 26         public T GetOrCreate<T>(String key, Func<String, T> factory) {27             T result;28             if (TryGet<T>(key, out result)) {29                 return result;30             }31             result = factory(key);32             Overwrite(key, result);33             return result;34         }35 36         public T GetOrCreate<T>(String key, Func<T> function) {37             T result;38             if (TryGet<T>(key, out result)) {39                 return result;40             }41             result = function();42             Overwrite(key, result);43             return result;44         }45 46 47         public T GetOrCreate<T>(String key, Func<T> function, TimeSpan slidingExpiration) {48             T result;49             if (TryGet<T>(key, out result)) {50                 return result;51             }52             result = function();53             Overwrite(key, result, slidingExpiration);54             return result;55         }56 57         public T GetOrCreate<T>(String key, Func<T> function, DateTime absoluteExpiration) {58             T result;59             if (TryGet<T>(key, out result)) {60                 return result;61             }62             result = function();63             Overwrite(key, result, absoluteExpiration);64             return result;65         }66 67         public void Overwrite<T>(String key, T value) {68             HttpRuntime.Cache.Insert(BuildCacheKey(key), value);69         }70 71         //slidingExpiration 时间内无访问则过期72         public void Overwrite<T>(String key, T value, TimeSpan slidingExpiration) {73             HttpRuntime.Cache.Insert(BuildCacheKey(key), value, null,74                 Cache.NoAbsoluteExpiration, slidingExpiration);75         }76 77         //absoluteExpiration 绝对时间过期78         public void Overwrite<T>(String key, T value, DateTime absoluteExpiration) {79             HttpRuntime.Cache.Insert(BuildCacheKey(key), value, null,80                 absoluteExpiration, Cache.NoSlidingExpiration);81         }82 83         public void Expire(String key) {84             HttpRuntime.Cache.Remove(BuildCacheKey(key));85         }86 87         public void ExpireAll() {88             lock (_sync) {89                 var entries = HttpRuntime.Cache.OfType<DictionaryEntry>()90                     .Where(entry => (entry.Key is String) && ((String)entry.Key).StartsWith("HttpRuntimeCacheProvider_"));91                 foreach (var entry in entries) {92                     HttpRuntime.Cache.Remove((String)entry.Key);93                 }94             }95         }96     }

测试用例与 HttpContextCacheProviderTest 类似,这里贴出缓存过期的测试:  

 1 public class HttpRuntimeCacheProviderTest { 2         [TestMethod] 3         public void GetOrCreateWithAbsoluteExpirationTest() { 4             var key = Guid.NewGuid().ToString(); 5             var val = Guid.NewGuid(); 6  7             IHttpRuntimeCacheProvider cacheProvider = new HttpRuntimeCacheProvider(); 8             var result = cacheProvider.GetOrCreate<Guid>(key, () => val, DateTime.UtcNow.AddSeconds(2D)); 9             Assert.AreEqual(result, val);10 11             var exist = cacheProvider.TryGet<Guid>(key, out val);12             Assert.IsTrue(exist);13             Assert.AreEqual(result, val);14 15             Thread.Sleep(2000);16             exist = cacheProvider.TryGet<Guid>(key, out val);17             Assert.IsFalse(exist);18             Assert.AreEqual(val, Guid.Empty);19         }20 21         [TestMethod]22         public void ExpireAllTest() {23             var key = Guid.NewGuid().ToString();24             var val = Guid.NewGuid();25 26             IHttpRuntimeCacheProvider cacheProvider = new HttpRuntimeCacheProvider();27             var result = cacheProvider.GetOrCreate<Guid>(key, () => val);28             Assert.AreEqual(result, val);29 30             cacheProvider.ExpireAll();31             Guid val2;32             var exist = cacheProvider.TryGet<Guid>(key, out val2);33             Assert.IsFalse(exist);34             Assert.AreEqual(val2, Guid.Empty);35         }36     }
View Code

3.1 分区与计数

缓存分区是常见需求,缓存用户A、用户B的认证信息可以拿用户标识作为缓存键,但每个用户分别有一整套包含授权的其他数据时,为创建以用户分区的缓存应该是更好的选择;
常规的想法是为缓存添加类似 `Region` 或 `Partition`的参数,个人觉得这不是很好的实践,因为接口被修改,同时过多的参数非常让人困惑;

读者可能对前文中 BuildCacheKey() 方法被 protected virtual 修饰觉得很奇怪,是的,个人觉得定义新的接口,配合从缓存Key的生成算法作文章来分区貌似比较巧妙,也迎合依赖注册被被广泛使用的现状;

分区的进程级别缓存定义,只需多出一个属性:

1     public interface IHttpRuntimeRegionCacheProvider : IHttpRuntimeCacheProvider {2         String Region { get; }3     }

分区的缓存实现,先为 IHttpRuntimeCacheProvider 添加计数,然后重构HttpRuntimeCacheProvider,提取出过滤算法,接着重写 BuildCacheKey() 方法的实现,使不同分区的生成不同的缓存键,缓存项操作方法无须修改;  

 1 public interface IHttpRuntimeCacheProvider : ICacheProvider { 2         ... 3         Int32 Count { get; } 4     } 5  6      public class HttpRuntimeCacheProvider : IHttpRuntimeCacheProvider { 7         ... 8         protected virtual Boolean Hit(DictionaryEntry entry) { 9             return (entry.Key is String) && ((String)entry.Key).StartsWith("HttpRuntimeCacheProvider_");10         }11 12         public void ExpireAll() {13             lock (_sync) {14                 var entries = HttpRuntime.Cache.OfType<DictionaryEntry>().Where(Hit);15                 foreach (var entry in entries) {16                     HttpRuntime.Cache.Remove((String)entry.Key);17                 }18             }19         }20 21         public Int32 Count {22             get {23                 lock (_sync) {24                     return HttpRuntime.Cache.OfType<DictionaryEntry>().Where(Hit).Count();25                 }26             }27         }28     }29 30     public class HttpRuntimeRegionCacheProvider : HttpRuntimeCacheProvider, IHttpRuntimeRegionCacheProvider {31         private String _prefix;32         public virtual String Region { get; private set; }33 34         private String GetPrifix() {35             if (_prefix == null) {36                 _prefix = String.Concat("HttpRuntimeRegionCacheProvider_", Region, "_");37             }38             return _prefix;39         }40 41         public HttpRuntimeRegionCacheProvider(String region)  {42             Region = region;43         }44 45         protected override String BuildCacheKey(String key) {46             //Region 为空将被当作  String.Empty 处理47             return String.Concat(GetPrifix(), base.BuildCacheKey(key));48         }49 50         protected override Boolean Hit(DictionaryEntry entry) {51             return (entry.Key is String) && ((String)entry.Key).StartsWith(GetPrifix());52         }53     }

测试用例示例了两个分区缓存对相同 key 的操作: 

 1  [TestClass] 2     public class HttpRuntimeRegionCacheProviderTest { 3         [TestMethod] 4         public void ValueType() { 5             var key = "key-guid"; 6             IHttpRuntimeCacheProvider cache1 = new HttpRuntimeRegionCacheProvider("Region1"); 7             var id1 = cache1.GetOrCreate(key, Guid.NewGuid); 8  9             IHttpRuntimeCacheProvider cache2 = new HttpRuntimeRegionCacheProvider("Region2");10             var id2 = cache2.GetOrCreate(key, Guid.NewGuid);11             Assert.AreNotEqual(id1, id2);12 13             cache1.ExpireAll();14             Assert.AreEqual(cache1.Count, 0);15             Assert.AreEqual(cache2.Count, 1);16         }17     }
View Code

至此一个基本的缓存模块已经完成;

3.2 可空缓存值

前文提及过,HttpRuntime.Cache 不授受空引用作为缓存值,与 HttpContext.Current.Items表现不同,另一方面实际需求中,空值作为字典的值仍然是有意义,此处给出一个支持空缓存值的实现;

HttpRuntime.Cache 断然是不能把 null 存入的,查看 HttpRuntimeCacheProvider.TryGet() 方法,可知 HttpRuntime.Cache.Get() 获取的总是 Object 类型,思路可以这样展开:

1) 添加缓存时进行判断,如果非空,常规处理,否则把用一个特定的自定义对象存入;
2) 取出缓存时进行判断,如果为特定的自定义对象,返回 null;

为 HttpRuntimeCacheProvider 的构造函数添加可选参数,TryGet() 加入 null 判断逻辑;添加方法 BuildCacheEntry(),替换空的缓存值为 _nullEntry,其他方法不变;  

  1 public class HttpRuntimeCacheProvider : IHttpRuntimeCacheProvider {  2         private static readonly Object _sync = new Object();  3         private static readonly Object _nullEntry = new Object();  4         private Boolean _supportNull;  5   6         public HttpRuntimeCacheProvider(Boolean supportNull = false) {  7             _supportNull = supportNull;  8         }  9  10         protected virtual String BuildCacheKey(String key) { 11             return String.Concat("HttpRuntimeCacheProvider_", key); 12         } 13  14         protected virtual Object BuildCacheEntry<T>(T value) { 15             Object entry = value; 16             if (value =http://www.mamicode.com/= null) { 17                 if (_supportNull) { 18                     entry = _nullEntry; 19                 } 20                 else { 21                     throw new InvalidOperationException(String.Format("Null cache item not supported, try ctor with paramter ‘supportNull = true‘ ")); 22                 } 23             } 24             return entry; 25         } 26  27         public Boolean TryGet<T>(String key, out T value) { 28             Object entry = HttpRuntime.Cache.Get(BuildCacheKey(key)); 29             Boolean exist = false; 30             if (entry != null) { 31                 exist = true; 32                 if (!(entry is T)) { 33                     if (_supportNull && !(entry == _nullEntry)) { 34                         throw new InvalidOperationException(String.Format("缓存项`[{0}]`类型错误, {1} or {2} ?", 35                             key, entry.GetType().FullName, typeof(T).FullName)); 36                     } 37                     value = http://www.mamicode.com/(T)((Object)null); 38                 } 39                 else { 40                     value =http://www.mamicode.com/ (T)entry; 41                 } 42             } 43             else { 44                 value = http://www.mamicode.com/default(T); 45             } 46             return exist; 47         } 48  49         public T GetOrCreate<T>(String key, Func<String, T> factory) { 50             T value; 51             if (TryGet<T>(key, out value)) { 52                 return value; 53             } 54             value =http://www.mamicode.com/ factory(key); 55             Overwrite(key, value); 56             return value; 57         } 58  59         public T GetOrCreate<T>(String key, Func<T> function) { 60             T value; 61             if (TryGet<T>(key, out value)) { 62                 return value; 63             } 64             value =http://www.mamicode.com/ function(); 65             Overwrite(key, value); 66             return value; 67         } 68  69         public T GetOrCreate<T>(String key, Func<T> function, TimeSpan slidingExpiration) { 70             T value; 71             if (TryGet<T>(key, out value)) { 72                 return value; 73             } 74             value =http://www.mamicode.com/ function(); 75             Overwrite(key, value, slidingExpiration); 76             return value; 77         } 78  79         public T GetOrCreate<T>(String key, Func<T> function, DateTime absoluteExpiration) { 80             T value; 81             if (TryGet<T>(key, out value)) { 82                 return value; 83             } 84             value =http://www.mamicode.com/ function(); 85             Overwrite(key, value, absoluteExpiration); 86             return value; 87         } 88  89         public void Overwrite<T>(String key, T value) { 90             HttpRuntime.Cache.Insert(BuildCacheKey(key), BuildCacheEntry<T>(value)); 91         } 92  93         //slidingExpiration 时间内无访问则过期 94         public void Overwrite<T>(String key, T value, TimeSpan slidingExpiration) { 95             HttpRuntime.Cache.Insert(BuildCacheKey(key), BuildCacheEntry<T>(value), null, 96                 Cache.NoAbsoluteExpiration, slidingExpiration); 97         } 98  99         //absoluteExpiration 时过期100         public void Overwrite<T>(String key, T value, DateTime absoluteExpiration) {101             HttpRuntime.Cache.Insert(BuildCacheKey(key), BuildCacheEntry<T>(value), null,102                 absoluteExpiration, Cache.NoSlidingExpiration);103         }104 105         public void Expire(String key) {106             HttpRuntime.Cache.Remove(BuildCacheKey(key));107         }108 109         protected virtual Boolean Hit(DictionaryEntry entry) {110             return (entry.Key is String) && ((String)entry.Key).StartsWith("HttpRuntimeCacheProvider_");111         }112 113         public void ExpireAll() {114             lock (_sync) {115                 var entries = HttpRuntime.Cache.OfType<DictionaryEntry>().Where(Hit);116                 foreach (var entry in entries) {117                     HttpRuntime.Cache.Remove((String)entry.Key);118                 }119             }120         }121 122         public Int32 Count {123             get {124                 lock (_sync) {125                     return HttpRuntime.Cache.OfType<DictionaryEntry>().Where(Hit).Count();126                 }127             }128         }129     }

然后是分区缓存需要修改构造函数:

 1     public HttpRuntimeRegionCacheProvider(String region) 2             : base(false) { 3             Region = region; 4         } 5  6         public HttpRuntimeRegionCacheProvider(String region, Boolean supportNull) 7             : base(supportNull) { 8             Region = region; 9         }10         ...11     }

测试用例: 

 1  [TestClass] 2     public class HttpRuntimeCacheProviderTest {     3         [TestMethod] 4         public void NullCacheErrorTest() { 5             var key = "key-null"; 6             Person person = null; 7             IHttpRuntimeCacheProvider cacheProvider = new HttpRuntimeCacheProvider(false); 8             try { 9                 cacheProvider.GetOrCreate<Person>(key, () => person); //error10                 Assert.Fail();11             }12             catch (Exception ex) {13                 Assert.IsTrue(ex is InvalidOperationException);14             }15 16             Person person2;17             var exist = cacheProvider.TryGet(key, out person2);18             Assert.IsFalse(exist);19             Assert.AreEqual(person2, null);20         }21 22         [TestMethod]23         public void NullableCacheTest() {24             var key = "key-nullable";25             Person person = null;26             IHttpRuntimeCacheProvider cacheProvider = new HttpRuntimeCacheProvider(true);27             cacheProvider.GetOrCreate<Person>(key, () => person);28             Person person2;29             var exist = cacheProvider.TryGet(key, out person2);30             Assert.IsTrue(exist);31             Assert.AreEqual(person2, null);32         }33 34         class Person {35             public Int32 Id { get; set; }36             public String Name { get; set; }37         }38     }
View Code

3.3 封装与集成

多数情况下我们不需要暴露实现和手动创建上文所提各种 CacheProvider,实践中它们被 internal 修饰,再配合工厂类使用:  

 1 public static class CacheProviderFacotry { 2         public static ICacheProvider GetHttpContextCache() { 3             return new HttpContextCacheProvider(); 4         } 5  6         public static IHttpRuntimeCacheProvider GetHttpRuntimeCache(Boolean supportNull = false) { 7             return new HttpRuntimeCacheProvider(supportNull); 8         } 9 10         public static IHttpRuntimeRegionCacheProvider GetHttpRuntimeRegionCache(String region, Boolean supportNull = false) {11             return new HttpRuntimeRegionCacheProvider(region, supportNull);12         }13 14         public static IHttpRuntimeRegionCacheProvider Region(this IHttpRuntimeCacheProvider runtimeCacheProvider, String region, Boolean supportNull = false) {15             return GetHttpRuntimeRegionCache(region, supportNull);16         }17     }

然后在依赖注入中的声明如下,这里是 Autofac 下的组件注册:     

1  ...2             //请求级别缓存, 使用 HttpContext.Current.Items 作为容器3             builder.Register(ctx => CacheProviderFacotry.GetHttpContextCache()).As<ICacheProvider>().InstancePerLifetimeScope();4             //进程级别缓存, 使用 HttpRuntime.Cache 作为容器5             builder.RegisterInstance(CacheProviderFacotry.GetHttpRuntimeCache()).As<IRuntimeCacheProvider>().ExternallyOwned();6             //进程级别且隔离的缓存, 若出于key算法唯一考虑而希望加入上下文件信息, 则仍然需要 CacheModule 类的实现7             builder.Register(ctx => CacheProviderFacotry.GetHttpRuntimeRegionCache(/*... 分区依据 ...*/))8                 .As<IRuntimeRegionCacheProvider>().InstancePerLifetimeScope();9         ...

4. 小结

本文简单探讨了一个具有线程安全、分区、过期特性缓存模块的实现过程,只使用了HttpRuntime.Cache的有限特性,有更多需求的同学可以自行扩展;见解有限,谬误之处还请园友指正。

园友Jusfr 原创,转载请注明来自博客园  。

一步步实现一个基本的缓存模块