天天看點

細說 ASP.NET Cache 及其進階用法

許多做過程式性能優化的人,或者關注過程程式性能的人,應該都使用過各類緩存技術。 而我今天所說的Cache是專指ASP.NET的Cache,我們可以使用HttpRuntime.Cache通路到的那個Cache,而不是其它的緩存技術。

以前我在【我心目中的Asp.net核心對象】 這篇部落格中簡單地提過它,今天我打算為它寫篇專題部落格,專門來談談它,因為它實在是太重要了。在這篇部落格中, 我不僅要介紹它的一些常見用法,還将介紹它的一些進階用法。 在上篇部落格【在.net中讀寫config檔案的各種方法】 的結尾處,我給大家留了一個問題,今天,我将在這篇部落格中給出一個我認為較為完美的答案。

本文提到的【延遲操作】方法(如:延遲合并寫入資料庫)屬于我的經驗總結,希望大家能喜歡這個思路。

Cache的基本用途

提到Cache,不得不說說它的主要功能:改善程式性能。

ASP.NET是一種動态頁面技術,用ASP.NET技術做出來的網頁幾乎都是動态的,所謂動态是指:頁面的内容會随着不同的使用者或者持續更新的資料, 而呈現出不同的顯示結果。既然是動态的,那麼這些動态的内容是從哪裡來的呢?我想絕大多數網站都有自己的資料源, 程式通過通路資料源擷取頁面所需的資料,然後根據一些業務規則的計算處理,最後變成适合頁面展示的内容。

由于這種動态頁面技術通常需要從資料源擷取資料,并經過一些計算邏輯,最終變成一些HTML代碼發給用戶端顯示。而這些計算過程顯然也是有成本的。 這些處理成本最直接可表現為影響伺服器的響應速度,尤其是當資料的處理過程變得複雜以及通路量變大時,會變得比較明顯。 另一方面,有些資料并非時刻在發生變化,如果我們可以将一些變化不頻繁的資料的最終計算結果(包括頁面輸出)緩存起來, 就可以非常明顯地提升程式的性能,緩存的最常見且最重要的用途就展現在這個方面。 這也是為什麼一說到性能優化時,一般都将緩存擺在第一位的原因。 我今天要說到的ASP.NET Cache也是可以實作這種緩存的一種技術。 不過,它還有其它的一些功能,有些是其它緩存技術所沒有的。

Cache的定義

在介紹Cache的用法前,我們先來看一下Cache的定義:(說明:我忽略了一些意義不大的成員) 

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法

// 實作用于 Web 應用程式的緩存。無法繼承此類。
public sealed class Cache : IEnumerable
{
    // 用于 Cache.Insert(...) 方法調用中的 absoluteExpiration 參數中以訓示項從不過期。
    public static readonly DateTime NoAbsoluteExpiration;

    // 用作 Cache.Insert(...) 或 Cache.Add(...)
    //       方法調用中的 slidingExpiration 參數,以禁用可調過期。
    public static readonly TimeSpan NoSlidingExpiration;


    // 擷取或設定指定鍵處的緩存項。
    public object this[string key] { get; set; }


    // 将指定項添加到 System.Web.Caching.Cache 對象,該對象具有依賴項、過期和優先級政策
    // 以及一個委托(可用于在從 Cache 移除插入項時通知應用程式)。
    public object Add(string key, object value, CacheDependency dependencies,
                        DateTime absoluteExpiration, TimeSpan slidingExpiration,
                        CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback);


    // 從 System.Web.Caching.Cache 對象檢索指定項。
    // key: 要檢索的緩存項的辨別符。
    // 傳回結果: 檢索到的緩存項,未找到該鍵時為 null。
    public object Get(string key);


    public void Insert(string key, object value);
    public void Insert(string key, object value, CacheDependency dependencies);
    public void Insert(string key, object value, CacheDependency dependencies,
                                    DateTime absoluteExpiration, TimeSpan slidingExpiration);

    // 摘要:
    //     向 System.Web.Caching.Cache 對象中插入對象,後者具有依賴項、過期和優先級政策
    //        以及一個委托(可用于在從 Cache 移除插入項時通知應用程式)。
    //
    // 參數:
    //   key:
    //     用于引用該對象的緩存鍵。
    //
    //   value:
    //     要插入緩存中的對象。
    //
    //   dependencies:
    //     該項的檔案依賴項或緩存鍵依賴項。當任何依賴項更改時,該對象即無效,
    //            并從緩存中移除。如果沒有依賴項,則此參數包含 null。
    //
    //   absoluteExpiration:
    //     所插入對象将過期并被從緩存中移除的時間。
    //        如果使用絕對過期,則 slidingExpiration 參數必須為 Cache.NoSlidingExpiration。
    //
    //   slidingExpiration:
    //     最後一次通路所插入對象時與該對象過期時之間的時間間隔。如果該值等效于 20 分鐘,
    //       則對象在最後一次被通路 20 分鐘之後将過期并被從緩存中移除。如果使用可調過期,則
    //     absoluteExpiration 參數必須為 System.Web.Caching.Cache.NoAbsoluteExpiration。
    //
    //   priority:
    //     該對象相對于緩存中存儲的其他項的成本,由 System.Web.Caching.CacheItemPriority 枚舉表示。
    //       該值由緩存在退出對象時使用;具有較低成本的對象在具有較高成本的對象之前被從緩存移除。
    //
    //   onRemoveCallback:
    //     在從緩存中移除對象時将調用的委托(如果提供)。
    //            當從緩存中删除應用程式的對象時,可使用它來通知應用程式。
    //
    // 異常:
    //   System.ArgumentException:
    //     為要添加到 Cache 中的項設定 absoluteExpiration 和 slidingExpiration 參數。
    //
    //   System.ArgumentNullException:
    //     key 或 value 參數為 null。
    //
    //   System.ArgumentOutOfRangeException:
    //     将 slidingExpiration 參數設定為小于 TimeSpan.Zero 或大于一年的等效值。
    public void Insert(string key, object value, CacheDependency dependencies,
                        DateTime absoluteExpiration, TimeSpan slidingExpiration,
                        CacheItemPriority priority, CacheItemRemovedCallback onRemoveCallback);

