Hello World
Spiga

适合ASP.NET MVC的视图片断缓存方式(上):起步

2009-09-17 17:19 by 老赵, 15815 visits

说到网站性能优化,没有什么比“缓存”更重要了。即便是某些朋友口中念念不忘的“静态页”,说到底也只是缓存了整张页面内容而已。但是,显然这样大粒度的缓存策略,在如今“牵一发而动全身”的Web 2.0站点中几乎是无法使用的。试想,在Twitter中的某个名人被数十万人订阅,那么他发一条消息,难道此时网站要去修改数十万用户的静态页面?因此,我们需要粒度更小的缓存。而比“整页缓存”粒度小一号的缓存,便是所谓“视图片断缓存”了。

视图片断缓存非常重要,因为它缓存的也是页面内容,这表示它比更低级别的缓存更有效率,也比静态页等整页内容缓存的适用面要大得多。在ASP.NET WebForm模型中提供了控件级别的缓存,我们可以为控件标记输出缓存策略,这样控件便不会每次都完整执行一遍。当然这个策略还不够灵活,因为它缓存的最小单元是“控件”,而不是页面中任意的部分。因此我在一年多前提出了一个CachePanel,由它包装的页面内容都可以被缓存,无论其内部是控件还是普通输出的内容。在实际生产过程中,CachePanel起到了非常重要的作用,许多场景下只要在页面中包裹一个<ext:CachePanel runat="server" />,性能立即就有了质的飞跃。

只可惜,在如今ASP.NET MVC的时代无法直接使用CachePanel这样的服务器端控件。因为CachePanel需要服务器端代码的配合,而ASP.NET MVC中的页面只是“视图模板”,除了呈现之外就不应该有其他职责。因此,我们必须提出一种脱离于后端代码的“标记”方式,将视图中的内容片断进行随意地缓存。在RailsDjango中都有类似的特性,但ASP.NET MVC甚至在2.0的Road Map中还没有包含这一功能,于是我们只能自己动手丰衣足食。不过有了ASP.NET WebForm作为强大的视图引擎,加这样的功能简直是举手之劳:

public static class CacheExtensions
{
    public static string Cache(
        this HtmlHelper htmlHelper,
        string cacheKey,
        CacheDependency cacheDependencies,
        DateTime absoluteExpiration,
        TimeSpan slidingExpiration,
        Func<object> func)
    {
        var cache = htmlHelper.ViewContext.HttpContext.Cache;
        var content = cache.Get(cacheKey) as string;

        if (content == null)
        {
            content = func().ToString();
            cache.Insert(cacheKey, content, cacheDependencies, absoluteExpiration, slidingExpiration);
        }

        return content;
    }
}

我们为HtmlHelper增加了一个Cache扩展方法,接受一些缓存参数(缓存键,绝对过期时间,偏移过期时间),以及一个生成缓存内容的Func<object>委托。Cache方法的逻辑非常简单:首先根据缓存键来获取内容,如果存在则直接返回,否则即调用委托对象获得新内容,并将其放入缓存。这样在缓存命中的情况下,委托的开销便可以节省下来了。

例如,我们可以使用这样的代码进行测试:

Before Rendering:
<%= DateTime.Now %>

<br />

Rendering:
<%= Html.Cache("Now", null, DateTime.Now.AddSeconds(60), Cache.NoSlidingExpiration,
    () => { System.Threading.Thread.Sleep(5000); return DateTime.Now; }) %>

<br />

After Rendering:
<%= DateTime.Now %>

在实际情况中,我们是不会在代码中调用Thread.Sleep方法的,不过这里我们需要模拟一段开销,因此通过暂停当前线程来实现时间消耗。于是我们第一次打开页面:

Before Rendering: 2009/9/17 16:52:37 
Rendering: 2009/9/17 16:52:42 
After Rendering: 2009/9/17 16:52:42

从结果中可以看出,Before Rendering和After Rendering相差了5秒钟,这就是Thread.Sleep(5000)的效果。但是如果您在60秒以内再次刷新页面,便可以看到缓存的效果:

Before Rendering: 2009/9/17 16:52:55 
Rendering: 2009/9/17 16:52:42 
After Rendering: 2009/9/17 16:52:55

可以看出,Rendering阶段显示的还是刚才的时间,而Before Rendering和After Rendering是即时更新的。此外,由于Cache方法将Thread.Sleep(5000)的开销节省了下来,因此Before Rendering和After Rendering两个阶段打印出的时间完全相同。

怎么样,简单吧。不过您应该会感到疑惑,这不是我们想要的结果啊,我们想缓存的是页面上的一个片断,但是现在必须将被缓存的内容作为一个完整的字符串输出,那么我们又该如何实现呢?难道我们要这么写吗?

<%= Html.Cache(..., () => "<span style=\"color:red;\">" + Model.Title + "</span>") %>

当然不可能这样。如果只是这样的话,那么这个Cache的可用性毫无疑问会非常低。因此,我们还需要寻找更好的解决方案——关于这点,我们下次再聊。而目前的Cache方法,最方便的输出大端页面内容的做法则是将内容放在一个Partial View中,然后使用Html.Partial方法输出内容:

<%= Html.Cache(..., () => Html.Partial("MyPartialViewToCache")) %>

请注意,我们这里使用的是扩展后的Partial方法,而不是自带的RenderPartial。之前我们谈过WebForm页面的输出方式,而RenderPartial方法是直接向Response.Output输出页面内容,因此我们无法将其捕捉为一个字符串。不过,之前文章中的Partial方法是“山寨”版本,而符合“标准”的Partial方法实现已经包含在MvcPatch项目中。如果您感兴趣的话,可以获取它的源代码并编译。我这段时间在一部分一部分地将以前项目中较为通用的扩展及修改提取至MvcPatch中,希望可以使MvcPatch成为一个可复用的强大组件。

相关文章

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

