設計模式的使用——實現一個簡單的緩存


 

一、背景介紹

    我們日常開發網站時,經常會用到下圖這樣的下拉框。其中下拉框里面的選項,不會經常變動。對於不會經常變動的數據,如果每次都從數據庫讀取,可能會影響網站的響應速度。所以通常會把這部分數據緩存起來,使用時直接從緩存讀取。如果在項目中引入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<Employee> 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<AttendanceRecord> 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<Department> departmentList;
        public List<Department> GetAll()
        {
            if (departmentList == null)
            {
                departmentList = DbContext.Departments.ToList();
            }
            return departmentList;
        }

        public void Update(Department department)
        {
            var oldDepartment = DbContext.Set<Department>().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<string, object> caches = new ConcurrentDictionary<string, object>();

        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<T>(string key)
        {
            object output;
            caches.TryGetValue(key, out output);
            if (output != null)
                return (T)output;
            return default(T);
        }
    }

  這里我們使用 ConcurrentDictionary<string, object> 字典來保存數據(這個字典是線程安全的)。並且添加了相應的添加、刪除和讀取緩存的方法。這樣每一個 Provider 就只需要保存自己的 key 就可以了,不再單獨保管緩存。下面是對 Provider 的修改。

public class DepartmentProvider
{
        private MyDbContext DbContext = new MyDbContext();

        private static string cacheKey = "departmentList";
        public List<Department> GetAll()
        {
            var departmentList = CacheManager.Get<List<Department>>(cacheKey);
            if (departmentList == null)
            {
                departmentList = DbContext.Departments.ToList();
                CacheManager.Set(cacheKey, departmentList);
            }
            return departmentList;
        }

        public void Update(Department department)
        {
            var oldDepartment = DbContext.Set<Department>().Find(department.DepartmentId);
            if (oldDepartment != null)
            {
                DbContext.Entry(oldDepartment).CurrentValues.SetValues(department);
                DbContext.SaveChanges();
                CacheManager.Remove(cacheKey);
            }
        }
}

  現在我們已經把 Provider 和緩存隔離開了,也可以集中在 CacheManager 里管理緩存了,避免了以后修改所有的 Provider。新的問題來了,假如以后要需要替換保存數據的方式,不使用 ConcurrentDictionary<string, object> 字典保存數據了。那是不是就需要在 CacheManager 里面找到所有使用 ConcurrentDictionary<string, object> 字典的地方,一個一個的修改(示例里面只有3個方法,好像改起來也不麻煩,但是不排除真實的項目中,CacheManager 在多處使用字典)?

  那么當這種情況發生時,如何讓我們用最小的代價修改代碼呢? 仔細一想,對於 CacheManager 來說,只需要可以對數據進行CRUD就可以了。具體的數據是如何保存的,CacheManager 根本就不關心。那這就符合策略模式的使用場景了——將保存數據的具體方式封裝起來,當 CacheManager 需要替換保存數據的方式時,替換一個用來保存數據的對象就可以了。

  首先,我們需要對實現保存數據的對象抽象分析一下。分析的結果是,這個對象需要能夠設置數據、讀取數據、刪除數據。所以我們寫一個 ICache 接口,代碼如下。

public interface ICache
{
    void Set(string key, object o);
    void Remove(string key);
    object Get(string key);
}

然后繼續先使用 ConcurrentDictionary<string, object> 實現這個接口,下面是代碼。

public class MemoryCache : ICache
{
        private static ConcurrentDictionary<string, object> caches = new ConcurrentDictionary<string, object>();

        public void Set(string key, object o)
        {
            caches.AddOrUpdate(key, o, (k, v) => v);
        }

        public void Remove(string key)
        {
            object output;
            caches.TryRemove(key, out output);
        }
        
        public object Get(string key)
        {
            object output;
            caches.TryGetValue(key, out output);
            return output;
        }
}

  現在來思考一下如何修改 CacheManager 的代碼。因為替換保存數據的方式就是替換一個對象,也就是說我們需要根據參數來實例化不同的對象。這么一說是不是想到了另一個常見的設計模式——簡單工廠模式。所以我們添加一個 CacheFactory 類(下面是示例代碼,所以我只實現了一個類)。

