## 微信开发深度解析之缓存策略(下) ### 本地数据容器缓存策略:LocalContainerCacheStrategy 本节重点介绍本地缓存(单机环境)的缓存策略实现,包括缓存策略的实现思路和实现代码两方面的全过程,开发者们可以举一反三,将其运用到更多的场景,包括分布式缓存。 注意:这里说的“单机”是指微信应用只部署在一台服务器上。 #### 创建 LocalContainerCacheStrategy 类 第一步我们需要新建 LocalContainerCacheStrategy.cs 文件,并创建 LocalContainerCacheStrategy 类。 ``` /// /// 本地容器缓存策略 /// public class LocalContainerCacheStrategy : IContainerCacheStragegy { } ``` LocalContainerCacheStrategy 继承自容器缓存策略接口 IContainerCacheStragegy,在实现 IContainerCacheStragegy 接口中的属性和方法之前,我们先来确定本地缓存的数据源。 #### 定义数据源 由于是本地缓存,数据源的选择就可以有很多,几乎本机上所有可以被调用的储存介质都可以成为一个备选方案,常见的方案及优缺点如表2所示。 ![enter image description here](http://images.gitbook.cn/2700dc40-fc3a-11e7-9da3-77cc9d66cbb8) 表2 对于常规的单机环境,假设我们对容器缓存追求的指标依次为: - 安全性; - 读写效率; - 运行稳定性、抗干扰性; - 可控性; - 持久化(可选)。 结合上述的假设,我们可以认为 IDictionary 可能是最好的选择。 下面根据 IDictionary 的方案我们来创建一个内存中的静态变量,作为数据源: ``` public class LocalContainerCacheStrategy : IContainerCacheStragegy { #region 数据源 private IDictionary _cache = LocalCacheHelper.LocalCache; #endregion } ``` 这里的 _cache 即为数据源,为了保持其安全性,设为 Private 变量,只提供给 LocalContainerCacheStrategy 类进行内部访问。 _cache 的类型为 `IDictionary`,但注意:这里的 _cache 并不是静态变量,也就是说它只能在当前 LocalContainerCacheStrategy 实例中被使用。 为什么这么设计呢?一方面,是出于安全的考虑,数据源不直接暴露给 LocalContainer- CacheStrategy(至少提供了这样一种可能),另外一方面,也便于全局静态数据源的功能扩展。因此,我们并不直接使用 `new Dictionary()`方法将 _cache 初始化,而是创建了一个类:LocalCacheHelper: ``` /// /// 全局静态数据源帮助类 /// public static class LocalCacheHelper { /// /// 所有数据集合的列表 /// internal static IDictionary LocalCache { get; set; } static LocalCacheHelper() { LocalCache = new Dictionary (StringComparer.OrdinalIgnoreCase); } } ``` 在 LocalCacheHelper 中可以看到,真正全局的静态数据源是 LocalCache,访问级别为 Internal,有时出于调试和测试源代码的目的,我们可临时将其设为 Public,生产环境部署的版本仍然强烈建议使用 Internal。并且这里也不建议在除了数据监控以外的任何地方对 LocalCache 进行直接操作(即使数据监控也有其他办法,这里不再展开)。 全局数据源的初始化过程在 LocalCacheHelper 的静态构造函数内完成,这里直接使用了 `new Dictionary(StringComparer.OrdinalIgnoreCase)`的方式,将数据源定义为 `Dictionary`类型。 如果需要的话,在 LocalCacheHelper 中可以加入对 LocalCache 的各种控制,例如访问统计、状态监控、访问加锁(当然这一步要慎重,以免影响可能发生的异步操作)等。 如果再开一下脑洞,有了 LocalCacheHelper,我们甚至还可以给输出到每个 LocalContainerCacheStrategy 提供一个深度复制的数据源对象(只在一些极端情况下会用到,并且需要进行更多的数据同步操作,这里不再展开)。 有了数据源之后,我们开始实现 IContainerCacheStragegy 接口下的一系列属性和方法。 #### 实现容器缓存策略 接下来我们着手实现所有 IContainerCacheStragegy 接口中的方法,以下提供的代码只是一种实现方式,并已经集成到 Senparc.Weixin SDK 中作为默认的容器缓存实现方式。我们认为这个默认的实现已经可以帮助大部分“单机”部署的微信服务处理好相关事务,如果出现无法满足实际项目需求的情况,开发者们也可以按照各自的习惯和实际需要来实现自己的方法。 ``` /// /// 本地容器缓存策略 /// public class LocalContainerCacheStrategy : IContainerCacheStragegy { #region 数据源 private IDictionary _cache = LocalCacheHelper.LocalCache; #endregion #region ILocalCacheStrategy 成员 public string CacheSetKey { get; set; } public void InsertToCache(string key, IContainerItemCollection value) { if (key == null || value == null) { return; } _cache[key] = value; } public void RemoveFromCache(string key) { _cache.Remove(key); } public IContainerItemCollection Get(string key) { if (!_cache.ContainsKey(key)) { _cache[key] = new ContainerItemCollection(); } return _cache[key]; } public IDictionary GetAll() { return _cache; } public bool CheckExisted(string key) { return _cache.ContainsKey(key); } public long GetCount() { return _cache.Count; } public void Update(string key, IContainerItemCollection value) { _cache[key] = value; } public void UpdateContainerBag(string key, IBaseContainerBag bag) { if (_cache.ContainsKey(key)) { var containerItemCollection = _cache[key]; containerItemCollection[bag.Key] = bag; } } #endregion } ``` 上述新增的代码大多是针对 `IDictionary` 的操作,这里不再赘述。 需要特别说明一下的是 `IDictionary GetAll()`这个方法,此方法要求以 `IDictionary`格式返回整个 Container 数据源,以提供给下游使用,因为我们设计的数据源正好是 `IDictionary`类型的,此处代码直接使用了 return _cache 这样的方式,如果使用的是其他类型的数据源,这里可能会需要出现一个使用其他方式查询和整理数据的过程,甚至也可能使用到其他的解决方案(有些情况下会非常复杂),比如在某些分布式缓存框架中,能否获取到完整的数据源取决于框架的接口,如果接口没有提供,我们只能另想办法。 除了 GetAll()方法以外,Count()方法也有类似的情况需要注意,但相对来说 Count 被支持得更普遍一些。 #### 运用单例模式 对于缓存策略的访问,最简单的方法是在每次需要访问缓存的时候,实例化一个缓存策略对象,然后通过这个示例对象去进行相应的查询或更新等操作。如果有必要,可以在访问结束之后进行一次资源回收。 这么做听上去还不错,例如: ``` LocalContainerCacheStrategy cache = new LocalContainerCacheStrategy(); var collection = cache.Get("AccessTokenContainer"); var data = collection.Get("AppId") as AccessTokenBag; data.Token = "ABC"; collection.Update("AppId",data); cache.Close();//必要的时候可以释放资源 ``` 的确,粗略地看上去这么做也没有什么问题。但作为一个可能嵌入到任何系统中的中间件,我们需要考虑到在多数动态系统中,缓存的访问是一个极其高频的环节,除了每一次请求的过程中可能在短时间内多次访问缓存,随着并发数量的升高,我们通常面临着如下两个重要的考验。 - 每一个实例的初始化都需要消耗 CPU 及内存资源,在增加系统响应时间的同时,越来越高的内存占用也会影响到系统的稳定性及效率。 - 如果同一时间,只有一个进程访问,那么没有资源抢夺和数据同步及隐藏的线程安全的问题,但是通常没有这么“舒服”的情况,可能同一时间内,系统中会存在多个缓存策略的实例,那么如何处理上述的矛盾呢? 对应这样的情况,正是“单例模式(Singleton Pattern)”出手的时机了。 简单地说,单例模式就是确保一个类在全局中有且只有一个实例,并且有一个全局访问点。 这样我们就可以大大降低类的实例化次数(事实上每个应用生命周期中只有1次),并且多个访问线程都访问同一个实例。 为了达到同样的单例的目的,其实可以有很多的做法,这里按照逐步改进的顺序,简单介绍几个常用的方法,并初步分析其利弊,这些解决方案多来自前辈们实践的经验。如果你已经对“单例模式”非常了解,也建议你温故一下相关的内容,其中的很多思想贯穿了 Senparc.Weixin SDK 中众多模块的设计思想。 5种不同的“单例模式”实现方法见 190#85 - 190#89(见文末说明)。 其中,方法五代码如下: ``` public sealed class LocalContainerCacheStrategy : IContainerCacheStragegy { #region 数据源 //数据源代码 #endregion #region 单例 /// /// LocalCacheStrategy的构造函数 /// LocalContainerCacheStrategy() { } //静态LocalCacheStrategy public static IContainerCacheStragegy Instance { get { return Nested.instance;//返回Nested类中的静态成员instance } } class Nested { static Nested() { } //将instance设为一个初始化的LocalCacheStrategy新实例 internal static readonly LocalContainerCacheStrategy instance = new LocalContainerCacheStrategy(); } #endregion #region ILocalCacheStrategy 成员 // ILocalCacheStrategy 成员代码 #endregion } ``` 相关的代码看上去比之前的 4 种方法要复杂不少,还用到了类名为 Nested 的嵌套类(Nested Class)。 嵌套类的目的是为 Instance 的初始化提供一个“屏障”,只有当程序根据逻辑需要,访问到 LocalContainerCacheStrategy.Instance 的时候,才会进一步访问到 Nested,此时 Nested 中的Instance 会被自动赋值一个新的 LocalContainerCacheStrategy 实例,从而达到延迟实例化的作用。有关静态变量 Instance 初始化的执行的过程在“方法四”中已经介绍过,全局只会执行一次,因此整个系统的生命周期中也只会初始化一个 LocalContainerCacheStrategy 实例对象。 这种做法没有用到线程锁,巧妙地利用了静态变量初始化的过程,保障了 Instance 在初始化时候的线程安全以及提供了延迟实例化的功能。 综合以上的一些分析和判断,LocalContainerCacheStrategy 选择了“方法五”来创建单例。 当需要使用到 LocalContainerCacheStrategy 的时候,我们只需要进行这样的调用即可: ``` var cache = LocalContainerCacheStrategy.Instance; ``` #### 测试 为了验证整套缓存机制(重点是缓存队列工作)的可靠性,我们在 Senparc.Weixin.MP.Sample 项目中创建了一个测试的方法,其原理和测试思路如下。 - 微信的 AccessToken 等数据都使用各类 Container 进行管理; - 每个 Container 都有一个强制约束的 ContainerBag,本地缓存信息; - ContainerBag 中的属性被修改时,会将需要对当前对象操作的过程放入消息队列(SenparcMessageQueue); - 每个消息队列中的对象都带有一个委托类型属性,其动作通常是通过缓存策略(实现自 IContainerCacheStrategy,可以是本地缓存或分布式缓存)更新缓存; - 一个独立的线程会对消息队列进行读取,依次执行队列成员的委托,直到完成当前所有队列的缓存更新操作; - 上一个步骤重复进行,每次执行完默认等待2秒。此方案可以有效避免同一个 ContainerBag 对象属性被连续更新的情况下,每次都和缓存服务通信而产生消耗。 此方法可以写成单元测试,但为了可以更加直观、方便地显示结果,我们在 Senparc.Weixin.MP.Sample/Controllers/CacheController.cs 下,根据上述思路创建了一个名为 RunTest 的 Action,代码见下。 ``` [HttpPost] public ActionResult RunTest() { var sb = new StringBuilder(); var containerCacheStrategy = CacheStrategyFactory.GetObjectCacheStrategyInstance().ContainerCacheStrategy; sb.AppendFormat("{0}:{1}
", "当前缓存策略", containerCacheStrategy.GetType().Name); var finalExisted = false; for (int i = 0; i < 3; i++) { sb.AppendFormat("
====== {0}:{1} ======