48 条回复

  1. 温景良(Jason)
    *.*.*.*
    链接

    温景良(Jason) 2009-09-17 17:28:00

    占一下沙发,回家细细品味

  2. 老赵
    admin
    链接

    老赵 2009-09-17 17:31:00

    @温景良(Jason)
    这么早下班?

  3. 韦恩卑鄙
    *.*.*.*
    链接

    韦恩卑鄙 2009-09-17 17:32:00

    你没说下集是什么内容阿

  4. 老赵
    admin
    链接

    老赵 2009-09-17 17:38:00

    @韦恩卑鄙
    不是写着“关于这点,我们下次再聊”吗?不急不急,最晚周一就发布,现成的东西,只要整理下就好。

  5. 伊牛娃
    *.*.*.*
    链接

    伊牛娃 2009-09-17 17:50:00

    老赵

    2009-09-17 17:19

    在上班吗?
    我也去你们公司吧

    呵呵

  6. 温景良(Jason)
    *.*.*.*
    链接

    温景良(Jason) 2009-09-17 17:50:00

    Jeffrey Zhao:
    @温景良(Jason)
    这么早下班?


    还没下班,只不过看你的文章,我得找找资料才能看懂,不过也丰富我的知识,真心感谢一下老赵.

  7. 老赵
    admin
    链接

    老赵 2009-09-17 17:58:00

    @伊牛娃
    在上班,如果你愿意以我的工资标准以及我的工作时间来上班,我这里非常欢迎……

  8. iiduce[未注册用户]
    *.*.*.*
    链接

    iiduce[未注册用户] 2009-09-17 18:10:00

    @Jeffrey Zhao
    对您的工资标准和工作时间很感兴趣啊。。。

  9. 老赵
    admin
    链接

    老赵 2009-09-17 18:13:00

    @iiduce
    一天在公司12小时,工资微微高于复旦大学软件学院本科生平均工资……

  10. kyorry
    *.*.*.*
    链接

    kyorry 2009-09-17 18:16:00

    什么时候上的项目,MvcPatch
    MVC开发者有福音了

    你的快速反射为什么总是beta版本

  11. 老赵
    admin
    链接

    老赵 2009-09-17 18:19:00

    @kyorry
    gmail好像beta了5年,我打算也beta个3年左右吧。

  12. 支持[未注册用户]
    *.*.*.*
    链接

    支持[未注册用户] 2009-09-17 18:20:00

    asp.net 不用javascript能行吗? 学了C#还非要学习javascript才能用asp.net做网页吗?请博主帮忙回复。

  13. 老赵
    admin
    链接

    老赵 2009-09-17 18:23:00

    @支持
    我认为是客户端和服务器端是可以分离的,但是事实是,如果就你一个人,你不学javascript就作不出网站来。事实上,我不咋懂CSS,所以我也做不出。
    不过我找一个懂CSS的就够了,我觉得你做asp.net的话,了解些基本的吧。

  14. 假正经哥哥
    *.*.*.*
    链接

    假正经哥哥 2009-09-17 20:45:00

    此贴甚好

  15. 向世界出发
    *.*.*.*
    链接

    向世界出发 2009-09-17 21:14:00

    good, 好文,继续关注!

  16. jowo
    *.*.*.*
    链接

    jowo 2009-09-17 21:59:00

    不错,受教了,

  17. leebo[未注册用户]
    *.*.*.*
    链接

    leebo[未注册用户] 2009-09-17 23:58:00

    看了你的做过的webcast 来到这里的 很感谢你能给我们这些入门者一些指引 我会继续努力地 呵呵

  18. yankai
    *.*.*.*
    链接

    yankai 2009-09-18 09:52:00

    能介绍下你的MvcPatch么?

  19. 老赵
    admin
    链接

    老赵 2009-09-18 09:54:00

    @yankai
    等我准备好了再介绍,现在可以在几篇文章中出现它的身影。

  20. 上山打老虎
    *.*.*.*
    链接

    上山打老虎 2009-09-18 10:12:00

    Jeffrey Zhao:
    @iiduce
    一天在公司12小时,工资微微高于复旦大学软件学院本科生平均工资……



    这个搜不到啊?
    能是多少呢?

  21. 妖居
    *.*.*.*
    链接

    妖居 2009-09-18 10:28:00

    是不是用到了比如
    <% using(var cache = Html.BeginCache(xxx))
    { %>
    ...
    ...
    <% } %>
    的方法,在dispose的时候缓存其中的HTML呢?但是如何能够得到中间生成的HTML呢?或者只是缓存了通过HtmlHelper生成的HTML,比如给string写个扩展方法CacheIt(cache)什么的。遐想的,需要实践一下但是不知道思路是不是可行。

  22. 老赵
    admin
    链接

    老赵 2009-09-18 10:33:00

    @妖居
    呵呵别急。不过我的做法还是挺好用的,结果可以是这样:

    <% Html.Cache(..., () => { %>
    
        <div>
            <%= Model.Title %>
        </div>
    
        <% Html.RenderPartial("MyPartial") %>
    
        <% Html.Output.Write("Hello World") %>
    
    <% } %>
    
    Cache块里的各种写法都是允许的。

  23. 妖居
    *.*.*.*
    链接

    妖居 2009-09-18 10:36:00

    @Jeffrey Zhao
    哦?这样也可以?真是没有想到lamdba表达式还可以被截断然后获得输出的信息?

  24. 老赵
    admin
    链接

    老赵 2009-09-18 10:39:00

    @妖居
    Lambda表达式就是语法糖,asp.net页面是被编译的,各种C#语言特性都可以使用,平时我们不还在页面用for,foreach嘛。
    // 当然实现你说的“截断捕获输出信息”还是用了一点小技巧,不过表面的API是看不出来的……

  25. 妖居
    *.*.*.*
    链接

    妖居 2009-09-18 10:44:00

    @Jeffrey Zhao
    恩,这个我知道,只是从来没有想过还可以这样,所以就动了用一个IDispose来框住这些信息的想法。
    关注一下,看看你使用的是什么方法。现在一时我还想不出来。

  26. qmxle
    *.*.*.*
    链接

    qmxle 2009-09-18 11:58:00

    老赵,问一个低级问题呀:
    ASP.NET MVC的流程是要先读取数据库获取数据,再调用视图输出。但是如果已经获取了数据,使用缓存又有什么意义呢?难道就为了节省一点将数据应用到试图的时间?
    难道是在调用视图输出之前不应该访问数据。但是这样在视图输出的时候未命中缓存时又如何去数据库读取数据呢?

  27. 老赵
    admin
    链接

    老赵 2009-09-18 12:18:00

    @qmxle
    哪里低级了,呵呵,提得好啊。看个链接哦:
    http://www.cnblogs.com/JeffreyZhao/archive/2009/09/05/simple-over-complex.html

  28. qmxle
    *.*.*.*
    链接

    qmxle 2009-09-18 12:54:00

    @Jeffrey Zhao:
    多谢老赵点拨!

  29. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 14:17:00

    @Jeffrey Zhao
    这确实是个很有趣的话题,也是一个很矛盾的话题。
    我不知道老赵是怎么理解那个5秒的性能消耗的(只是这个过程,不一定是在这个例子里)?

    先说说我的看法。首先我觉得这个“5秒”在实际生产中如果真的能够成为“性能杀手”的话,那么说明这个环节本身就存在着很大的问题,缓存只是将问题掩盖而已。
    其一,这种消耗的产生可能源自和26#qmxle提出的那类问题,为了解决这样的问题,使用了延时的载入。这种做法在MVC中本身即是不可取的,Lazy使得数据再次由视图拉动,而不是业务逻辑层和Controller推动到Views中。关于这一点,其实之前版本的MVC中,使用RenderView()这个名称我觉得更贴切(包括现在的RenderPartial),Views是用来Render的,而不应该参与实质性的Execute。
    其二,这种消耗可能来自于极端复杂的Render过程(当然可能性很小)。一般来说,单纯的输出HTML如果构成了整个系统的大负担的话(去掉这个环节可以“大大提高”),这时候应该考虑分页或者分功能页了。

    另外对于文章开头举的Twitter的例子个人觉得用在这里并不很恰当,那种缓存更应当由Models层的数据缓存来担当(前提是不适用Lazy Load)。因为反正都不会整个页面缓存了(单总体上来说,细化到分、秒可能还是会有页面级缓存存在),只要有数据,这个里面的性能差别应该不会构成整个Request-Response环节的累赘。

  30. 老赵
    admin
    链接

    老赵 2009-09-18 14:24:00

    @SZW
    我的看法不太一样啊。
    的确Render本身是不消耗的,省下的还是数据获取的开销。fragment cache非常重要,实际应用价值很高。
    5秒是为了体现性能差距,而真实世界里是否合理就要看怎么用了啊,是一种优化,不是“掩盖”。
    我不觉得延迟载入是不合适的,因为这部分逻辑是Controller准备的,Controller也是表现层逻辑的一部分,包括表现层缓存的控制。
    事实上,Controller中还会有清除fragment cache的能力,这些都是需要的。
    缓存有多种级别,业务model有缓存,数据层有缓存,表现层也有。

    // twitter只是为了说明静态页的适用范围是很小的,没说它适合fragement cache,呵呵。

  31. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 14:42:00

    @Jeffrey Zhao
    有价值是肯定的,因为需求总是千变万化、出人意料的:)
    毕竟没有一个东西会绝对的好或者不好嘛。我只是想讨论一下实现这个价值背后的一些损失,是否值得。

    Controller负责“一部分”视图逻辑的处理确实可以接受,处理缓存也无可非议(在HttpContextBase下控制),但这个过程本身和“延迟”没有直接发生关系,而是一旦采用这样的缓存方法,就间接导致了“延迟”方法的应用(否则不管是否缓存,Controlloer还是会照例请求一次Models中的数据、逻辑)。
    所以表面上看使用这样的缓存(本质上我们还是可以近似认为是“(局部)页面级缓存”对吧,并非有实际挖掘价值的数据缓存),似乎并没有影响到Controller和Models的职责,但是间接地在Views中去请求Models中的东西,这种做法总是有违MVC本身的结构的,况且对VIews单独的测试也是不利的(类似有的Model充血过头的情况)。

    “Render本身是不消耗的,省下的还是数据获取的开销”这个也正是我想说的,如果数据到位了,剩下来的Render内优化的余地毕竟通常没有之前发生的一系列动作来的大。

    所以个人觉得Render中的优化更应该向开发人员倾斜。

  32. 老赵
    admin
    链接

    老赵 2009-09-18 14:52:00

    @SZW
    Model没有充血,只是被安排了一个延迟加载而已,这对View是透明的。
    也就是说,如果用上了fragment cache,View只要加上一个标记,其他不用改的。
    Model是没血的,“延迟”是由Controller安排的。

    例如,如果没fragment cache的情况下,Controller是这样准备Model的:

    new IndexModel { Prop1 = 1, Prop2 = GetProp2() };
    
    而有了fragment cache:
    LazyFactory
        .Prepare(new IndexModel { Prop1 = 1 })
        .Setup(m => m.Prop2, () => GetProp2())
        .Create();
    
    对于View来说,它还是老老实实访问Prop2而已,没有区别的。

    // 你说的View本身单独测试是指什么呢?是如何测试的呢?

  33. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 15:01:00

    @Jeffrey Zhao
    恩,不充血的做法可以接受。

    看了你的代码我又有了另外一个问题,这么做和缓存数据其实在效率上的区别也只是在“普通的”“MVC的”Render环节上,对吧?

    如果不充血的话,Views的单独测试就没啥问题了。
    具体操作方法有很多,其中最简单的是我们自己fake一些ViewData(ViewData.Model)数据,传入到ViewPage里面,避免了请求逻辑和数据,这样在测试环节就能把Models和Controller+Views分裂开来了(当然普通的做法不可能十分彻底,只限逻辑和数据,因为ViewModel中的数据还是需要Models提供模型的,除非把ViewData.Model也fake出来,但是有点杀鸡取蛋的感觉了)。所以说如果一旦充血,那么在测试的时候Models就一定要掺和进来了。

  34. 老赵
    admin
    链接

    老赵 2009-09-18 15:05:00

    @SZW
    不是阿,第二段代码中Prop2是延迟加载的,也就是说如果View中不访问Prop2的话,GetProp2方法是不会执行的,开销就节省下来了。
    我也测试View,也是准备一些Model给View去Render,由于延迟与否对View是完全透明的,所以测试方式并没有任何变化。

  35. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 15:12:00

    对啊,我说的效率差别的“对象”是:
    1、用你这样的fragment cache
    vs
    2、把ViewData中的已经经过逻辑分析处理的数据缓存起来

    他们相差的只是一个Render,确切的说不是整个Render,而是拼接这一部分的HTML的过程。

    恩,这样的做法对View的测试没啥损失,挺好:)

  36. 老赵
    admin
    链接

    老赵 2009-09-18 15:20:00

    @SZW
    我再详细说说吧。
    假如,业务逻辑中有一个GetValue方法,开销5秒钟。

    在没有fragment cache的时候,Controller里是这么写的:

    var model = new Model { Value = GetValue() };
    return View(model);
    
    而View是这样的:
    <%= Model.Value %>
    
    此时,Controller耗时5秒,View Rendering耗时0秒。


    那么在有fragment cache的时候呢?Controller是这么写的:
    var model = LazyFactory
        .Prepare(new Model())
        .Setup(m => m.Value, () => GetValue())
        .Create();
    return View(model);
    
    View是这样的:
    <% Html.Cache(..., () => { %>
    
        <%= Model.Value %>
    
    <% } %>
    
    由于Controller只是“配置”了延迟加载,并没有直接执行GetValue方法,因此永远只需要0秒钟。
    而在View Rendering时,如果cache miss,那么GetValue会执行,耗时5秒。
    如果Cache hit,那么GetValue不会执行,耗时0秒。

    因此,无cache时,耗时永远是5秒。
    有cache但cache miss,耗时也是5秒。
    有cache但cache hit,耗时是0秒。

    如果只是节省拼接HTML的时间,那么实在没什么价值啊。

  37. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 15:35:00

    @Jeffrey Zhao
    首先确定一点,你缓存的是HTML,而不是原始数据,对吧?

    你刚才的对比是在“用fragment cache”和“不用fragment cache”两种“极端”的情况下对比的,那么差别肯定会很大。

    我之前说的对比是另外一种情况:把数据缓存起来。

    这样的好处是ViewPage不会产生任何变化。“坏处”是和
    fragment cache相比,他需要每次都拼接HTML(但这个消耗可以忽略不计),而在Controller组装ViewDataModel的层面,这两种方法的效率几乎是相同的,都是“索引->(取数据->)返回数据”的过程。

    [PS:另外有一个相关方面的个人的“追求”就是(只是在某些项目中),View中外部渗入的东西,以及和Controller耦合的东西越少越好,尽量保持独立,总结起来是:使用最简单的输出方式、使用最简单的循环方式、最简单的判断方式、使用尽可能简单的HtmlHelper和UrlHelper扩展。这样在一些情况下,只要我自己写一个简单的视图引擎,我甚至可以把PHP,Ruby中的一套Views进过简单的修改就能挪过来用(抑或是别的ASP.NET MVC中的)。一旦耦合的东西多了,这种愿望就无法实现了。]

  38. 老赵
    admin
    链接

    老赵 2009-09-18 15:40:00

    @SZW
    Business,Data Access,Presentation,每一层都有缓存。
    这就是不同级别的缓存,各种方式各有使用场景,各有缺点,这点CachePanel文章里说过了,呵呵。

  39. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 15:46:00

    @Jeffrey Zhao
    这个例子的特殊之处在于,用fragment cache需要做的事、“破坏”(牵扯到的)的东西都比缓存ViewDataModel要多,而“和缓存ViewDataModel相比”,换取的效率上的提升并不是很可观。
    当然,我说的只是一般情况下,或许你有比较特殊的应用:)

  40. 老赵
    admin
    链接

    老赵 2009-09-18 15:52:00

    @SZW
    如果你domain上的缓存都做足够了,那么自然省不下多少东西了。
    但是事实并非如此,不会完全足够的。因此,每个平台都有fragement cache的支持,呵呵。

  41. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 16:08:00

    @Jeffrey Zhao
    我之前也说过了,没有什么方案会完全好或者不好,这种讨论也只是针对通常通常情况下的实用价值。
    如果ViewData对Views的(需要挖掘的)数据支持还不够的话,那么这个MVC就真有点别的味道了。
    等着看你下篇吧,哈哈。

  42. 老赵
    admin
    链接

    老赵 2009-09-18 16:12:00

    @SZW
    下篇也只是谈技术,不谈使用,呵呵。

  43. SZW
    *.*.*.*
    链接

    SZW 2009-09-18 16:15:00

    @Jeffrey Zhao
    上面这些都是杂谈,技术本身的价值以及能够推演到其他用途的才是最值得关注的:)

  44. Selfocus
    *.*.*.*
    链接

    Selfocus 2009-09-22 12:31:00

    timeout expired. The timeout period elapsed prior to completion of the operation or the server is not responding.

  45. doubleyou
    *.*.*.*
    链接

    doubleyou 2009-12-09 16:30:00

    喜欢读你写的东西,但好多提示有代码下载之类的地方都下不到或者没有内容 比如mvcpatch之类

  46. 老赵
    admin
    链接

    老赵 2009-12-09 16:40:00

    @doubleyou
    http://MvcPatch.codeplex.com 访问不了?

  47. 链接

    承志 2010-07-21 13:26:13

    能访问,不过我用VS2010打开MVCPatch,报了一个错误:Extensions.Tests缺了AssemblyInfo.cs没法编译通过。

    其他人没这问题吗?

  48. 幽灵
    116.22.166.*
    链接

    幽灵 2011-07-08 15:55:44

    第一千零一次经过此地,所以留个脚印哇哈哈,我要找的东东总是在你也儿找到

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我