站点静态资源优化合并解决方案

背景

 

站点经过几年的积累,一个页面上需要加载数十个脚本和样式文件(之后我们把脚本和样式统称为静态资源),在浏览器没有缓存的情况下如此之多的请求数不但会对服务器有比较大的压力(这个问题可以通过CDN或反向代理解决),而且页面呈现也会非常慢(我们知道脚本的下载会阻塞其它资源下载以及页面呈现)。由于一个站点往往页面数量很多,而且由于静态资源分布于页面/用户控件以及面板页等各种类型的页面,所以人工逐个页面修改合并耗时比较多。而且这些资源都是没有经过精简,包含大量注释和空格空行,我们还需要进行资源的压缩。基于这些原因,提出需求能否有一个自动化的框架来自动处理这些静态资源。调查了现有的一些框架,很多框架功能完善,但是有两个主要缺点:

1) 需要人工配置合并哪些资源为一个资源包,不能实现自动化的把每一个页面的脚本和样式分别合并为一个文件。

2) 需要大量的代码改动,删除现有的样式和脚本声明然后进控件的声明或配置文件的配置,很难通过工具自动替换相关的客户端标签。

因此,我们只有自己来制作一个具有4C功能的资源合并框架,实现合并(Combine)/Compress(压缩)/Compact(精简)和Cache(缓存)。

 

介绍

 

ResourceMerge框架有下列功能和特性:

1) 和其它框架不同,不需要把静态资源的声明归并到一处(比如一个ScriptManager来声明所有脚本),只需要原地替换。

1) 服务器控件的作用只是收集这些静态资源的信息,然后通过HttpModule在合适的时候把合并后的标签渲染到页面合适位置,比如脚本放在Form的底部,样式放在Head中或Form的头部。

2) 可以通过配置选择为某些页面或所有页面进行静态资源的合并/精简/Gzip压缩/浏览器缓存等等。

3) 所有的配置改动都不需要重启网站,在下一个请求到达之后新的配置生效。

在这里要补充说明几点:

1) 任何事情都是有利有弊的,我们知道如果静态资源数量很多(甚至重复)会增加请求数,增加页面打开时间,但是如果盲目合并为一个很可能会增加流量,假设有资源A/B/C,页面1和页面2分别用刀了AB和ABC,如果一个页面的资源合并为一个文件的话,页面1下载了AB,页面2下载了ABC,导致AB资源的流量重复下载了,在这种情况下最佳的做法是把AB合并为一个文件。我们的项目有特殊性,经过几年的积累,开发人员并不清楚需要怎么去合并,我们只能实现这种自动化的合并。

2) 本来我们的资源都是本地资源,这有两个坏处,一是不能通过CDN给Web服务器释放静态资源这块的访问压力,二是在请求静态资源的时候不必要的一些Cookie浏览器也会发动给服务器。之后我们进行了静态资源分离的项目,把所有静态资源分离到了cdn域名。其实对于我们静态资源合并来说这样的分离反而不是一件好事情,因为我们的Web服务器在读取静态资源的时候,如果是本地的文件会很快,如果是一个网络地址,那么读取会比较慢而且还有可能在网络不稳定的情况下出现读取不完整的情况,因此最好是在Web服务器上设置一些Host,把原先cdn域名的直接解析到我们静态资源服务器的内网IP地址上。

下面介绍一下基本的流程:

image

我们可以看到由于很多需要合并的文件来自于网络,我们的缓存情况变得复杂了点,如果一个来自于网络的文件更新了之后合并后的文件并没有改动可能因为以下几个原因:

1) Web服务器访问到的合并前的文件被CDN或反向代理缓存,我们只有等待缓存过期。

2) 合并后的文件服务端缓存没到期,我们可以通过MergeHandler.ashx?action=clear来清空缓存。

3) 合并后的文件又被前端的反向代理或CDN缓存,我们只有等待缓存过期。

4) 合并后的文件浏览器缓存没有到期,浏览器没有发出请求,我们可以清空浏览器缓存。

 

框架解析

 

ConfigProvider.cs 实现了把xml中的配置读取到实体中,并且实现文件依赖,一旦文件有改动,立即把过期开关打开,通知下一个请求来更新配置。

ConfigSet.cs 对应配置的实体,我们可以为全局应用一套配置,也可以为单独的页面应用一套配置。

CssMinifier.cs 精简样式文件。

JsMinifier.cs 精简脚本文件。

MergedResource.cs 内存中保存的有关和合并后的资源信息的实体。

RawResource.cs 服务器控件,声明静态资源的地址,是否合并等开关,这个控件并不会渲染合并后资源的标签,唯一作用只是在Init的时候收集声明信息并且写入内存。