", "开始一轮测试", i + 1); var shortBagKey = DateTime.Now.Ticks.ToString(); var finalBagKey = containerCacheStrategy.GetFinalKey(ContainerHelper.GetItemCacheKey(typeof(TestContainerBag1), shortBagKey));//获取最终缓存中的键 var bag = new TestContainerBag1() { Key = shortBagKey, DateTime = DateTime.Now }; TestContainer1.Update(shortBagKey, bag); //更新到缓存(队列) sb.AppendFormat("{0}:{1}(Ticks:{2})
", "bag.DateTime", bag.DateTime.ToLongTimeString(), bag.DateTime.Ticks); Thread.Sleep(1); bag.DateTime = DateTime.Now; //进行修改 //读取队列 var mq = new SenparcMessageQueue(); var mqKey = SenparcMessageQueue.GenerateKey("ContainerBag", bag.GetType(), bag.Key, "UpdateContainerBag"); var mqItem = mq.GetItem(mqKey); sb.AppendFormat("{0}:{1}(Ticks:{2})
", "bag.DateTime", bag.DateTime.ToLongTimeString(), bag.DateTime.Ticks); sb.AppendFormat("{0}:{1}
", "已经加入队列", mqItem != null); sb.AppendFormat("{0}:{1}
", "当前消息队列数量(未更新缓存)", mq.GetCount()); var itemCollection = containerCacheStrategy.GetAll(); var existed = itemCollection.ContainsKey(finalBagKey); sb.AppendFormat("{0}:{1}
", "当前缓存是否存在", existed); sb.AppendFormat("{0}:{1}
", "插入缓存时间", !existed ? "不存在" : itemCollection[finalBagKey].CacheTime.Ticks.ToString()); //应为0 var waitSeconds = i; sb.AppendFormat("{0}:{1}
", "操作", "等待" + waitSeconds + "秒"); Thread.Sleep(waitSeconds * 1000); //线程默认轮询等待时间为2秒 sb.AppendFormat("{0}:{1}
", "当前消息队列数量(未更新缓存)", mq.GetCount()); itemCollection = containerCacheStrategy.GetAll(); existed = itemCollection.ContainsKey(finalBagKey); finalExisted = existed; sb.AppendFormat("{0}:{1}
", "当前缓存是否存在", existed); sb.AppendFormat("{0}:{1}(Ticks:{2})
", "插入缓存时间", !existed ? "不存在" : itemCollection[finalBagKey].CacheTime.ToLongTimeString(), !existed ? "不存在" : itemCollection[finalBagKey].CacheTime.Ticks.ToString()); //应为当前加入到缓存的最新时间 } sb.AppendFormat("
============

