上一篇文章中我们提出了了片断缓存的基本方式,也就是构建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( this HtmlHelper 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项目。