    // 從應用程式的 System.Web.Caching.Cache 對象移除指定項。
    public object Remove(string key);

    // 将對象與依賴項政策、到期政策和優先級政策
    // 以及可用來在從緩存中移除項【之前】通知應用程式的委托一起插入到 Cache 對象中。
    // 注意:此方法受以下版本支援:3.5 SP1、3.0 SP1、2.0 SP1
    public void Insert(string key, object value, CacheDependency dependencies,
                            DateTime absoluteExpiration, TimeSpan slidingExpiration,
                            CacheItemUpdateCallback onUpdateCallback);
}      

View Code

ASP.NET為了友善我們通路Cache,在HttpRuntime類中加了一個靜态屬性Cache,這樣,我們就可以在任意地方使用Cache的功能。 而且,ASP.NET還給它增加了二個“快捷方式”:Page.Cache, HttpContext.Cache,我們通過這二個對象也可以通路到HttpRuntime.Cache, 注意:這三者是在通路同一個對象。Page.Cache通路了HttpContext.Cache,而HttpContext.Cache又直接通路HttpRuntime.Cache

Cache常見用法

通常,我們使用Cache時,一般隻有二個操作:讀,寫。

要從Cache中擷取一個緩存項,我們可以調用Cache.Get(key)方法,要将一個對象放入緩存,我們可以調用Add, Insert方法。 然而,Add, Insert方法都有許多參數,有時我們或許隻是想簡單地放入緩存,一切接受預設值,那麼還可以調用它的預設索引器, 我們來看一下這個索引器是如何工作的:

public object this[string key]
{
    get
    {
        return this.Get(key);
    }
    set
    {
        this.Insert(key, value);
    }
}      

可以看到:讀緩存,其實是在調用Get方法,而寫緩存則是在調用Insert方法的最簡單的那個重載版本。

注意了:Add方法也可以将一個對象放入緩存,這個方法有7個參數,而Insert也有一個簽名類似的重載版本, 它們有着類似的功能:将指定項添加到 System.Web.Caching.Cache 對象,該對象具有依賴項、過期和優先級政策以及一個委托(可用于在從 Cache 移除插入項時通知應用程式)。 然而,它們有一點小的差別:當要加入的緩存項已經在Cache中存在時,Insert将會覆寫原有的緩存項目,而Add則不會修改原有緩存項。

也就是說:如果您希望某個緩存項目一旦放入緩存後,就不要再被修改,那麼調用Add确實可以防止後來的修改操作。 而調用Insert方法,則永遠會覆寫已存在項(哪怕以前是調用Add加入的)。

從另一個角度看,Add的效果更像是 static readonly 的行為,而Insert的效果則像 static 的行為。

注意:我隻是說【像】,事實上它們比一般的static成員有着更靈活的用法。

由于緩存項可以讓我們随時通路,看起來确實有點static成員的味道,但它們有着更進階的特性,比如: 緩存過期(絕對過期,滑動過期),緩存依賴(依賴檔案,依賴其它緩存項),移除優先級,緩存移除前後的通知等等。 後面我将會分别介紹這四大類特性。

Cache類的特點

Cache類有一個很難得的優點,用MSDN上的說話就是:

此類型是線程安全的。

為什麼這是個難得的優點呢?因為在.net中,絕大多數類在實作時,都隻是保證靜态類型的方法是線程安全, 而不考慮執行個體方法是線程安全。這也算是一條基本的.NET設計規範原則。

對于那些類型,MSDN通常會用這樣的話來描述:

此類型的公共靜态(在 Visual Basic 中為 Shared)成員是線程安全的。但不能保證任何執行個體成員是線程安全的。

是以,這就意味着我們可以在任何地方讀寫Cache都不用擔心Cache的資料在多線程環境下的資料同步問題。 多線程程式設計中,最複雜的問題就是資料的同步問題,而Cache已經為我們解決了這些問題。

不過我要提醒您:ASP.NET本身就是一個多線程的程式設計模型,所有的請求是由線程池的線程來處理的。 通常,我們在多線程環境中為了解決資料同步問題,一般是采用鎖來保證資料同步, 自然地,ASP.NET也不例外,它為了解決資料的同步問題,内部也是采用了鎖。

說到這裡,或許有些人會想:既然隻一個Cache的靜态執行個體,那麼這種鎖會不會影響并發?

答案是肯定的,有鎖肯定會在一定程度上影響并發,這是沒有辦法的事情。

然而,ASP.NET在實作Cache時,會根據CPU的個數建立多個緩存容器,盡量可能地減小沖突, 以下就是Cache建立的核心過程:

internal static CacheInternal Create()
{
    CacheInternal internal2;
    int numSingleCaches = 0;
    if( numSingleCaches == 0 ) {
        uint numProcessCPUs = (uint)SystemInfo.GetNumProcessCPUs();
        numSingleCaches = 1;
        for( numProcessCPUs -= 1; numProcessCPUs > 0; numProcessCPUs = numProcessCPUs >> 1 ) {
            numSingleCaches = numSingleCaches << 1;
        }
    }
    CacheCommon cacheCommon = new CacheCommon();
    if( numSingleCaches == 1 ) {
        internal2 = new CacheSingle(cacheCommon, null, 0);
    }
    else {
        internal2 = new CacheMultiple(cacheCommon, numSingleCaches);
    }
    cacheCommon.SetCacheInternal(internal2);
    cacheCommon.ResetFromConfigSettings();
    return internal2;
}      

