1. 程式人生 > >一步步實現一個基本的快取模組

一步步實現一個基本的快取模組

注意後續程式碼及改進見後後文及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     }
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 = (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     }
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 == 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