public class CacheFactory
{
        public static ICache GetDefaultCache(string cacheType)
        {
            switch (cacheType)
            {
                case "Memory":
                    return new MemoryCache();
                default:
                    return new MemoryCache();
            }
        }
}

然后再來看對 CacheManager 的修改:

public class CacheManager
{
        private static ICache cache = CacheFactory.GetDefaultCache("Memory");

        public static void Set(string key, object o)
        {
            cache.Set(key, o);
        }

        public static void Remove(string key)
        {
            cache.Remove(key);
        }
        
        public static T Get<T>(string key)
        {
            object o = cache.Get(key);
            if (o != null)
                return (T)o;
            return default(T);
        }
}

現在,如果我們想換一種保存數據的方式。只需要新建一個實現了 ICache 接口的類,然后在 CacheFactory 里面返回這個類的實例就可以了。

  總結一下這一部分的內容。我們使用門面模式,分離了 Provider 與緩存的代碼,將所有的緩存交給 CacheManager 管理。然后用 ICache 接口抽象了具體的保存數據的方式,使用策略模式和簡單工廠模式,讓 CacheManager 可擴展、易維護。現在我們啟動項目看一下效果。第一次讀取部門信息的時候,是從數據庫讀取的。之后再讀信息,就從緩存中獲取數據了。

  到了這里,這個緩存還是不完善——數據過期的問題沒有很好的解決。

 

四、互相關聯的數據更新了怎么辦——觀察者、中介者模式處理緩存過期

  在 DepartmentProvider 類里面,我們處理了 Department 緩存過期的問題——當Department 更新了,清空緩存,重新加載數據。假設現在有這么一條業務邏輯,根據一組Employee 的 Id,查找部門信息。具體的代碼如下:

public List<Department> GetDepartmentByEmployeeIds(List<int> empIds)
{
            var departmentList = CacheManager.Get<List<Department>>(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<Employee>().Find(employee.EmploeeId);
            if (oldEmployee != null)
            {
                DbContext.Entry(oldEmployee).CurrentValues.SetValues(oldEmployee);
                DbContext.SaveChanges();
                CacheManager.Remove(cacheKey);
                cacheObserver.Update(this);
            }
        }
}

再把 DepartmentProvider 的 RemoveCache 方法貼出來:

public static void RemoveCache()
{
            CacheManager.Remove(cacheKey);
}

  總結一下這一部分的內容。為了處理一張表的數據更新了,造成另一張表的緩存數據過期的問題。我們使用了觀察者模式,觀察 Provider 的變化,通知其他 Provider 做出響應。為了不在 Provider 里面到處寫響應變化的代碼,我們使用中介者模式,集中在中介者類(就是我們的Observer)里面處理Provider的關聯關系。通過這些方式,我們得到了易維護、可擴展的代碼。

   這里我賣兩個小關子。通過改變觀察目標,還可以進一步的減少代碼量。以及如何保證 key 是唯一的,如何處理不同的 Provider 添加緩存時,因為 key 值一樣,造成其他 Provider 的緩存被覆蓋掉的問題。知道答案的朋友在評論里面分享一下吧。下圖是所有代碼的目錄結構(MapHelper 在下一節講):

      緩存的內容到這里就結束了。下面的小節,是為了解決由於 Entity Framework 的包裝類、延遲加載、非跟蹤查詢,造成的 JSON 序列化時拋出的異常。

 

五、JSON序列化拋出了異常——使用深拷貝解決

  我們在 Controller 里面向前台返回JSON數據:

public class HomeController : Controller
{
        public JsonResult Index()
        {
            var data = new DepartmentProvider().GetAll();
            return Json(data, JsonRequestBehavior.AllowGet);
        }
}