MergeModule.cs 在页面Init的时候把合并后的标签写到页面合适的位置(如果能找到Page的Header的话就把样式写入Header否则加到Form的第一个控件),由于我们是在这里添加控件,因此需要保证页面上不能有类似<%=%>这样的代码,必须替换为<%#%>绑定。

MergeHandler.cs 这个Handler就是用来根据ID从内存中获取原始文件列表,然后进行合并/精简/压缩处理,并且把处理后的文件缓存到memcached中,最后打上浏览器缓存的头以合适的ContentType进行输出。同时这个Handler也提供了action=info和action=clear两个开关来查看合并后的文件列表(什么时候过期,保存在哪里)以及清空缓存这两个小功能。

MergeService.cs 提供了一些合并的主要功能,同时也会在这里处理样式文件中的@import(提到第一行)和@charset(删除)。

ResourceCache.cs 处理所有和缓存相关的功能。

UrlVariable.cs 支持在Url中使用$$appSettings.jspath$$的方式来引用AppSettings中的Value:<add key="jspath" value="images.5173cdn.com"/>。

 

配置

 

0) 引用ResourceMerge.Core.dll。

1) 配置web.config文件:

<?xml version="1.0"?>
<configuration>
  <appSettings>
    <add key="ResourceMergeConfigFilePath" value="~/Config/ResourceMerge.config"/>
    <add key="jspath" value="images.5173cdn.com"/>
  </appSettings>
  <connectionStrings/>
  <system.web>
    <pages>
      <controls>
        <add assembly="ResourceMerge.Core" tagPrefix="ResourceMerge" namespace="ResourceMerge.Core"/>
      </controls>
    </pages>
    <compilation debug="true">
    </compilation>
    <authentication mode="Windows"/>
    <httpModules>
      <add name="ResourceMerge.Core.MergeModule" type="ResourceMerge.Core.MergeModule, ResourceMerge.Core"/>
    </httpModules>
    <httpHandlers>
      <add path="MergeHandler.ashx" type="ResourceMerge.Core.MergeHandler, ResourceMerge.Core" verb="GET,HEAD" />     
    </httpHandlers>
  </system.web>
</configuration>

主要是配置Handler/Module,还有就是RawResource控件的配置,在appSettings中需要配置配置文件的地址,默认就是这个地址。

2) 配置配置文件,所有的配置都具有默认值,但是配置文件一定要存在,注意必须修改CacheKeyPrefix的值。

<?xml version="1.0" encoding="utf-8" ?>
<ResourceMerge>
  <Common>
    <!--输出合并后文件Handler地址,可以是一个相对路径也可以是绝对路径,默认为~/MergeHandler.ashx-->
    <MergeHandlerUrl>~/MergeHandler.ashx</MergeHandlerUrl>
    <!--服务端缓存键前缀,修改这个值能立即让服务端缓存失效,默认为ResourceMerge_,每一个网站必须设定不同的值,否则会看到其它网站的缓存或清空其它网站的缓存-->
    <CacheKeyPrefix>TestResourceMerge_</CacheKeyPrefix>
    <!--全局应用的ConfigSetName,默认为名为Default的配置,都是默认值-->
    <GlobalConfigSetName>s1</GlobalConfigSetName>
  </Common>
  <ConfigSets>
    <ConfigSet>
      <!--标识名-->
      <Name>s1</Name>
      <!--资源的字符集,需要和实际文件字符集对应否则可能出现乱码,默认为utf-8-->
      <Charset>utf-8</Charset>
      <!--是否进行样式文件合并,即使不合并也会重新调整资源的呈现位置,默认为true-->
      <IsMergeStyle>true</IsMergeStyle>  
      <!--是否进行样式文件最小化,比如去除空行和注释等,默认为true-->
      <IsMinifyStyle>true</IsMinifyStyle>
      <!--是否进行脚本文件合并,即使不合并也会重新调整资源的呈现位置,默认为true-->
      <IsMergeScript>true</IsMergeScript>
      <!--是否进行脚本文件最小化,比如去除空行和注释等,默认为true-->
      <IsMinifyScript>true</IsMinifyScript>
      <!--是否进行文件gzip压缩,如果web服务器或cdn已经开启了压缩,这里就不需要开了,默认为true-->
      <IsCompress>false</IsCompress>
      <!--是否从远程获取资源的时候支持gzip或deflate,默认为true-->
      <GetCompressedRemoteResource>false</GetCompressedRemoteResource>
      <!--是否把合并以及压缩后的文件保存在服务端内存中,默认为true-->
      <UseServerCache>true</UseServerCache>
      <!--是否为合并以及压缩后的文件开启客户端浏览器缓存,默认为true-->
      <UseClientCache>false</UseClientCache>
      <!--服务端缓存过期时间,单位是秒,默认为3600-->
      <ServerCacheDuration>30</ServerCacheDuration>
      <!--客户端缓存过期时间,单位是秒,默认为3600-->
      <ClientCacheDuration>60</ClientCacheDuration>
    </ConfigSet>
  </ConfigSets>
  <Pages>
    <Page Url="~/WebForm1.aspx" ConfigSetName="s2" />
  </Pages>