說明:CacheInternal是個内部用的包裝類,Cache的許多操作都要由它來完成。

在上面的代碼中,numSingleCaches的計算過程很重要,如果上面代碼不容易了解,那麼請看我下面的示例代碼:

static void Main()
{
    for( uint i = 1; i <= 20; i++ )
        ShowCount(i);            
}
static void ShowCount(uint numProcessCPUs)
{
    int numSingleCaches = 1;
    for( numProcessCPUs -= 1; numProcessCPUs > 0; numProcessCPUs = numProcessCPUs >> 1 ) {
        numSingleCaches = numSingleCaches << 1;
    }
    Console.Write(numSingleCaches + ",");
}      

程式将會輸出:

1,2,4,4,8,8,8,8,16,16,16,16,16,16,16,16,32,32,32,32      

CacheMultiple的構造函數如下:

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法
internal CacheMultiple(CacheCommon cacheCommon, int numSingleCaches) : base(cacheCommon)
{
    this._cacheIndexMask = numSingleCaches - 1;
    this._caches = new CacheSingle[numSingleCaches];
    for (int i = 0; i < numSingleCaches; i++)
    {
        this._caches[i] = new CacheSingle(cacheCommon, this, i);
    }
}      

現在您應該明白了吧:CacheSingle其實是ASP.NET内部使用的緩存容器,多個CPU時,它會建立多個緩存容器。

在寫入時,它是如何定位這些容器的呢?請繼續看代碼:

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法
internal CacheSingle GetCacheSingle(int hashCode)
{
    hashCode = Math.Abs(hashCode);
    int index = hashCode & this._cacheIndexMask;
    return this._caches[index];
}      

說明:參數中的hashCode是直接調用我們傳的key.GetHashCode() ,GetHashCode是由Object類定義的。

是以,從這個角度看,雖然ASP.NET的Cache隻有一個HttpRuntime.Cache靜态成員,但它的内部卻可能會包含多個緩存容器, 這種設計可以在一定程度上減少并發的影響。

不管如何設計,在多線程環境下,共用一個容器,沖突是免不了的。如果您隻是希望簡單的緩存一些資料, 不需要Cache的許多進階特性,那麼,可以考慮不用Cache 。 比如:可以建立一個Dictionary或者Hashtable的靜态執行個體,它也可以完成一些基本的緩存工作, 不過,我要提醒您:您要自己處理多線程通路資料時的資料同步問題。

順便說一句:Hashtable.Synchronized(new Hashtable())也是一個線程安全的集合,如果想簡單點,可以考慮它。

接下來,我們來看一下Cache的進階特性,這些都是Dictionary或者Hashtable不能完成的。

緩存項的過期時間

ASP.NET支援二種緩存項的過期政策:絕對過期和滑動過期。

1. 絕對過期,這個容易了解:就是在緩存放入Cache時,指定一個具體的時間。當時間到達指定的時間的時,緩存項自動從Cache中移除。

2. 滑動過期:某些緩存項,我們可能隻希望在有使用者在通路時,就盡量保留在緩存中,隻有當一段時間内使用者不再通路該緩存項時,才移除它, 這樣可以優化記憶體的使用,因為這種政策可以保證緩存的内容都是【很熱門】的。 作業系統的記憶體以及磁盤的緩存不都是這樣設計的嗎?而這一非常有用的特性,Cache也為我們準備好了,隻要在将緩存項放入緩存時, 指定一個滑動過期時間就可以實作了。

以上二個選項分别對應Add, Insert方法中的DateTime absoluteExpiration, TimeSpan slidingExpiration這二個參數。

注意:這二個參數都是成對使用的,但不能同時指定它們為一個【有效】值,最多隻能一個參數值有效。 當不使用另一個參數項時,請用Cache類定義二個static readonly字段指派。

這二個參數比較簡單,我就不多說了,隻說一句:如果都使用Noxxxxx這二個選項,那麼緩存項就一直儲存在緩存中。(或許也會被移除)

緩存項的依賴關系 - 依賴其它緩存項

ASP.NET Cache有個很強大的功能,那就是緩存依賴。一個緩存項可以依賴于另一個緩存項。 以下示例代碼建立了二個緩存項,且它們間有依賴關系。首先請看頁面代碼:

<body>
    <p>Key1 的緩存内容:<%= HttpRuntime.Cache["key1"] %></p>
    <hr />
        
    <form action="CacheDependencyDemo.aspx" method="post">
        <input type="submit" name="SetKey1Cache" value="設定Key1的值" />
        <input type="submit" name="SetKey2Cache" value="設定Key2的值" />
    </form>
</body>      

頁面背景代碼:

public partial class CacheDependencyDemo : System.Web.UI.Page
{
    [SubmitMethod(AutoRedirect=true)]
    private void SetKey1Cache()
    {
        SetKey2Cache();

        CacheDependency dep = new CacheDependency(null, new string[] { "key2" });
        HttpRuntime.Cache.Insert("key1", DateTime.Now.ToString(), dep, 
                                    Cache.NoAbsoluteExpiration, Cache.NoSlidingExpiration);
    }

    [SubmitMethod(AutoRedirect=true)]
    private void SetKey2Cache()
    {
        HttpRuntime.Cache.Insert("key2", Guid.NewGuid().ToString());
    }
}      

當運作這個示例頁面時,運作結果如下圖所示, 點選按鈕【設定Key1的值】時,将會出現緩存項的内容(左圖)。點選按鈕【設定Key2的值】時,此時将擷取不到緩存項的内容(右圖)。

細說 ASP.NET Cache 及其進階用法

根據結果并分析代碼,我們可以看出,在建立Key1的緩存項時,我們使用了這種緩存依賴關系:

CacheDependency dep = new CacheDependency(null, new string[] { "key2" });      

是以,當我們更新Key2的緩存項時,Key1的緩存就失效了(不存在)。

不要小看了這個示例。的确,僅看這幾行示例代碼,或許它們實在是沒有什麼意義。 那麼,我就舉個實際的使用場景來說明它的使用價值。

細說 ASP.NET Cache 及其進階用法

上面這幅圖是我寫的一個小工具。在示意圖中,左下角是一個緩存表CacheTable,它由一個叫Table1BLL的類來維護。 CacheTable的資料來源于Table1,由Table1.aspx頁面顯示出來。 同時,ReportA, ReportB的資料也主要來源于Table1,由于Table1的通路幾乎絕大多數都是讀多寫少,是以,我将Table1的資料緩存起來了。 而且,ReportA, ReportB這二個報表采用GDI直接畫出(由報表子產品生成,可認是Table1BLL的上層類),鑒于這二個報表的浏覽次數較多且資料源是讀多寫少, 是以,這二個報表的輸出結果,我也将它們緩存起來。

在這個場景中,我們可以想像一下:如果希望在Table1的資料發生修改後,如何讓二個報表的緩存結果失效?

讓Table1BLL去通知那二個報表子產品,還是Table1BLL去直接删除二個報表的緩存?

其實,不管是選擇前者還是後者,當以後還需要在Table1的CacheTable上做其它的緩存實作時(可能是其它的新報表), 那麼,勢必都要修改Table1BLL,那絕對是個失敗的設計。 這也算是子產品間耦合的所帶來的惡果。

幸好,ASP.NET Cache支援一種叫做緩存依賴的特性,我們隻需要讓Table1BLL公開它緩存CacheTable的KEY就可以了(假設KEY為 CacheTableKey), 然後,其它的緩存結果如果要基于CacheTable,設定一下對【CacheTableKey】的依賴就可以實作這樣的效果: 當CacheTable更新後,被依賴的緩存結果将會自動清除。這樣就徹底地解決了子產品間的緩存資料依賴問題。

緩存項的依賴關系 - 檔案依賴

在上篇部落格【在.net中讀寫config檔案的各種方法】的結尾, 我給大家留了一個問題:

我希望在使用者修改了配置檔案後,程式能立刻以最新的參數運作,而且不用重新開機網站。

今天我就來回答這個問題,并給出所需的全部實作代碼。

首先,我要說明一點:上次部落格的問題,雖然解決方案與Cache的檔案依賴有關,但還需與緩存的移除通知配合使用才能完美的解決問題。 為了便于内容的安排,我先使用Cache的檔案依賴來簡單的實作一個粗糙的版本,在本文的後續部分再來完善這個實作。

先來看個粗糙的版本。假如我的網站中有這樣一個配置參數類型:

/// <summary>
/// 模拟網站所需的運作參數
/// </summary>
public class RunOptions
{
    public string WebSiteUrl;
    public string UserName;
}      

我可以将它配置在這樣一個XML檔案中:

<?xml version="1.0" encoding="utf-8"?>
<RunOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
            xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <WebSiteUrl>http://www.cnblogs.com/fish-li</WebSiteUrl>
  <UserName>fish li</UserName>
</RunOptions>      

再來一個用于顯示運作參數的頁面:

<body>
    <p>WebSiteUrl: <%= WebSiteApp.RunOptions.WebSiteUrl %></p>
    <p>UserName: <%= WebSiteApp.RunOptions.UserName %></p>
</body>      

下面的代碼就可以實作:在XML修改後,浏覽頁面就能立即看到最新的參數值:

public static class WebSiteApp
{
    private static readonly string RunOptionsCacheKey = Guid.NewGuid().ToString();

    public static RunOptions RunOptions
    {
        get
        {
            // 首先嘗試從緩存中擷取運作參數
            RunOptions options = HttpRuntime.Cache[RunOptionsCacheKey] as RunOptions;
            if( options == null ) {
                // 緩存中沒有,則從檔案中加載
                string path = HttpContext.Current.Server.MapPath("~/App_Data/RunOptions.xml");
                options = RwConfigDemo.XmlHelper.XmlDeserializeFromFile<RunOptions>(path, Encoding.UTF8);

                // 把從檔案中讀到的結果放入緩存,并設定與檔案的依賴關系。
                CacheDependency dep = new CacheDependency(path);
                // 如果您的參數較複雜,與多個檔案相關,那麼也可以使用下面的方式,傳遞多個檔案路徑。
                //CacheDependency dep = new CacheDependency(new string[] { path });
                HttpRuntime.Cache.Insert(RunOptionsCacheKey, options, dep);
            }
            return options;
        }
    }
}      

注意:這裡仍然是在使用CacheDependency,隻是我們現在是給它的構造函數的第一個參數傳遞要依賴的檔案名。

在即将結束對緩存的依賴介紹之前,還要補充二點:

1. CacheDependency還支援【嵌套】,即:CacheDependency的構造函數中支援傳入其它的CacheDependency執行個體,這樣可以構成一種非常複雜的樹狀依賴關系。

2. 緩存依賴的對象還可以是SQL SERVER,具體可參考SqlCacheDependency

緩存項的移除優先級

緩存的做法有很多種,一個靜态變量也可以稱為是一個緩存。一個靜态的集合就是一個緩存的容器了。 我想很多人都用Dictionary,List,或者Hashtable做過緩存容器,我們可以使用它們來儲存各種資料,改善程式的性能。 一般情況下,如果我們直接使用這類集合去緩存各類資料,那麼,那些資料所占用的記憶體将不會被回收,哪怕它們的使用機會并不是很多。 當緩存資料越來越多時,它們所消耗的記憶體自然也會越來越多。那麼,能不能在記憶體不充足時,釋放掉一些通路不頻繁的緩存項呢?

