设计模式的使用——实现一个简单的缓存
一、背景介绍
我们日常开发网站时,经常会用到下图这样的下拉框。其中下拉框里面的选项,不会经常变动。对于不会经常变动的数据,如果每次都从数据库读取,可能会影响网站的响应速度。所以通常会把这部分数据缓存起来,使用时直接从缓存读取。如果在项目中引入Redis这一类缓存框架,好像又不太划算,所以我们可以选择自己实现一个简单的缓存
这篇文章的目的不是具体的介绍设计模式,而是结合一个做缓存的案列,介绍设计模式的使用,加深对设计模式的理解。这里实现的缓存也可以应用于实际项目中。为了方便说明,我先用 Entity Framework 的 Code-First 建立三个实体类(我使用的是.Net的EF和AutoMapper,对于其他的开发工具,比如Java的Hibernate、ModelMapper,道理是一样的)。
复制代码
public class Department
{
[Key]
public int DepartmentId { get; set; }
public string Name { get; set; }
public virtual ICollection Employees { get; set; }
}
复制代码
复制代码
public class Employee
{
[Key]
public int EmploeeId { get; set; }
public string Name { get; set; }
public virtual Department Department { get; set; }
public virtual ICollection AttendanceRecords { get; set; }
}
复制代码
复制代码
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public DateTime RecordTime { get; set; }
public virtual Employee Employee { get; set; }
}
复制代码
一个部门有多个雇员,一个雇员有多条考勤记录(然后在数据库中添加了一些数据)。
二、最简单的缓存——静态字段
通常我们会为一个实体类建立一个数据访问类,在这个数据访问类里面管理这个实体类的CRUD。如下图所示,我建立了三个Provider类(后面用”Provider“代指数据访问类)。
现在我们需要缓存部门数据,最简单的方式,就是在 DepartmentProvider 里面增加一个静态字段。第一次读取数据后,把数据保存在这个静态字段里,后面的读取直接返回静态字段中的数据。
复制代码
public class DepartmentProvider
{
private MyDbContext DbContext = new MyDbContext();
private static List departmentList;
public List GetAll()
{
if (departmentList == null)
{
departmentList = DbContext.Departments.ToList();
}
return departmentList;
}
public void Update(Department department)
{
var oldDepartment = DbContext.Set().Find(department.DepartmentId);
if (oldDepartment != null)
{
DbContext.Entry(oldDepartment).CurrentValues.SetValues(department);
DbContext.SaveChanges();
departmentList = null;
}
}
}
复制代码
这里我加了一个 departmentList 静态字段。并且当 Department 有更新时,我们把这个缓存清除掉,使得缓存的数据也能被更新。当然,更新缓存数据有两种方式。一是设置缓存过期时间,定期更新。二是数据库有更新时,也更新缓存。我这里选择的是第二种方式。
用这种方式缓存数据,会存在许多问题。比如每一个 Provider 单独管理自己的缓存,不方便维护代码,也不方便我们集中管理缓存(假设需要给管理员增加一键清空所有缓存的功能,我们就需要修改所有的 Provider)。所以我们需要改进代码,把所有的缓存集中在一个地方管理。
三、集中管理缓存——门面、策略、简单工厂模式
我们现在的想法是 Provider 类不直接管理缓存,而是把缓存集中在一个地方管理。在这里,我们可以把缓存看成是一个子系统。Provider 不需要知道缓存子系统是如何工作的,只需要能使用缓存这个功能就可以了。这种情况正好符合门面模式的使用场景——我们建立一个 CacheManager 类,Provider 只与 CacheManager 打交道。缓存的具体实现,交给 CacheManager 处理。下面开始修改代码,建立一个 CacheManager 类,在里面写管理缓存的代码。
复制代码
public class CacheManager
{
private static ConcurrentDictionary caches = new ConcurrentDictionary();
public static void Set(string key, object o)
{
caches.AddOrUpdate(key, o, (k, v) => v);
}
public static void Remove(string key)
{
object output;
caches.TryRemove(key, out output);
}
public static T Get(string key)
{
object output;
caches.TryGetValue(key, out output);
if (output != null)
return (T)output;
return default(T);
}
}
复制代码
这里我们使用 ConcurrentDictionary 字典来保存数据(这个字典是线程安全的)。并且添加了相应的添加、删除和读取缓存的方法。这样每一个 Provider 就只需要保存自己的 key 就可以了,不再单独保管缓存。下面是对 Provider 的修改。
复制代码
public class DepartmentProvider
{
private MyDbContext DbContext = new MyDbContext();
private static string cacheKey = "departmentList";
public List GetAll()
{
var departmentList = CacheManager.Get
- >(cacheKey);
if (departmentList == null)
{
departmentList = DbContext.Departments.ToList();
CacheManager.Set(cacheKey, departmentList);
}
return departmentList;
}
public void Update(Department department)
{
var oldDepartment = DbContext.Set
- >(cacheKey);
if (departmentList == null)
{
departmentList = DbContext.Departments.Include(d => d.Employees)
.ToList();
CacheManager.Set(cacheKey, departmentList);
}
return departmentList.Where(d => d.Employees.Any(e => empIds.Contains(e.EmploeeId)))
.ToList();
}
复制代码
第一次读取所有的 Department,并且立即加载 Employees 这个导航属性。 把读取的数据缓存起来,再从缓存数据中,根据传来的参数筛选结果。
我们需要根据 EmployeeId 筛选 Department,所以缓存了 Employees 这个导航属性。但是如果 Employee 表中的数据更新了怎么办? 我们这里缓存的数据不就不准确了! 所以我们需要有一种方式,监听 Employee 表的变化。当 Employee 有更新时,我们要清空 Department 的缓存数据。
第一反应想到的是,在 EmployeeProvider 里面加代码,发现 Employee 有更新时,清空 Department 的缓存数据。这样写虽然可以达到目的,但是我们这里是示例代码,代码又少又简单。如果一个真实的项目里面,有很多地方有这种关联的数据。想在Provider 里面处理缓存过期是非常困难的,也是特别容易出错的。我们需要一种方式写出易维护的代码。
分析这里的场景,EmployeeProvider的变化,需要通知 DepartmentProvider。 这不正好是观察者模式的使用场景吗? 另外,为了保持 Provider 的职责单一,我们不希望在 Provider 里面写响应其他 Provider 变化的代码。我们需要把这种对象间的相互影响交给一个中间者处理。这不就是中介者模式的使用场景吗?
下面是具体的代码实现。先添加一个 IMyObserver 接口,这个接口很简单(由于System命名空间里的IObserver接口,里面有我们不需要的东西,所以我自己定义了一个):
复制代码
public interface IMyObserver
{
void Update(object subject);
}
复制代码
再添加一个 ProviderCacheObserver 实现这个接口,这个类既是一个观察者,也是一个中介者:
复制代码
public class ProviderCacheObserver : IMyObserver
{
public void Update(object subject)
{
if (subject is EmployeeProvider)
{
// 因为不希望cacheKey被外部访问到
// 所以我们给 DepartmentProvider
// 添加 RemoveCache 方法
DepartmentProvider.RemoveCache();
}
}
}
复制代码
在 Update 里面,我们就可以单独处理 Provider 之间相互关联的关系了,不需要将处理关系的代码添加到 Provider 里面。现在再去 EmployeeProvider 里面,注册这个观察者。当 Employee 发生更新时,通知Observer,让Observer(同时是中介者)去处理关联的数据:
复制代码
public class EmployeeProvider
{
private MyDbContext DbContext;
private static string cacheKey = "employeeList";
public EmployeeProvider()
{
DbContext = new MyDbContext();
}
private IMyObserver cacheObserver = new ProviderCacheObserver();
public void Update(Employee employee)
{
var oldEmployee = DbContext.Set
- >(sourceData);
return desData;
}
}
复制代码
解释一下为什么要传两个泛型参数。我们希望在 JSON 序列化 Department 数据时,保留 Department 的导航属性 Employees,但是去除 Employee 的导航属性 AttendanceRecords和 Department(为了解决循环引用)。所以 TOuter 的实参是 Department,TInner 的实参是 Employee,这样就能映射 Department 的导航属性,并且去除 Employee 的导航属性。
3.读取数据后深拷贝,将拷贝后的数据做缓存:
复制代码
public List
- >(cacheKey);
if (departmentList == null)
{
departmentList = DbContext.Departments.Include(d => d.Employees)
.ToList();
departmentList = MapHelper.DeepCopy