用程序實現HTTP壓縮和緩存

    用Asp.Net開發Web應用時,爲了減少請求次數和流量,可以在IIS裏配置gzip壓縮以及開啓客戶端緩存。園子裏已經有很多文章介紹瞭如何在IIS裏開啓壓縮和緩存,但我想搞清楚該如何自己寫代碼來實現http壓縮或者緩存,這樣做的原因主要有下面兩點:

1.IIS的版本不同,啓用IIShttp壓縮的方式也不同,IIS7還好一些,但對於IIS6來說,稍微麻煩一點;

2.如果我把應用部署在虛擬空間上,是沒辦法去設置虛擬主機的IIS

     所以瞭解如何用程序實現http壓縮和緩存還是很有必要的。

實現壓縮:在.netSystem.IO.Compression命名空間裏,有兩個類可以幫助我們壓縮response中的內容:DeflateStreamGZIPStream,分別實現了deflategzip壓縮,可以利用這兩個類來實現http壓縮。

實現緩存:通過在responseheader中加入ETagExpiresLastModified,即可啓用瀏覽器緩存。

     下面我們創建一個小小的Asp.net Mvc2 App,然後逐步爲它加入壓縮和緩存。

     首先新建一個Asp.net Mvc2web application,建好後整個solution如下圖:

    clip_image002

實現緩存

     要緩存的文件包括jscss、圖片等靜態文件。我在上面已經提到了,要使瀏覽器能夠緩存這些文件,需要在responseheader中加入相應的標記。要做到這一點,我們首先要使我們的程序可以控制到這些文件的response輸出。用mvccontroller是一個不錯的方法,所以首先在Global.asax.cs中加入下面的路由規則:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    routes.MapRoute(
        "Default", // 路由名稱
        "{controller}/{action}/{id}", // 帶有參數的 URL
        new { controller = "Home", action = "Index", id = UrlParameter.Optional } // 參數默認值
    );
    routes.MapRoute(
        "Cache", // 路由名稱
        "Cache/{action}/{version}/{resourceName}",
        new
        {
            controller = "Cache",
            action = "Css",
            resourceName = "",
            version = "1"
        } // 參數默認值
        );
}

上面加粗的代碼增加了一條url路由規則,匹配以Cache開頭的url,並且指定了ControllerCache。參數action指定請求的是css還是jsresourceName指定請求的資源的文件名,versioncssjs文件的版本。加入這個version參數的目的是爲了刷新客戶端的緩存,當cssjs文件做了改動時,只需要在url中改變這個version值,客戶端瀏覽器就會認爲這是一個新的資源,從而請求服務器獲取最新版本。

可能你會有疑問,加了這個路由規則之後,在View中引用cssjs的方法是不是得變一下才行呢?沒錯,既然我要用程序控制jscss的輸出,那麼在View中引用jscss的方式也得做些改變。引用jscss的常規方法如下:

    <link href="../../Content/Site.css" rel="stylesheet" type="text/css" />
    <script src="../../Scripts/jquery-1.4.1.js" language="javascript" type="text/javascript"></script>

這種引用方式是不會匹配到我們新加的路由的,所以在View中,要改成如下的方式:

    <link href="/Cache/Css/1/site" rel="Stylesheet" type="text/css" />
    <script src="/Cache/Js/1/jquery-1.4.1" language="javascript" type="text/javascript"></script>

 

下面我們先實現這個CacheController。添加一個新的Controller,名爲CacheController,併爲它添加兩個Action:

using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    public class CacheController : Controller
    {
        public ActionResult Css(string resourceName, string version)
        {
            throw new System.NotImplementedException();
        }
        public ActionResult Js(string resourceName, string version)
        {
            throw new System.NotImplementedException();
        }
    }
}

 

添加的兩個ActionCssJs,分別用於處理對cssjs的請求。其實對css和對js請求的邏輯是差不多的,都是讀取服務器上相應資源的文件內容,然後發送到客戶端,不同的只是cssjs文件所在的目錄不同而已,所以我們添加一個類來處理對資源的請求。

Controllers下添加一個類,名爲ResourceHandler,代碼如下:

using System;
using System.IO;
using System.Web;
namespace MvcApplication1.Controllers
{
    public class ResourceHandler
    {
        private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(30);
        private string _contentType;
        private string _resourcePath;
        private HttpContextBase _context;
        public ResourceHandler(string resourceName, string resourceType, HttpContextBase context)
        {
            ParseResource(resourceName, resourceType, context);
        }
        public string PhysicalResourcePath { get; private set; }
        public DateTime LastModifiedTime { get; private set; }
        private void ParseResource(string resourceName, string resourceType, HttpContextBase context)
        {
            if (resourceType.ToLower() == "css")
            {
                _contentType = @"text/css";
                _resourcePath = string.Format("~/Content/{0}.css", resourceName);
            }
            if (resourceType.ToLower() == "js")
            {
                _contentType = @"text/javascript";
                _resourcePath = string.Format("~/Scripts/{0}.js", resourceName);
            }
            _context = context;
            PhysicalResourcePath = context.Server.MapPath(_resourcePath);
            LastModifiedTime = File.GetLastWriteTime(PhysicalResourcePath);
        }
        public void ProcessRequest()
        {
            if (IsCachedOnBrowser()) return;
            byte[] bts = File.ReadAllBytes(PhysicalResourcePath);
            WriteBytes(bts);
        }
        protected bool IsCachedOnBrowser()
        {
            var ifModifiedSince = _context.Request.Headers["If-Modified-Since"];
            if (!string.IsNullOrEmpty(ifModifiedSince))
            {
                var time = DateTime.Parse(ifModifiedSince);
                //1秒的原因是requestheader裏的modified time沒有精確到毫秒,而_lastModified是精確到毫秒的
                if (time.AddSeconds(1) >= LastModifiedTime)
                {
                    var response = _context.Response;
                    response.ClearHeaders();
                    response.Cache.SetLastModified(LastModifiedTime);
                    response.Status = "304 Not Modified";
                    response.AppendHeader("Content-Length", "0");
                    return true;
                }
            }
            return false;
        }
        private void WriteBytes(byte[] bytes)
        {
            var response = _context.Response;
            response.AppendHeader("Content-Length", bytes.Length.ToString());
            response.ContentType = _contentType;
            response.Cache.SetCacheability(HttpCacheability.Public);
            response.Cache.SetExpires(DateTime.Now.Add(CacheDuration));
            response.Cache.SetMaxAge(CacheDuration);
            response.Cache.SetLastModified(LastModifiedTime);
            response.OutputStream.Write(bytes, 0, bytes.Length);
            response.Flush();
        }
    }
}

 

在上面的代碼中,ProecesRequest負責處理對cssjs的請求,先判斷資源是否在客戶端瀏覽器中緩存了,如果沒有緩存,再讀取cssjs文件,並在header中加入和緩存相關的header,發送到客戶端。

在這裏有必要解釋一下IsCachedOnBrowser這個方法。你可能會質疑這個方法是否有存在的必要:既然瀏覽器已經緩存了某個資源,那麼在緩存過期之前,瀏覽器就不會再對服務器發出請求了,所以這個方法是不會被調用的。這個方法一旦被調用,那說明瀏覽器在重新請求服務器,再次讀取資源文件不就行了嗎,爲什麼還要判斷一次呢?

其實,即使客戶端緩存的資源沒有過期,瀏覽器在某些時候也會重新請求服務器的,例如按F5刷新的時候。用戶按了瀏覽器的刷新按鈕之後,瀏覽器就會重新請求服務器,並利用LastModifiedETag來詢問服務器資源是否已經改變,所以IsCachedOnBrowser這個方法就是用來處理這種情況的:讀出Request中的If-Modified-Since,然後和資源的最後修改時間做比較,如果資源沒被修改,則直接返回304的代碼,告知瀏覽器只需要從緩存裏取就行了。

下面在CacheController中使用這個ResourceHandler。先增加一個CacheResult的類,繼承自ActionReult

using System;
using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    public class CacheResult : ActionResult
    {
        private readonly string _resourceName;
        private readonly string _type;
        public CacheResult(string resourceName, string type)
        {
            _resourceName = resourceName;
            _type = type;
        }
        public override void ExecuteResult(ControllerContext context)
        {
            if (context == null)
                throw new ArgumentNullException("context");
            var handler = new ResourceHandler(_resourceName, _type, context.HttpContext);
            handler.ProcessRequest();
        }
    }
}

修改CacheController如下:

using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    public class CacheController : Controller
    {
        public ActionResult Css(string resourceName, string version)
        {
            return new CacheResult(resourceName, "css");
        }
        public ActionResult Js(string resourceName, string version)
        {
            return new CacheResult(resourceName, "js");
        }
    }
}

可以看到,由於version只是用來改變url更新緩存的,對於我們處理資源的請求是沒用的,所以我們在這兩個Action中都忽略了這兩個參數。

緩存的邏輯到這裏就完成大部分了,下面我們爲UrlHelper加兩個擴展方法,方便我們在View中使用。增加一個UrlHelperExtensions的類,代碼如下:

using System.Web.Mvc;
namespace MvcApplication1
{
    public static class UrlHelperExtensions
    {
        public static string CssCache(this UrlHelper helper, string fileName)
        {
            return helper.Cache("Css", fileName);
        }
        public static string JsCache(this UrlHelper helper, string fileName)
        {
            return helper.Cache("Js", fileName);
        }
        private static string Cache(this UrlHelper helper, string resourceType, string resourceName)
        {
            var version = System.Configuration.ConfigurationManager.AppSettings["ResourceVersion"];
            var action = helper.Action(resourceType, "Cache");
            return string.Format("{0}/{1}/{2}", action, version, resourceName);
        }
    }
}