這個問題也确實是個較現實的問題。雖然,使用緩存會使用程式運作更快,但是,我們資料會無限大,不可能統統緩存起來, 畢竟,記憶體空間是有限的。是以,我們可以使用前面所說的基于一段時間内不再通路就删除的政策來解決這個問題。 然而,在我們編碼時,根本不知道我們的程式會運作在什麼配置标準的計算機上,是以,根本不可能會對記憶體的大小作出任何假設, 此時,我們可能會希望當緩存占用過多的記憶體時,且當記憶體不夠時,能自動移除一些不太重要的緩存項,這或許也比較有意義。

對于這個需求,在.net framework提供了二種解決辦法,一種是使用WeakReference類,另一種是使用Cache 。 不過,既然我們是在使用ASP.NET,選擇Cache當然會更友善。 在Cache的Add, Insert方法的某些重載版本中,可以指定緩存項的儲存優先級政策,由參數CacheItemPriority priority來傳入。 其中,CacheItemPriority是一個枚舉類型,它包含了如下枚舉值: 

細說 ASP.NET Cache 及其進階用法

說明:當我們調用Cache的Add, Insert方法時,如果不指定CacheItemPriority選項,最終使用Normal所代表的優先級。 如果我們希望将某個可能不太重要的資料放入緩存時,可以指定優先級為Low或者BelowNormal。 如果想讓緩存項在記憶體不足時,也不會被移除(除非到期或者依賴項有改變),可使用NotRemovable。

顯然,我們可以使用這個特性來控制緩存對記憶體壓力的影響。 其它的緩存方案,如static Collection + WeakReference也較難實作這樣靈活的控制。

緩存項的移除通知

ASP.NET Cache與一些static變量所實作的緩存效果并不相同,它的緩存項是可以根據一些特定的條件失效的,那些失效的緩存将會從記憶體中移除。 雖然,某些移除條件并不是由我們的代碼直接解發的,但ASP.NET還是提供一種方法讓我們可以在緩存項在移除時,能通知我們的代碼。

注意哦:ASP.NET Cache支援移除【前】通知 和 移除【後】通知二種通知方式。

我們可以在調用Add, Insert方法時,通過參數onRemoveCallback傳遞一個CacheItemRemovedCallback類型的委托,以便在移除指定的緩存項時, 能夠通知我們。這個委托的定義如下:

/// <summary>
/// 定義在從 System.Web.Caching.Cache 移除緩存項時通知應用程式的回調方法。
/// </summary>
/// <param name="key">從緩存中移除的鍵(當初由Add, Insert傳入的)。</param>
/// <param name="value">與從緩存中移除的鍵關聯的緩存項(當初由Add, Insert傳入的)。</param>
/// <param name="reason">從緩存移除項的原因。 </param>
public delegate void CacheItemRemovedCallback(string key, object value, CacheItemRemovedReason reason);


//  指定從 System.Web.Caching.Cache 對象移除項的原因。
public enum CacheItemRemovedReason
{
    //  該項是通過指定相同鍵的 Cache.Insert(System.String,System.Object)
    //  方法調用或 Cache.Remove(System.String) 方法調用從緩存中移除的。
    Removed = 1,

    //  從緩存移除該項的原因是它已過期。
    Expired = 2,

    //  之是以從緩存中移除該項,是因為系統要通過移除該項來釋放記憶體。
    Underused = 3,

    //  從緩存移除該項的原因是與之關聯的緩存依賴項已更改。
    DependencyChanged = 4,
}      

委托的各個參數的含義以及移除原因,在注釋中都有明确的解釋,我也不再重複了。

我想:有很多人知道Cache的Add, Insert方法有這個參數,也知道有這個委托,但是,它們有什麼用呢? 在後面的二個小節中,我将提供二個示例來示範這一強大的功能。

通常,我們會以下面這種方式從Cache中擷取結果:

RunOptions options = HttpRuntime.Cache[RunOptionsCacheKey] as RunOptions;
if( options == null ) {
    // 緩存中沒有,則從檔案中加載
    // ..................................

    HttpRuntime.Cache.Insert(RunOptionsCacheKey, options, dep);
}
return options;      

這其實也是一個慣用法了:先嘗試從緩存中擷取,如果沒有,則從資料源中加載,并再次放入緩存。

為什麼會在通路Cache時傳回null呢?答案無非就是二種原因:1. 根本沒有放入Cache,2. 緩存項失效被移除了。

這種寫法本身是沒有問題,可是,如果從資料源中加載資料的時間較長,情況會怎樣呢?

顯然,會影響後面第一次的通路請求。您有沒有想過,如果緩存項能一直放在Cache中,那不就可以了嘛。 是的,通常來說,隻要您在将一個對象放入Cache時,不指定過期時間,不指定緩存依賴,且設定為永不移除,那麼對象确實會一直在Cache中, 可是,過期時間和緩存依賴也很有用哦。如何能二者兼得呢?

為了解決這個問題,微軟在.net framework的3.5 SP1、3.0 SP1、2.0 SP1版本中,加入了【移除前通知】功能,不過,這個方法僅受Insert支援, 随之而來的還有一個委托和一個移除原因的枚舉定義:

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法
/// <summary>
/// 定義一個回調方法,用于在從緩存中移除緩存項之前通知應用程式。
/// </summary>
/// <param name="key">要從緩存中移除的項的辨別符。</param>
/// <param name="reason">要從緩存中移除項的原因。</param>
/// <param name="expensiveObject">此方法傳回時,包含含有更新的緩存項對象。</param>
/// <param name="dependency">此方法傳回時,包含新的依賴項的對象。</param>
/// <param name="absoluteExpiration">此方法傳回時,包含對象的到期時間。</param>
/// <param name="slidingExpiration">此方法傳回時,包含對象的上次通路時間和對象的到期時間之間的時間間隔。</param>
public delegate void CacheItemUpdateCallback(string key, CacheItemUpdateReason reason, 
                out object expensiveObject, 
                out CacheDependency dependency, 
                out DateTime absoluteExpiration, 
                out TimeSpan slidingExpiration);

