注意后續代碼及改進見后后文及github,文章上的並沒有更新。
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 = (T)entry; 17 } 18 else { 19 value = 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 = 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 = 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 }
引用類型測試用例忽略。
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 = (T)entry; 19 } 20 else { 21 value = 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 }
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 }
至此一個基本的緩存模塊已經完成;
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 == 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 = (T)((Object)null); 38 } 39 else { 40 value = (T)entry; 41 } 42 } 43 else { 44 value = 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 = 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 = 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 = 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 = 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); //error 10 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 }
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 原創,轉載請注明來自博客園 。
注意后續代碼及改進見后后文及github,文章上的並沒有更新。