</ResourceMerge>

每一个配置项这里已经说明了,各个站点需要根据自己的需要来设置合适的缓存时间。

3) 由于合并后的文件并没有保存在文件中而是保存在memcached中,所以还需要配置memcached:

<?xml version="1.0" encoding="utf-8" ?>
<memcached-configuration xmlns="urn:memcached-configuration">
  <master>
    <memcached>
      <server address="192.168.135.11" port="11211" />
      <socket-pool minPoolSize="10" maxPoolSize="100" connectionTimeout="00:00:10" deadTimeout="00:02:00" />
    </memcached>
  </master>
</memcached-configuration>

4) 相关代码修改:

protected void Application_Start(object sender, EventArgs e)
{
    MergeService.Start(); 
}

protected void Application_End(object sender, EventArgs e)
{
    MergeService.Stop();
}

Global里面需要添加相关代码,最主要的是把所有用户控件/页面/母板页中声明脚本和样式的客户端tag修改为服务端tag(可以通过补充中提到的工具自动进行):

<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site1.master.cs" Inherits="ResourceMerge.DemoWebApp.Site1" %>

<%@ Register Assembly="ResourceMerge.Core" Namespace="ResourceMerge.Core" TagPrefix="cc1" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
    <asp:ContentPlaceHolder ID="head" runat="server">
    </asp:ContentPlaceHolder>
</head>
<body>
    <form id="form1" runat="server">
    <div>
  <script type="text/javascript" language="javascript" src="http://<%# System.Configuration.ConfigurationManager.AppSettings["jspath"].ToString() %>/PagesScript/www/DefaultNewV1.js" charset="gb2312" ></script>
        <ResourceMerge:RawResource ID="RawResource1" runat="server" Url="http://images.5173cdn.com/5173/css/base.css" />
        <ResourceMerge:RawResource ID="RawResource2" runat="server" Url="~/RESOURCE/StylesheetSample2.css" />
        <ResourceMerge:RawResource ID="RawResource4" runat="server" Url="~/RESOURCE/StylesheetSample3.css" />
        <ResourceMerge:RawResource ID="RawResource3" runat="server" Url="~/RESOURCE/SampleJavaSCRIPT.js"/>

        <ResourceMerge:RawResource ID="RawResource5" runat="server" Url="http://$$appSettings.jspath$$/5173/js/layer/layer31.js" />
        <ResourceMerge:RawResource ID="RawResource6" runat="server" Url="http://$$appSettings.jspath$$/js/common/floater/v1.js" />
        <ResourceMerge:RawResource ID="RawResource7" runat="server" Url="http://images.5173cdn.com/JS/JScript/PagesScript/www/consummateinfo.js"
            Charset="gb2312" />
        <ResourceMerge:RawResource ID="RawResource8" runat="server" Url="http://images.5173cdn.com/5173/js/newIndex/new_index.js" />
        <ResourceMerge:RawResource ID="RawResource10" runat="server" Url="http://images.5173cdn.com/5173/js/newIndex/toolTip.js" />
        <ResourceMerge:RawResource ID="RawResource12" runat="server" ResourceType="Style">
            .tradbutton { BORDER-RIGHT: #ff9900 0px solid; PADDING-RIGHT: 0px; BORDER-TOP: #ff9900
            0px solid; PADDING-LEFT: 0px; FONT-SIZE: 12px; BACKGROUND-IMAGE: url(http://images.5173CDN.com/Images/list_20070123.gif);
            PADDING-BOTTOM: 0px; MARGIN: 0px; BORDER-LEFT: #ff9900 0px solid; WIDTH: 62px; COLOR:
            #ff7800; PADDING-TOP: 2px; BORDER-BOTTOM: #ff9900 0px solid; HEIGHT: 20px; BACKGROUND-COLOR:
            #fff }
        </ResourceMerge:RawResource>
        <asp:ContentPlaceHolder ID="ContentPlaceHolder1" runat="server">
        </asp:ContentPlaceHolder>
        
            <asp:TextBox ID="TextBox1" runat="server"></asp:TextBox>
    </div>
    </form>
</body>
</html>

可以看到,即使是内联的样式或脚本也可以包裹在服务器控件之内,当然,这个时候我们就不要声明Url属性了。我们也可以编程方式添加资源:

protected override void OnPreInit(EventArgs e)
{
     for (int i = 1; i <= 3; i++)
    {
        ResourceMerge.Core.MergeService.AddResouece("~/RESOURCE/Script0" + i + ".js");
    }
    for (int i = 1; i <= 3; i++)
    {
        ResourceMerge.Core.MergeService.AddResouece("~/RESOURCE/Style0" + i + ".css");
    }

    ResourceMerge.Core.MergeService.AddResource(new ResourceMerge.Core.ResourceItem
    {
        ResourceType = ResourceMerge.Core.ResourceType.Style,
        Url = "~/RESOURCE/style.css",
    });
    base.OnPreInit(e);
}

这里要说明几点:

1) 如果路径中需要用到web.config中appsettings中的内容,可以这么写:

<ResourceMerge:RawResource ID="RawResource5" runat="server" Url="http://$$appSettings.jspath$$/5173/js/layer/layer31.js" />
<ResourceMerge:RawResource ID="RawResource6" runat="server" Url="http://$$appSettings.jspath$$/js/common/floater/v1.js" />

2) 页面中所有<%=%>必须替换为<%#%>,但是不需要在后台代码中写DataBind()。