/// <summary>
/// 指定要從 Cache 對象中移除緩存項的原因。
/// </summary>
public enum CacheItemUpdateReason
{
    /// <summary>
    /// 指定要從緩存中移除項的原因是絕對到期或可調到期時間間隔已到期。
    /// </summary>
    Expired = 1,
    /// <summary>
    /// 指定要從緩存中移除項的原因是關聯的 CacheDependency 對象發生了更改。
    /// </summary>
    DependencyChanged = 2,
}      

注意:CacheItemUpdateReason這個枚舉隻有二項。原因請看MSDN的解釋:

與 CacheItemRemovedReason 枚舉不同,此枚舉不包含 Removed 或 Underused 值。可更新的緩存項是不可移除的,因而絕不會被 ASP.NET 自動移除,即使需要釋放記憶體也是如此。

再一次提醒:有時我們确實需要緩存失效這個特性,但是,緩存失效後會被移除。 雖然我們可以讓後續的請求在擷取不到緩存資料時,從資料源中加載,也可以在CacheItemRemovedCallback回調委托中, 重新加載緩存資料到Cache中,但是在資料的加載過程中,Cache并不包含我們所期望的緩存資料,如果加載時間越長,這種【空缺】效果也會越明顯。 這樣會影響(後續的)其它請求的通路。為了保證讓我們所期望的緩存資料能夠一直存在于Cahce中,且仍有失效機制,我們可以使用【移除前通知】功能。

巧用緩存項的移除通知 實作【延遲操作】

我看過一些ASP.NET的書,也看過一些人寫的關于Cache方面的文章,基本上,要麼是一帶而過,要麼隻是舉個毫無實際意義的示例。 可惜啊,這麼強大的特性,我很少見到有人把它用起來。

今天,我就舉個有實際意義的示例,再現Cache的強大功能!

我有這樣一個頁面,可以讓使用者調整(上下移動)某個項目分支記錄的上線順序:

細說 ASP.NET Cache 及其進階用法

當使用者需要調整某條記錄的位置時,頁面會彈出一個對話框,要求輸入一個調整原因,并會發郵件通知所有相關人員。

細說 ASP.NET Cache 及其進階用法

由于界面的限制,一次操作(點選上下鍵頭)隻是将一條記錄移動一個位置,當要對某條記錄執行跨越多行移動時,必須進行多次移動。 考慮到操作的友善性以及不受重複郵件的影響,程式需要實作這樣一個需求: 頁面隻要求輸入一次原因便可以對一條記錄執行多次移動操作,并且不要多次發重複郵件,而且要求将最後的移動結果在郵件中發出來。

這個需求很合理,畢竟誰都希望操作簡單。

那麼如何實作這個需求呢?這裡要從二個方面來實作,首先,在頁面上我們應該要完成這個功能,對一條記錄隻彈一次對話框。 由于頁面與服務端的互動全部采用Ajax方式進行(不重新整理),狀态可以采用JS變量來維持,是以這個功能在頁面中是很容易實作。 再來看一下服務端,由于服務端并沒有任何狀态,當然也可以由頁面把它的狀态傳給服務端,但是,哪次操作是最後一次呢? 顯然,這是無法知道的,最後隻能修改需求,如果使用者在2分鐘之内不再操作某條記錄時,便将最近一次操作視為最後一次操作。

基于新的需求,程式必須記錄使用者的最近一次操作,以便在2分鐘不操作後,發出一次郵件,但要包含第一次輸入的原因, 還應包含最後的修改結果哦。

該怎麼實作這個需求呢? 我立即就想到了ASP.NET Cache,因為我了解它,知道它能幫我完成這個功能。下面我來說說在服務端是如何實作的。

整個實作的思路是:

1. 用戶端頁面還是每次将記錄的RowGuid, 調整方向,調整原因,這三個參數發到服務端。

2. 服務端在處理完順序調整操作後,将要發送的郵件資訊Insert到Cache中,同時提供slidingExpiration和onRemoveCallback參數。

3. 在CacheItemRemovedCallback回調委托中,忽略CacheItemRemovedReason.Removed的通知,如果是其它的通知,則發郵件。

為了便于了解,我特意為大家準備了一個示例。整個示例由三部分組成:一個頁面,一個JS檔案,服務端代碼。先來看頁面代碼:

<body>
    <p> 為了簡單,示例頁面隻處理一條記錄,且将記錄的RowGuid直接顯示出來。<br />
        實際場景中,這個RowGuid應該可以從一個表格的【目前選擇行】中擷取到。
    </p>
    <p> 目前選擇行的 RowGuid = <span id="spanRowGuid"><%= Guid.NewGuid().ToString() %></span><br />
        目前選擇行的 Sequence= <span id="spanSequence">0</span>
    </p>
    <p><input type="button" id="btnMoveUp" value="上移" />
        <input type="button" id="btnMoveDown" value="下移" />
    </p>
</body>      

頁面的顯示效果如下:

細說 ASP.NET Cache 及其進階用法

處理頁面中二個按鈕的JS代碼如下:

// 使用者輸入的調整記錄的原因
var g_reason = null;

$(function(){
    $("#btnMoveUp").click( function() { MoveRec(-1); } );
    $("#btnMoveDown").click( function() { MoveRec(1); } );
});