打開瀏覽器,訪問這個方法,發現拋出了下面的異常:

  這是由於我們的 Department 和 Employee 互為導航屬性,所以在 JSON 序列化時就產生了循環引用。我們確實是可以用 [JsonIgnore] 特性標簽解決循環引用的問題。

  我沒有使用這種方式,是因為公司的項目有類似下面這種業務邏輯:查詢所有的考勤記錄;單條考勤記錄下包含雇員作為導航屬性;單條雇員下包含部門作為導航屬性;然后把考勤記錄用 JSON 傳給前台。由於這里確實又需要把導航屬性JSON 序列化,所以我沒有使用 [JsonIgnore] 注解處理循環引用的問題。

  另外我們看上面的異常信息,拋出異常的並不是我們自己的實體類,而是EF的包裝類。如果我們用非跟蹤查詢的方式加載數據,JSON 序列化時會拋出和延遲加載有關的異常。具體信息我就不貼出來了。關閉延遲加載也不太好。

  所以我的解決方式是,用EF加載出數據后。把數據做一次深拷貝,然后把拷貝的數據緩存起來。這樣緩存的數據就不是EF的包裝類了。同時可以通過配置 AutoMapper 的映射行為,解決循環引用的問題。

  在用AutoMapper做映射的時候,也遇到了問題—— AutoMapper 把導航屬性的導航屬性也映射了,這個導航屬性的導航屬性依然是一個EF包裝類。AutoMapper 可以自定義映射行為,查看文檔后,找出了如下的配置方式。

  1.自定義一個Profile,利用反射出來的類型信息,將指定類型不做映射:

public class NotMapGenericAndModelProfile<TSource, TDestination> : Profile
{
        public NotMapGenericAndModelProfile()
        {
            CreateMap<TSource, TDestination>();
            ShouldMapProperty = 
                pr => pr.PropertyType.Namespace != "System.Collections.Generic"
                                  && pr.PropertyType.Namespace != "System.Linq"
                      && pr.PropertyType.Namespace != "WebApplication1.Models.CodeFirst";
        }
}

一對多的導航屬性肯定是泛型類,所以遇到泛型類型不做映射。一對一的導航屬性,其導航屬性一定是一個實體類,所以遇到實體類類型不做映射。

  2.使用上面的 Profile 配置一個 Mapper,用自動映射做深拷貝:

public class MapHelper
{
        public static List<TOuter> DeepCopy<TOuter, TInner>(List<TOuter> sourceData)
        {
            var mapper = new MapperConfiguration(cfg => {
                cfg.CreateMap<TOuter, TOuter>();
                cfg.AddProfile(new NotMapGenericAndModelProfile<TInner, TInner>());
            }).CreateMapper();

            var desData = mapper.Map<List<TOuter>>(sourceData);

            return desData;
        }
}

解釋一下為什么要傳兩個泛型參數。我們希望在 JSON 序列化 Department 數據時,保留 Department 的導航屬性 Employees,但是去除 Employee 的導航屬性 AttendanceRecords和 Department(為了解決循環引用)。所以 TOuter 的實參是 Department,TInner 的實參是 Employee,這樣就能映射 Department 的導航屬性,並且去除 Employee 的導航屬性。

  3.讀取數據后深拷貝,將拷貝后的數據做緩存: 

public List<Department> GetAll()
{
            var departmentList = CacheManager.Get<List<Department>>(cacheKey);
            if (departmentList == null)
            {
                departmentList = DbContext.Departments.Include(d => d.Employees)
                                           .ToList();
                departmentList = MapHelper.DeepCopy<Department, Employee>(departmentList);
                CacheManager.Set(cacheKey, departmentList);
            }
            return departmentList;
}

再次啟動項目,查看結果:

可以看到 Employee 的導航屬性 Department 和 AttendanceRedords 都被去除了,只保留了我們想要的信息。

 

六、最后

  上面就是我這次做緩存,遇到的問題以及解決方式。這讓我對設計模式的感知加深了許多。以前看設計模式,總是覺得設計模式離日常工作很遠,總是覺得設計模式之間是相互孤立的,總是覺得設計模式使用起來很僵化。

  通過這次做緩存,現在看來,設計模式是一種分隔代碼、組織代碼的方式。通過這種方式分割、組織的代碼,有良好的復用性、擴展性、可維護性。所以再去看沒有使用過的設計模式,我關注的點就是組織代碼的方式,而不是機械的死記硬背這個設計模式有哪些組成部分、有什么好處等。比如只要是動態的創建對象了,那就是簡單工廠模式;把具體的實現細節封裝起來,讓調用者覺得調用的東西都是一樣的,那就是策略模式。一個對象,通過第三方來影響另一個對象,這個第三方就是中介者,兩個對象這間就是觀察者和觀察目標。

  最后,非常感謝RDT項目組的亮哥教我如何考慮問題、如何具體的使用設計模式把代碼寫好。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM