## 微信开发深度解析之缓存策略(下)
### 本地数据容器缓存策略:LocalContainerCacheStrategy
本节重点介绍本地缓存(单机环境)的缓存策略实现,包括缓存策略的实现思路和实现代码两方面的全过程,开发者们可以举一反三,将其运用到更多的场景,包括分布式缓存。
注意:这里说的“单机”是指微信应用只部署在一台服务器上。
#### 创建 LocalContainerCacheStrategy 类
第一步我们需要新建 LocalContainerCacheStrategy.cs 文件,并创建
LocalContainerCacheStrategy 类。
```
///
/// 本地容器缓存策略
///
public class LocalContainerCacheStrategy : IContainerCacheStragegy
{
}
```
LocalContainerCacheStrategy 继承自容器缓存策略接口
IContainerCacheStragegy,在实现 IContainerCacheStragegy 接口中的属性和方法之前,我们先来确定本地缓存的数据源。
#### 定义数据源
由于是本地缓存,数据源的选择就可以有很多,几乎本机上所有可以被调用的储存介质都可以成为一个备选方案,常见的方案及优缺点如表2所示。

表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所示。

图6 运行结果
注:
本文中涉及了较多的代码,大家可以访问http://book.weixin.senparc.com 查看与复制本文中的图片、代码片段。
文中提到的“190#85 - 190#89”,也可通过访问该网址,在搜索栏中输入对应编号,查看5种不同的“单例模式”实现方法。如输入190#85,即可查看第一种实现代码。
>本文节选自电子工业出版社出版的《微信开发深度解析:微信公众号、小程序高效开发秘籍》一书,剖析 Senparc.Weixin SDK 设计思想和使用方法,全面介绍了开发微信公众号所需的关键技能,侧重于服务器端开发。作者苏震巍,苏州盛派网络创始人、首席架构师。