代码改变世界

适合ASP.NET MVC的视图片断缓存方式(中):更实用的API

2009-09-21 15:49 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

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

相关文章