function MoveRec(direction){
    if( ~~($("#spanSequence").text()) + direction < 0 ){
        alert("已經不能上移了。");
        return;
    }
    if( g_reason == null ){
        g_reason = prompt("請輸入調整記錄順序的原因:", "由于什麼什麼原因,我要調整...");
        if( g_reason == null )
            return;
    }
    
    $.ajax({
        url: "/AjaxDelaySendMail/MoveRec.fish",
        data: { RowGuid: $("#spanRowGuid").text(), 
                Direction: direction,
                Reason: g_reason
        },
        type: "POST", dataType: "text",
        success: function(responseText){
            $("#spanSequence").text(responseText);
        }
    });
}      

說明:在服務端,我使用了我在【用Asp.net寫自己的服務架構】那篇部落格中提供的服務架構, 服務端的全部代碼是這個樣子的:(注意代碼中的注釋)

/// <summary>
/// 移動記錄的相關資訊。
/// </summary>
public class MoveRecInfo
{
    public string RowGuid;
    public int Direction;
    public string Reason;
}


[MyService]
public class AjaxDelaySendMail
{
    [MyServiceMethod]
    public int MoveRec(MoveRecInfo info)
    {
        // 這裡就不驗證從用戶端傳入的參數了。實際開發中這個是必須的。

        // 先來調整記錄的順序,示例程式沒有資料庫,就用Cache來代替。
        int sequence = 0;
        int.TryParse(HttpRuntime.Cache[info.RowGuid] as string, out sequence);
        // 簡單地示例一下調整順序。
        sequence += info.Direction;
        HttpRuntime.Cache[info.RowGuid] = sequence.ToString();


        string key = info.RowGuid +"_DelaySendMail";
        // 這裡我不直接發郵件,而是把這個資訊放入Cache中,并設定2秒的滑過過期時間,并指定移除通知委托
        // 将操作資訊放在緩存,并且以覆寫形式放入,這樣便可以實作儲存最後狀态。
        // 注意:這裡我用Insert方法。
        HttpRuntime.Cache.Insert(key, info, null, Cache.NoAbsoluteExpiration,
            TimeSpan.FromMinutes(2.0), CacheItemPriority.NotRemovable, MoveRecInfoRemovedCallback);

        return sequence;
    }    

    private void MoveRecInfoRemovedCallback(string key, object value, CacheItemRemovedReason reason)
    {
        if( reason == CacheItemRemovedReason.Removed )
            return;        // 忽略後續調用HttpRuntime.Cache.Insert()所觸發的操作

        // 能運作到這裡,就表示是肯定是緩存過期了。
        // 換句話說就是:使用者2分鐘再也沒操作過了。

        // 從參數value取回操作資訊
        MoveRecInfo info = (MoveRecInfo)value;
        // 這裡可以對info做其它的處理。

        // 最後發一次郵件。整個延遲發郵件的過程就處理完了。
        MailSender.SendMail(info);
    }
}      

為了能讓JavaScript能直接調用C#中的方法,還需要在web.config中加入如下配置:

<httpHandlers>
    <add path="*.fish" verb="*" validate="false" type="MySimpleServiceFramework.AjaxServiceHandler"/>
</httpHandlers>      

好了,示例代碼就是這些。如果您有興趣,可以在本文的結尾處下載下傳這些示例代碼,自己親自感受一下利用Cache實作的【延遲處理】的功能。

其實這種【延遲處理】的功能是很有用的,比如還有一種适用場景:有些資料記錄可能需要頻繁更新,如果每次更新都去寫資料庫,肯定會對資料庫造成一定的壓力, 但由于這些資料也不是特别重要,是以,我們可以利用這種【延遲處理】來将寫資料庫的時機進行合并處理, 最終我們可以實作:将多次的寫入變成一次或者少量的寫入操作,我稱這樣效果為:延遲合并寫入

這裡我就對資料庫的延遲合并寫入提供一個思路:将需要寫入的資料記錄放入Cache,調用Insert方法并提供slidingExpiration和onRemoveCallback參數, 然後在CacheItemRemovedCallback回調委托中,模仿我前面的示例代碼,将多次變成一次。不過,這樣可能會有一個問題:如果資料是一直在修改,那麼就一直不會寫入資料庫。 最後如果網站重新開機了,資料可能會丢失。如果擔心這個問題,那麼,可以在回調委托中,遇到CacheItemRemovedReason.Removed時,使用計數累加的方式,當到達一定數量後, 再寫入資料庫。比如:遇到10次CacheItemRemovedReason.Removed我就寫一次資料庫,這樣就會将原來需要寫10次的資料庫操作變成一次了。 當然了,如果是其它移除原因,寫資料庫總是必要的。注意:對于金額這類敏感的資料,絕對不要使用這種方法。

再補充二點:

1. 當CacheItemRemovedCallback回調委托被調用時,緩存項已經不在Cache中了。

2. 在CacheItemRemovedCallback回調委托中,我們還可以将緩存項重新放入緩存。

有沒有想過:這種設計可以構成一個循環?如果再結合參數slidingExpiration便可實作一個定時器的效果。

關于緩存的失效時間,我要再提醒一點:通過absoluteExpiration, slidingExpiration參數所傳入的時間,當緩存時間生效時,緩存對象并不會立即移除, ASP.NET Cache大約以20秒的頻率去檢查這些已過時的緩存項。

巧用緩存項的移除通知 實作【自動加載配置檔案】

在本文的前部分的【檔案依賴】小節中,有一個示例示範了:當配置檔案更新後,頁面可以顯示最新的修改結果。 在那個示例中,為了簡單,我直接将配置參數放在Cache中,每次使用時再從Cache中擷取。 如果配置參數較多,這種做法或許也會影響性能,畢竟配置參數并不會經常修改,如果能直接通路一個靜态變量就能擷取到,應該會更快。 通常,我們可能會這樣做:

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法
private static RunOptions s_RunOptions;