"); sb.AppendFormat("{0}:{1}
", "测试结果", !finalExisted ? "失败" : "成功"); return Content(sb.ToString()); }
``` 其中涉及的两个自定义的 Container 相关类(TestContainerBag1、TestContainer1)定义如下: ``` [Serializable] internal class TestContainerBag1 : BaseContainerBag { private DateTime _dateTime; public DateTime DateTime { get { return _dateTime; } set { this.SetContainerProperty(ref _dateTime, value); } } } internal class TestContainer1 : BaseContainer { } ``` 此测试也可以直接通过浏览器访问[在线 Demo](http://sdk.weixin.senparc.com/Cache/Test),运行结果如图6所示。 ![enter image description here](http://images.gitbook.cn/f6d650c0-fc3b-11e7-b7b3-2b94bdd06d75) 图6 运行结果 注: 本文中涉及了较多的代码,大家可以访问http://book.weixin.senparc.com 查看与复制本文中的图片、代码片段。 文中提到的“190#85 - 190#89”,也可通过访问该网址,在搜索栏中输入对应编号,查看5种不同的“单例模式”实现方法。如输入190#85,即可查看第一种实现代码。 >本文节选自电子工业出版社出版的《微信开发深度解析:微信公众号、小程序高效开发秘籍》一书,剖析 Senparc.Weixin SDK 设计思想和使用方法,全面介绍了开发微信公众号所需的关键技能,侧重于服务器端开发。作者苏震巍,苏州盛派网络创始人、首席架构师。