3) 需要保留样式或脚本文件的charset声明,服务器控件其它属性如下:

public string Url { get; set; }

public bool? IsMinify { get; set; }

public bool? IsMerge { get; set; }

public ResourceType ResourceType { get; set; }

public string Charset { get; set; }

public string Content { get; set; }

public int RenderPriority { get; set; }

public RenderLocation RenderLocation { get; set; }

在配置文件中我们定义的是整个站点或某个页面的配置,在服务器控件中可以配置某个资源的Url,是否需要精简,是否需要合并,资源的类型(默认是Auto枚举,自动根据扩展名判断),字符集(默认是配置文件中配置的字符集),以及呈现的次序(数字越小越靠前)和呈现的位置:

public enum RenderLocation
{
    Auto = 0,
    Head = 1,
    FormTop = 2,
    FormButtom = 3,
}

(如果开启合并的话呈现位置配置无效)

这样就完成了所有的配置

 

补充说明

 

1) 如果使用服务端缓存的话,可以通过MergeHandler.ashx?action=info地址来查看缓存列表

image 

 

2) 我们知道,如果样式表中有@import,那么需要放在开始,因此合并后的样式中的所有@import会提到最前面,并且原先的地方会注释:

image 

并且我们可以观察顶部的毫秒数来判断这个文件是新生成的还是从缓存中获取的。

 

3) 可以使用提供的工具来一次性替换整个网站的客户端tag为服务端tag,也就是解决方案中的UIOA项目:

image 

选择路径后点击批量合成可以把整个站点下所有aspx/ascx/master文件中的客户端link/script/stype的tag替换为服务端tag,把<%=%>替换为<%#%>。

 

需要特别注意,工具只是机械性替换所有声明,我们需要手动对下列情况进行处理:

a) 对于有些框架性的文件,比如jquery,不需要合并(这个工具已经会自动判断了)。

b) 由于合并后的脚本在尾部声明,所以页面中穿插的内联脚本如果引用到某些函数的话会出错,那么这些脚本就不能合并,比如广告。

c) 一定要注意在替换标签的时候设置正确的charset,否则很可能出现乱码,如果合并的静态资源本身是动态生成的并没有扩展名,那么我们需要设置ResourceType枚举。

d) 如果有一些脚本包含动态内容,或者是不允许所有用户共用一份的话就不适合合并。

 

4) 由于很多脚本和样式都有错误,所以我们并没有使用yui mini,ajax mini等框架来最小化脚本和样式,只是用了最简单的算法删除了空行注释等内容。

 

常见错误

 

1) 样式丢失: 检查样式文件是否合并了,检查样式是否是通过@import引入的,框架是否把@import提到了最前面。

2) 脚本错误: 一般来说脚本错误大多数是由于部分脚本合并了,部分脚本没合并引起的。由于没合并的脚本调用了合并后的脚本文件中的函数,而合并后的文件比较大尚未下载完成,这个时候会发生错误。一般解决方法是把那个脚本设置为IsMerge=false不合并,或索性把外部的调用这个脚本的函数也进行合并,或就是通过setTimeout延迟这个脚本的执行。大多数的脚本错误都是因此次序问题而产生的,默认情况下合并后的脚本在Form的尾部输出,页面中如果有其它地方调用了其中的函数那么就会出错。

3) 偶尔产生上述错误: 我们知道,合并脚本的工作是Web服务器进行的,如果某个时刻网络不好,Web服务器正从网络上读取原始的静态资源,那么很可能产生下载不全的情况,然后Web服务器会设置一定时间的缓存,那么在这段时间内都会出现错误。这个时候可以让监控相关人员通过MergaHandler.ashx?action=clear来清空缓存重新生成即可。

 

所有代码在这里下载(包括框架/测试网站以及替换工具)。

posted @ 2010-05-14 16:48  lovecindywang  阅读(6914)  评论(2编辑  收藏  举报