public static RunOptions RunOptions
{
    // s_RunOptions 的初始化放在Init方法中了,會在Global.asax的Application_Start事件中調用。
    get { return s_RunOptions; }
}

public static RunOptions LoadRunOptions()
{
    string path = Path.Combine(AppDataPath, "RunOptions.xml");
    return RwConfigDemo.XmlHelper.XmlDeserializeFromFile<RunOptions>(path, Encoding.UTF8);
}      

但是,這種做法有一缺點就是:不能在配置檔案更新後,自動加載最新的配置結果。

為了解決這個問題,我們可以使用Cache提供的檔案依賴以及移除通知功能。 前面的示例示範了移除後通知功能,這裡我再示範一下移除前通知功能。

說明:事實上,完成這個功能,可以仍然使用移除後通知,隻是移除前通知我還沒有示範,然而,這裡使用移除前通知并沒有顯示它的獨有的功能。

下面的代碼示範了在配置檔案修改後,自動更新運作參數的實作方式:(注意代碼中的注釋)

細說 ASP.NET Cache 及其進階用法
細說 ASP.NET Cache 及其進階用法
private static int s_RunOptionsCacheDependencyFlag = 0;

public static RunOptions LoadRunOptions()
{
    string path = Path.Combine(AppDataPath, "RunOptions.xml");
    // 注意啦:通路檔案是可能會出現異常。不要學我,我寫的是示例代碼。
    RunOptions options = RwConfigDemo.XmlHelper.XmlDeserializeFromFile<RunOptions>(path, Encoding.UTF8);

    int flag = System.Threading.Interlocked.CompareExchange(ref s_RunOptionsCacheDependencyFlag, 1, 0);

    // 確定隻調用一次就可以了。
    if( flag == 0 ) {
        // 讓Cache幫我們盯住這個配置檔案。
        CacheDependency dep = new CacheDependency(path);
        HttpRuntime.Cache.Insert(RunOptionsCacheKey, "Fish Li", dep,
            Cache.NoAbsoluteExpiration, Cache.NoSlidingExpiration, RunOptionsUpdateCallback);
    }

    return options;
}

public static void RunOptionsUpdateCallback(
    string key, CacheItemUpdateReason reason, 
    out object expensiveObject, 
    out CacheDependency dependency, 
    out DateTime absoluteExpiration, 
    out TimeSpan slidingExpiration)
{
    // 注意哦:在這個方法中,不要出現【未處理異常】,否則緩存對象将被移除。

    // 說明:這裡我并不關心參數reason,因為我根本就沒有使用過期時間
    //        是以,隻有一種原因:依賴的檔案發生了改變。
    //        參數key我也不關心,因為這個方法是【專用】的。

    expensiveObject = "http://www.cnblogs.com/fish-li/";
    dependency = new CacheDependency(Path.Combine(AppDataPath, "RunOptions.xml"));
    absoluteExpiration = Cache.NoAbsoluteExpiration;
    slidingExpiration = Cache.NoSlidingExpiration;

    // 重新加載配置參數
    s_RunOptions = LoadRunOptions();
}      

改動很小,隻是LoadRunOptions方法做了修改了而已,但是效果卻很酷。

還記得我在上篇部落格【在.net中讀寫config檔案的各種方法】的結尾處留下來的問題嗎? 這個示例就是我的解決方案。

檔案監視技術的選擇

對于檔案監視,我想有人或許會想到FileSystemWatcher。正好我就來說說關于【檔案監視技術】的選擇問題。

說明,本文所有結論均為我個人的觀點,僅供參考。

這個元件,早在做WinForm開發時就用過了,對它也是印象比較深的。

它有一個包裝不好的地方是:事件會重複發出。比如:一次檔案的儲存操作,它卻引發了二次事件。

什麼,你不信? 正好,我還準備了一個示例程式。

細說 ASP.NET Cache 及其進階用法

說明:圖檔中顯示了發生過二次事件,但我隻是在修改了檔案後,做了一次儲存操作而已。 本文的結尾處有我的示例程式,您可以自己去試一下。這裡為了友善,還是貼出相關代碼:

private void Form1_Shown(object sender, EventArgs e)
{
    this.fileSystemWatcher1.Path = Environment.CurrentDirectory;
    this.fileSystemWatcher1.Filter = "RunOptions.xml";
    this.fileSystemWatcher1.NotifyFilter = System.IO.NotifyFilters.LastWrite;
    this.fileSystemWatcher1.EnableRaisingEvents = true;            
}

private void fileSystemWatcher1_Changed(object sender, System.IO.FileSystemEventArgs e)
{
    string message = string.Format("{0} {1}.", e.Name, e.ChangeType);
    this.listBox1.Items.Add(message);
}      

對于這個類的使用,隻想說一點:會引發的事件很多,是以一定要注意過濾。以下引用MSDN的一段說明:

Windows 作業系統在 FileSystemWatcher 建立的緩沖區中通知元件檔案發生更改。如果短時間内有很多更改,則緩沖區可能會溢出。這将導緻元件失去對目錄更改的跟蹤,并且它将隻提供一般性通知。使用 InternalBufferSize 屬性來增加緩沖區大小的開銷較大,因為它來自無法換出到磁盤的非頁面記憶體,是以應確定緩沖區大小适中(盡量小,但也要有足夠大小以便不會丢失任何檔案更改事件)。若要避免緩沖區溢出,請使用 NotifyFilter 和 IncludeSubdirectories 屬性,以便可以篩選掉不想要的更改通知。

幸運的是,ASP.NET Cache并沒有使用這個元件,我們不用擔心檔案依賴而引發的重複操作問題。 它直接依賴于webengine.dll所提供的API,是以,建議在ASP.NET應用程式中,優先使用Cache所提供的檔案依賴功能。

各種緩存方案的共存