輸出緩存與CachePanel
2008-07-28 10:02 Jeffrey Zhao 閱讀(40110) 評論(64) 收藏 舉報緩存的級別
緩存的作用自不必說,提高系統性能最重要的手段之一。上至應用框架,下至文件系統乃至CPU,計算機中各部分設計都能見到緩存的身影。許多朋友一直在追求如何提高Web應用程序的性能,其實最容易被理解和采納的一條估計就是“緩存”了。也正因為如此Live Journal才會開發出memcached,而微軟也推出了Velocity。
有朋友說生成靜態頁?好吧,在老趙看來,其實這只是把頁面內容緩存至硬盤罷了。不過這就涉及到了緩存的某些“級別”了。撇開硬件(如CPU)和系統(例如文件系統,數據庫系統)的緩存不說,如果只談論應用級別——也就是我們平時“代碼”中會遇到的緩存來說,一般可以將緩存分為兩大類:緩存“數據”和“輸出”。啥叫緩存數據呢?例如在代碼中,我們將一個表示用戶的User實例放入內存,以避免第二次讀取時訪問較慢的存儲設備,這就是緩存了“數據”。至于緩存輸出,最典型的也就莫過于剛才提到的“靜態頁”了。生成了靜態頁之后,大量的請求將直接獲得結果,而不需要進行動態處理,這性能自然就提高了。
這兩大類緩存其實也就是緩存的兩個級別。之所以把它們分個“高低”或“上下”(并非褒貶,僅指“位置”),是因為在一個應用中,這兩類緩存在使用和處理上有所不同。
對于數據緩存,如果用典型的三層架構(有些朋友會單獨提出一個“緩存層”,這我就不多作區分了,該往哪層靠攏相信大家自能分清)來舉例,往往發生在業務邏輯層和數據訪問層。數據訪問層里的緩存自然與業務邏輯無關,因此緩存的大都以“單個對象”為主,而緩存的命中與過期策略由“對象”所表示的“數據”的特性來決定,例如Hibernate的一級和二級緩存會根據對象的ID進行確定命中或過期操作。有些數據訪問框架提供了更復雜的緩存功能,例如Hibernate也能夠根據Query生成Key來進行緩存。而業務邏輯層中的緩存策略則可能較為復雜,命中與過期都與業務需求密切相關。例如一個用戶的好友列表(假設是一個int數組)將會根據用戶修改其好友信息時過期——當然也可能不是簡單的過期,而是直接在內存中進行修改,不過這時候就要考慮到并發性等等。
至于“輸出”緩存則容易理解多了。任何應用最終都要有所輸出,輸出往往就要導致格式的改變,例如變成HTML,XML,亦或是二進制流(有人說其實一切都是二進制流——沒錯沒錯,不過我們還是在略高層次上看這個問題吧)。這種直接和表現相關的緩存自然是放在“表現層”了。緩存“輸出”相對于緩存“數據”的優點便是有更高的性能。“數據”還需要通過運算才能變成“輸出”(試想我們可能需要操作了10K的數據只為了生成1K的有效內容),而緩存“輸出”則直接面向了輸出內容,性能的提高難道不是顯而易見的嗎?至于缺點,那就是降低了緩存的復用幾率,一個HTML形式的數據自然無法被需要SOAP格式的輸出所復用。降低了復用幾率則意味著降低了緩存命中率,則意味著提高了存儲所需的空間——或者降低了性能。等等,這不是和之前所說“較高性能”的有所矛盾嗎?沒錯,我們剛開始學算法就會接觸到一個詞:“時間換空間、空間換時間”,這就道出玩兒編程的本質:一切都是在權衡,世上沒有真正完美的做法。緩存“輸出”其實玩的就是“空間換時間”的游戲,我們緩存的便是同樣數據的不同表現形式。
當然緩存輸出還有個問題可能就是不太容易設計合適緩存策略(主要是過期策略),對于實時性要求高的內容,往往不得不放棄輸出緩存而啟用數據緩存。
話說回來,在很多情況下,我們的業務真需要很高的實時性嗎?例如一張新建的照片真的需要被立即索引到嗎?照片的訪問次數真要在列表中即時更新嗎?很多時候需求的一些細節膨脹只是一廂情愿,對于用戶來說并沒有太大意義——尤其是在Web 2.0應用中,因為用戶是很容易被誘導的,而且并不會對很多數據進行追究。因此技術架構師在和領域專家進行探討時不如多提一些建議,一個常用的“句式”是:“XX要求的目的是不是為了YY?如果ZZ的話您能否接受呢?”。不過,如果一個用戶刪除了自己的一篇文章,卻發現它還在自己的管理列表中出現,那么就實在不能說是一個“能夠令人接受”的現象了。
ASP.NET的Output Cache及其缺陷
ASP.NET作為一個成熟、強大的應用程序框架,緩存相關的設計自然是它不可或缺的一部份。而ASP.NET作為一個面向Web應用的框架,實現的便是表現層的方方面面,因此它所涉及到的緩存,自然就是上文所說的“輸出緩存”了(HTTP協議的緩存不在本文討論范圍之內)。ASP.NET中的輸出緩存即為所謂的“OutputCache”。Output Cache的使用可以在任意一本ASP.NET程序設計的書中找到,不過最詳細的描述自然莫過于MSDN了。下文將對于ASP.NET的Output Cache進行簡單描述,僅僅是為了形成完整的討論內容。
OutputCache為WebForms框架的一部分,可以在Page和User Control中使用。OutputCache的命中可以讓對于某個控件,甚至是整個請求內容的處理直接獲得HTML內容,而不需要對頁面或控件進行處理。Output Cache主要通過在aspx或ascx文件頂部添加<@ OutputCache />標記來使用,在標記中可以定義緩存的各種特性,例如緩存在多少時間后失效(Duration),緩存的存儲位置(Location),緩存的版本控制(VaryByControl / Header / Param / Custom / ContentEncoding)以及緩存與哪個SqlDependency依賴等等。而User Control中的OutputCache標記相對于Page還可以指定一個Shared屬性,如果該屬性為true,則表示UserControl的緩存可以跨Page命中,反之則會為不同的頁面生成不同的緩存內容。此外,ASP.NET還提供了<asp:Substitution />控件,該控件在User Control或Page被緩存的時候依然會被執行,使程序員可以通過編程的方式為緩存內容中的特定部分進行改變。
ASP.NET OutputCache使用起來非常容易,但是在實際運用中往往會顯得不夠。例如有一個Users控件,用于顯示UserIDs所指定的用戶。這很容易。不過我們現在有個“特別”的需求,就是在同一個頁面中放置兩個這樣的控件:
<jeffz:Users runat="server" UserIDs="1, 2, 3, 4, 5" />
<jeffz:Users runat="server" UserIDs="6, 7, 8, 9, 10" />
兩個控件“實例”的UserIDs參數截然不同,自然輸出的內容也會大相徑庭。不過如果我們為Users控件添加了OutputCache(并且使用了最傳統的VaryByParam="*")之后,問題就出現了。猜猜看結果如何?第二個控件實例的輸出和第一個完全相同了,都是參數“1, 2, 3, 4, 5”的結果。這是因為VaryByParam的Param是指QueryString,或Post時的Form數據,而我們的頁面在請求時哪有這方面的變化?于是我們就需要開始尋找解決方案了,翻遍了MSDN有關OutputCache的內容,可能只有一個VaryByCustom有些接近。可是VaryByCustom將某個特定參數傳給GlobalApplication的GetVaryByCustomString方法中,其余的信息就只有個HttpContext了。所以VaryByCustom也只能通過公用的信息來判斷是否使用之前緩存的版本,而無法根據哪個頁面的具體哪個控件實例,以及某個控件實例的狀態來決定緩存的版本。因此我們可以這么認為,在這種情況下,我們的Users控件無法使用ASP.NET的OutputCache。
還有一種情況就是需要“無條件”地為控件保存多個版本的緩存。例如有個需求是寫一個RandomUsers控件,根據UserIDs指定的數據中隨機挑選出幾個用于展示的用戶數據,例如:
<jeffz:RandomUsers runat="server" UserIDs="1, 2, 3, 4, 5" Count="3" />
那么現在還能夠使用ASP.NET的OutputCache嗎?至少老趙不知道該如何做。因此我們需要一個額外的緩存輸出的解決方案,它的要求其實只有兩個:
- 可以自由地定義緩存版本。
- 可以為每個版本緩存不同的副本,并隨機輸出。
這就是老趙下面要提到的這個解決方案:CachePanel的需求來源。
CachePanel的構建與使用
其實CachePanel很簡單,相信已經有一些朋友能夠想象出這個組件的大致邏輯了。根據老趙的習慣,我們還是使用“用例驅動開發”的方式來進行組件開發。例如老趙期望的使用方式是這樣的:
<jeffz:CachePanel runat="server"
Duration="00:15:00"
CopyCount="10"
CacheKey="RandomUsers"
ResolveCacheKey="CachePanel_ResolveCacheKey"
>
<jeffz:RandomUsers runat="server" UserIDs="1, 2, 3, 4, 5" Count="3" />
</asp:CachePanel>
以下是CachePanel的各種成員描述與代碼:
public class CachePanel : Control
{
public TimeSpan Duration { get; set; }
public int CopyCount { get; set; }
public string CacheKey { get; set; }
public EventHandler ResolveCacheKey { get; set; }
...
}
- Duration屬性:每份緩存副本的有效時間長度。這里使用TimeSpan的字符串表示法,老趙認為相較傳統的秒數,這樣能夠更直接地設定和讀取一段時間長度。
- CopyCount屬性:每個緩存版本的副本數量,輸出時將任意選擇一個副本輸出。在上例中,我們通過生成10個副本讓用戶看起來的確是在輸出隨機的結果,而其實我們只是緩存了10個副本而已。
- CacheKey屬性:不同的CacheKey決定了不同的緩存版本。請注意這個CacheKey是全局的,因此不同頁面中的CachePanel如果CacheKey相同,將會得到相同的緩存結果(排除CopyCount屬性的影響)。
- ResolveCacheKey事件:提供了一個動態指定緩存版本的可能。開發人員可以響應該事件,根據上下文環境的不同(例如QueryString,Form或Header的不同)對CacheKey進行改變。
可以看到,其實只是這簡單的四個成員就能滿足上文的要求(而且事實上,在理論上CopyCount也能夠省略,因為我們有ResolveCacheKey事件,不是嗎?)。
至于與緩存相關的具體邏輯,其實非常簡單。首先是在OnInit事件中檢查是否命中緩存:
- 執行ResolveCacheKey事件以確認CacheKey。
- 隨機選取副本編號。
- 根據CacheKey和副本編號確認被緩存的數據所使用的key(如果CacheKey為空,則使用默認的CacheKey,它保證了同一頁面中的位置相同的CachePanel實例共享緩存版本)。
- 如果緩存命中,則清除CachePanel內的所有子控件。
代碼如下:
public class CachePanel : Control
{
...
private static Random s_random = new Random(DateTime.Now.Millisecond);
public bool CacheHit { get; private set; }
private string m_cacheKey;
private string m_cachedContent;
protected override void OnInit(EventArgs e)
{
var resolveCacheKey = this.ResolveCacheKey;
if (resolveCacheKey != null)
{
resolveCacheKey(this, EventArgs.Empty);
}
int copyIndex = s_random.Next(this.CopyCount);
this.m_cacheKey = this.GetCacheKey(copyIndex);
this.m_cachedContent = this.Context.Cache.Get(this.m_cacheKey) as string;
this.CacheHit = (this.m_cachedContent != null);
if (this.CacheHit) this.Controls.Clear();
base.OnInit(e);
}
private string GetCacheKey(int copyIndex)
{
var cacheKeyBase = this.CacheKey ?? this.GetDefaultCacheKeyBase();
return "$CachePanel$" + cacheKeyBase + "_" + copyIndex;
}
private string GetDefaultCacheKeyBase()
{
return this.Context.Request.AppRelativeCurrentExecutionFilePath + "_" + this.UniqueID;
}
...
}
由于內容被清空,然后到了生成內容階段,事情就好辦了——簡單的緩存子控件生成的HTML內容即可:
public partial class CachePanel : Control
{
...
protected override void RenderChildren(HtmlTextWriter writer)
{
if (this.m_cachedContent == null)
{
StringBuilder sb = new StringBuilder();
HtmlTextWriter innerWriter = new HtmlTextWriter(new StringWriter(sb));
base.RenderChildren(innerWriter);
this.m_cachedContent = sb.ToString();
this.Context.Cache.Insert(this.m_cacheKey, this.m_cachedContent, null,
DateTime.Now.Add(this.Duration), Cache.NoSlidingExpiration);
}
writer.Write(this.m_cachedContent);
}
}
至此,CachePanel就制作完成了,其實只是短短的幾十行代碼而已。到這里老趙不禁又要發一句感慨:只要了解了框架的運行規則,開發出各種擴展又有多少難度呢?一切都只是看您有多少想象力而已。
不過大家在使用CachePane時可能還需要注意以下幾點:
- CacheKey的作用域是整個ASP.NET應用程序,因此如果您要指定CacheKey的話,請給出清晰而明確的值。
- CachePanel能夠緩存頁面中任意部分的內容,不過在使用時可能就需要您根據CacheHit屬性的值來判斷是否需要為控件填充數據,否則可能就會無法達到緩存的目的。
- CachePanel將會在緩存命中時清空所有子控件,因此在操作時也請注意這一點,以免出現不可預知(Unpredictable)的結果。
浙公網安備 33010602011771號