1. 程式人生 > >設計模式的使用——實現一個簡單的快取

設計模式的使用——實現一個簡單的快取

 

一、背景介紹

    我們日常開發網站時,經常會用到下圖這樣的下拉框。其中下拉框裡面的選項,不會經常變動。對於不會經常變動的資料,如果每次都從資料庫讀取,可能會影響網站的響應速度。所以通常會把這部分資料快取起來,使用時直接從快取讀取。如果在專案中引入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類。

    現在我們需要快取部門資料,最簡單的方式,就是在 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 "Merory":
                    return new MemoryCache();
                default:
                    return new MemoryCache();
            }
        }
}

然後再來看對 CacheManager 的修改:

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

        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);
        }
}

  總結一下這一部分的內容。我們使用門面模式,分離了 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 裡面到處寫響應變化的程式碼,我們使用中介者模式,集中在中介者類(就是我們的Observe)裡面處理Provider的關聯關係。通過這些方式,我們得到了易維護、可擴充套件的程式碼。這裡我賣一個小關子,通過改變觀察目標,還可以進一步的減少程式碼量。知道答案的朋友在評論裡面分享一下吧。

      快取的內容到這裡就結束了。下面的小節,是為了解決由於 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把導航屬性的導航屬性也映射了,這個導航屬性的導航屬性依然是一個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,這樣就能達到我們想要的效果。

  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;
}

再次啟動專案,檢視結果:

 

六、最後

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

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

  最後,非常感謝RDT專案組的老大亮哥教我如何考慮問題、如何具體的使用設計模式把程式碼寫