適合ASP.NET MVC的檢視片斷快取方式(中):更實用的API
上一篇文章中我們提出了了片斷快取的基本方式,也就是構建HtmlHelper的擴充套件方法Cache,接受一個用於生成字串的委託物件。在快取命中時,則直接返回快取中的字串片斷,否則則使用委託生成的內容。因此,快取命中時委託的開銷便節省了下來。不過這個方法並不實用,如果您要快取大片的HTML,還需要準備一個Partial View,再用它來生成網頁片段:
<%= Html.Cache(..., () => Html.Partial("MyPartialViewToCache")) %>
但是在實際開發過程中,我們最樂於看到的使用方法,應該只是使用某個標記來“圍繞”一段現有的程式碼。也就是說,我們希望的API使用方式可能是這樣的:
<% Html.Cache("cache_key", DateTime.Now.AddSeconds(10), () => { %> <% foreach (var article in Model.Articles) { %> <p><%= article.Body %></p> <% } %> <% }); %>
我們可以從這種“表現形式”上推斷出這個Cache方法的簽名:
public static void Cache( thisHtmlHelper htmlHelper, string cacheKey, CacheDependency cacheDependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, Action action) { ... }
與前一個擴充套件相比,最後一個委託引數變成了Action,而不是Func<string>。這是因為ASP.NET頁面在編譯時,會將頁面Cache塊中的程式碼,編譯為內容的輸出方式——這點在之前的文章中已經有過比較詳細的描述。不過有一點還是與之前相同的,我們要省下的是action委託的開銷。也就是說,如果快取命中,則不執行action。快取沒有命中,則執行action,獲得action生成的字串,加入快取並輸出。
看似比較簡單,但這裡有個問題:如之前的Func<string>引數,我們執行後自然可以獲得一個字串作為結果。但是現在是個action,執行後它又把內容輸出到什麼地方去,我們又該如何得到這裡生成的字串呢?根據頁面輸出行為,我們可以推斷出頁面上的內容是被寫入一個HtmlTextWriter中的。那麼,這個HtmlTextWriter又是如何生成的呢?
它是根據Page型別的CreateHtmlTextWriter方法生成的:
protected virtual HtmlTextWriter CreateHtmlTextWriter(System.IO.TextWriter tw) { ... }
在頁面準備生成內容之前,Page會呼叫其CreateHtmlTextWriter來包裝一個TextWriter,這個TextWriter一般即是由Response.Output暴露出來的HttpWriter物件。CreateHtmlTextWriter方法生成的HtmlTextWriter,便會交給Page的Render方法用於輸出頁面內容了。這便是我們的入手點,我們可以趁此機會在HtmlTextWriter和CreateHtmlTextWriter之間“插入”一個元件。這個元件除了將外部傳入的資料傳入內部的TextWriter以外,還有著“紀錄”內容的功能:
internal class RecordWriter : TextWriter { public RecordWriter(TextWriter innerWriter) { this.m_innerWriter = innerWriter; } private TextWriter m_innerWriter; private List<StringBuilder> m_recorders = new List<StringBuilder>(); public override Encoding Encoding { get { return this.m_innerWriter.Encoding; } } public override void Write(char value) { ... } public override void Write(string value) { if (value != null) { this.m_innerWriter.Write(value); if (this.m_recorders.Count > 0) { foreach (var recorder in this.m_recorders) { recorder.Append(value); } } } } public override void Write(char[] buffer, int index, int count) { ... } public void AddRecorder(StringBuilder recorder) { this.m_recorders.Add(recorder); } public void RemoveRecorder(StringBuilder recorder) { this.m_recorders.Remove(recorder); } }
一個TextWriter有數十個可以覆蓋的成員,但是一般情況下我們只需覆蓋其中三個Write方法就可以了。以上程式碼用Write(string)作為示例,可以看出,如果RecordWriter中添加了Recorder之後,便會將外界寫入的內容再交給Recorder一次。換句話說,如果我們希望紀錄頁面上寫入Writer的內容,只要在RecordWriter裡新增Recorder就可以了。當然,在此之前我們需要為檢視頁面“開啟”快取功能:
// 定義在CacheExtensions中 public static TextWriter CreateCacheWriter(this HtmlHelper htmlHelper, TextWriter writer) { var recordWriter = new RecordWriter(writer); htmlHelper.SetRecordWriter(recordWriter); return recordWriter; } // 定義在檢視頁面(aspx)中 <script runat="server"> protected override HtmlTextWriter CreateHtmlTextWriter(System.IO.TextWriter tw) { return base.CreateHtmlTextWriter(Html.CreateCacheWriter(tw)); } </script>
當然,在實際開發過程中不會在aspx中重寫CreateHtmlTextWriter方法,我們往往會將其放在檢視頁面的共同基類中。例如在我的專案中,我就為所有的檢視“開啟”了這種紀錄功能。由於在沒有快取的情況下這層薄薄的封裝只是在做一個“轉發”功能,因此不會帶來效能問題。
此時,新的Cache方法便非常直觀了:
public static void Cache( this HtmlHelper htmlHelper, string cacheKey, CacheDependency cacheDependencies, DateTime absoluteExpiration, TimeSpan slidingExpiration, Action action) { var cache = htmlHelper.ViewContext.HttpContext.Cache; var content = cache.Get(cacheKey) as string; var writer = htmlHelper.GetRecordWriter(); if (content == null) { var recorder = new StringBuilder(); writer.AddRecorder(recorder); action(); writer.RemoveRecorder(recorder); content = recorder.ToString(); cache.Insert(cacheKey, content, cacheDependencies, absoluteExpiration, slidingExpiration); } else { htmlHelper.Output.Write(content); } }
如果快取沒有命中,則我們會向RecordWriter中新增一個Recorder,然後再執行action委託,這樣action中的所有內容便會被紀錄下來。action執行完畢後,我們再摘除Recorder即可。現在Cache方法已經可用了,例如:
<%= DateTime.Now %> <br /> <% Html.Cache("now", DateTime.Now.AddSeconds(5), () => { %> <%= DateTime.Now %> <% }); %>
那麼,Html.Cache能否巢狀呢?答案也是肯定的。
<%= DateTime.Now %> <br /> <% Html.Cache("now", DateTime.Now.AddSeconds(5), () => { %> <%= DateTime.Now %> <br /> <% Html.Cache("inner_now", DateTime.Now.AddSeconds(10), () => { %> <% Html.RenderPartial("CurrentTime"); %> <% }); %> <% }); %>
外層快取塊5秒後過期,記憶體快取塊10秒鐘過期,因此在某一時刻(如第一次重新整理後7秒後),您會發現頁面上會出現這樣的結果:
2009/9/21 15:36:10 2009/9/21 15:36:08 2009/9/21 15:36:03
我們的RecordWriter支援同時擁有多個recorder,您可以根據上面得出的結果來理解內外層迴圈是以何種順序向RecordWriter新增Recorder的,這並不困難。
從程式碼中我們也可以發現,Cache塊內部也可以直接使用Html.RenderPartial。您也可以在Cache塊內部使用各種輔助方法,它們的結果會被一併快取下來。
不過它們還是有“前提”的,至於這個前提是什麼,我們下次在討論吧。如果您想先睹為快,可以關注MvcPatch專案。