version配置在web.configappSettings節點下。然後修改Site.Master中對cssjs的引用:

    <link href="<%=Url.CssCache("site") %>" rel="Stylesheet" type="text/css" />
    <script src="<%=Url.JsCache("jquery-1.4.1") %>" language="javascript" type="text/javascript"></script>

這樣,緩存基本上算是完成了,但我們還漏了一個很重要的問題,那就是css中對圖片的引用。假設在site.css中有下面一段css

body
{
    background-image:url(images/bg.jpg);
}

然後再訪問~/Home/Index時就會有一個404的錯誤,如下圖:

clip_image004

由於css中對圖片的鏈接採用的是相對路徑,所以瀏覽器自動計算出http://localhost:37311/Cache/Css/12/images/bg.jpg這個路徑,但服務器上並不存在這個文件,所以就有了404的錯誤。解決這個問題的方法是再加一個路由規則:

    routes.MapRoute(
        "CacheCssImage", // 路由名稱
        "Cache/Css/{version}/images/{resourceName}",
        new
        {
            controller = "Cache",
            action = "CssImage",
            resourceName = "",
            version = "1",
            image = ""
        } // 參數默認值
        );

這樣就把對~/Cache/Css/12/images/bg.jpg的請求路由到了CacheControllerCssImage這個Action上。下面我們爲CacheController加上CssImage這個Action

using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    public class CacheController : Controller
    {
        public ActionResult Css(string resourceName, string version)
        {
            return new CacheResult(resourceName, "css");
        }
        public ActionResult Js(string resourceName, string version)
        {
            return new CacheResult(resourceName, "js");
        }
        public ActionResult CssImage(string resourceName, string version)
        {
            return new CacheResult(resourceName, "image");
        }
    }
}

然後修改ResourceHandler類,讓他支持image資源的處理如下:

using System;
using System.IO;
using System.Web;
namespace MvcApplication1.Controllers
{
    public class ResourceHandler
    {
        ...
        private void ParseResource(string resourceName, string resourceType, HttpContextBase context)
        {
            if (resourceType.ToLower() == "css")
            {
                _contentType = @"text/css";
                _resourcePath = string.Format("~/Content/{0}.css", resourceName);
            }
            if (resourceType.ToLower() == "js")
            {
                _contentType = @"text/javascript";
                _resourcePath = string.Format("~/Scripts/{0}.js", resourceName);
            }
            if (resourceType.ToLower() == "image")
            {
                string ext = Path.GetExtension(resourceName);
                if (string.IsNullOrEmpty(ext))
                {
                    ext = ".jpg";
                }
                _contentType = string.Format("image/{0}", ext.Substring(1));
                _resourcePath = string.Format("~/Content/images/{0}", resourceName);
            }
            ...
        }
        ...
    }
}

再次訪問~/Home/Index,可以看到css中的image已經正常了:

clip_image006

到這裏,緩存的實現可以說已經完成了,但總覺得還有個問題很糾結,那就是在修改cssjs之後,如何更新緩存?上面的代碼中,可以修改web.config中的一個配置來改變version值,從而達到更新緩存的目的,但這是一個全局的配置,改變這個配置後,所有的cssjsurl都會跟着變。這意味着即使我們只改動其中一個css文件,所有的資源文件的緩存都失效了,因爲url都變了。爲了改進這一點,我們需要修改version的取值方式,讓他不再讀取web.config中的配置,而是以資源的最後修改時間作爲version值,這樣一旦某個資源文件的最後修改時間變了,該資源的緩存也就跟着失效了,但並不影響其他資源的緩存。修改UrlHelperExtensionsCache方法如下:

        private static string Cache(this UrlHelper helper, string resourceType, string resourceName)
        {
            //var version = System.Configuration.ConfigurationManager.AppSettings["ResourceVersion"];
            var handler = new ResourceHandler(resourceName, resourceType, helper.RequestContext.HttpContext);
            var version = handler.LastModifiedTime.Ticks;
            var action = helper.Action(resourceType, "Cache");
            return string.Format("{0}/{1}/{2}", action, version, resourceName);
        }

實現HTTP壓縮

在文章的開頭已經提到,DeflateStreamGZIPStream可以幫助我們實現Http壓縮。讓我們來看一下如何使用這兩類。

首先要清楚的是我們要壓縮的是文本內容,例如cssjs以及View(aspx),圖片不需要壓縮。

爲了壓縮cssjs,需要修改ResourceHandler類:

using System;
using System.IO;
using System.IO.Compression;
using System.Web;
namespace MvcApplication1.Controllers
{
    public class ResourceHandler
    {
        private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(30);
        private string _contentType;
        private string _resourcePath;
        private HttpContextBase _context;
        private bool _needCompressed = true;
        public ResourceHandler(string resourceName, string resourceType, HttpContextBase context)
        {
            ParseResource(resourceName, resourceType, context);
        }
        public string PhysicalResourcePath { get; private set; }
        public DateTime LastModifiedTime { get; private set; }
        private void ParseResource(string resourceName, string resourceType, HttpContextBase context)
        {
            if (resourceType.ToLower() == "css")
            {
                _contentType = @"text/css";
                _resourcePath = string.Format("~/Content/{0}.css", resourceName);
            }
            if (resourceType.ToLower() == "js")
            {
                _contentType = @"text/javascript";
                _resourcePath = string.Format("~/Scripts/{0}.js", resourceName);
            }
            if (resourceType.ToLower() == "image")
            {
                string ext = Path.GetExtension(resourceName);
                if (string.IsNullOrEmpty(ext))
                {
                    ext = ".jpg";
                }
                _contentType = string.Format("image/{0}", ext.Substring(1));
                _resourcePath = string.Format("~/Content/images/{0}", resourceName);
                _needCompressed = false;
            }
            _context = context;
            PhysicalResourcePath = context.Server.MapPath(_resourcePath);
            LastModifiedTime = File.GetLastWriteTime(PhysicalResourcePath);
        }
        public void ProcessRequest()
        {
            if (IsCachedOnBrowser()) return;
            byte[] bts = File.ReadAllBytes(PhysicalResourcePath);
            WriteBytes(bts);
        }
        public static bool CanGZip(HttpRequestBase request)
        {
            string acceptEncoding = request.Headers["Accept-Encoding"];
            if (!string.IsNullOrEmpty(acceptEncoding) && (acceptEncoding.Contains("gzip")))
                return true;
            return false;
        }
        protected bool IsCachedOnBrowser()
        {
            var ifModifiedSince = _context.Request.Headers["If-Modified-Since"];
            if (!string.IsNullOrEmpty(ifModifiedSince))
            {
                var time = DateTime.Parse(ifModifiedSince);
                //1秒的原因是requestheader裏的modified time沒有精確到毫秒,而_lastModified是精確到毫秒的
                if (time.AddSeconds(1) >= LastModifiedTime)
                {
                    var response = _context.Response;
                    response.ClearHeaders();
                    response.Cache.SetLastModified(LastModifiedTime);
                    response.Status = "304 Not Modified";
                    response.AppendHeader("Content-Length", "0");
                    return true;
                }
            }
            return false;
        }
        private void WriteBytes(byte[] bytes)
        {
            var response = _context.Response;
            var needCompressed = CanGZip(_context.Request) && _needCompressed;
            if (needCompressed)
            {
                response.AppendHeader("Content-Encoding", "gzip");
                using (var stream = new MemoryStream())
                {
                    using (var writer = new GZipStream(stream, CompressionMode.Compress))
                    {
                        writer.Write(bytes, 0, bytes.Length);
                    }
                    bytes = stream.ToArray();
                }
            }
            response.AppendHeader("Content-Length", bytes.Length.ToString());
            response.ContentType = _contentType;
            response.Cache.SetCacheability(HttpCacheability.Public);
            response.Cache.SetExpires(DateTime.Now.Add(CacheDuration));
            response.Cache.SetMaxAge(CacheDuration);
            response.Cache.SetLastModified(LastModifiedTime);
            response.OutputStream.Write(bytes, 0, bytes.Length);
            response.Flush();
        }
    }
}

 

加粗的代碼是修改的內容,並且只用了gzip壓縮,並沒有用deflate壓縮,有興趣的同學可以改一改。

爲了壓縮Viewaspx),我們需要添加一個ActionFilter,代碼如下:

using System.IO.Compression;
using System.Web;
using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    public class CompressFilterAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var response = filterContext.HttpContext.Response;
            HttpRequestBase request = filterContext.HttpContext.Request;
            if (!ResourceHandler.CanGZip(request)) return;
            response.AppendHeader("Content-encoding", "gzip");
            response.Filter = new GZipStream(response.Filter, CompressionMode.Compress);
        }
    }
}

然後爲HomeController添加這個Filter

using System.Web.Mvc;
namespace MvcApplication1.Controllers
{
    [HandleError]
    [CompressFilterAttribute]
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            ViewData["Message"] = "歡迎使用 ASP.NET MVC!";
            return View();
        }
        public ActionResult About()
        {
            return View();
        }
    }
}

這樣就可以壓縮View了。

最終的效果如下圖:

第一次訪問:

clip_image008

第二次訪問:

clip_image010

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章