用Asp.Net開發Web應用時,為了減少請求次數和流量,可以在IIS里配置gzip壓縮以及開啟客戶端緩存。園子里已經有很多文章介紹了如何在IIS里開啟壓縮和緩存,但我想搞清楚該如何自己寫代碼來實現http壓縮或者緩存,這樣做的原因主要有下面兩點:
1.IIS的版本不同,啟用IIS的http壓縮的方式也不同,IIS7還好一些,但對於IIS6來說,稍微麻煩一點;
2.如果我把應用部署在虛擬空間上,是沒辦法去設置虛擬主機的IIS的
所以了解如何用程序實現http壓縮和緩存還是很有必要的。
實現壓縮:在.net的System.IO.Compression命名空間里,有兩個類可以幫助我們壓縮response中的內容:DeflateStream和GZIPStream,分別實現了deflate和gzip壓縮,可以利用這兩個類來實現http壓縮。
實現緩存:通過在response的header中加入ETag、Expires或LastModified,即可啟用瀏覽器緩存。
下面我們創建一個小小的Asp.net Mvc2 App,然后逐步為它加入壓縮和緩存。
首先新建一個Asp.net Mvc2的web application,建好后整個solution如下圖:
實現緩存
要緩存的文件包括js、css、圖片等靜態文件。我在上面已經提到了,要使瀏覽器能夠緩存這些文件,需要在response的header中加入相應的標記。要做到這一點,我們首先要使我們的程序可以控制到這些文件的response輸出。用mvc的controller是一個不錯的方法,所以首先在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,並且指定了Controller為Cache。參數action指定請求的是css還是js,resourceName指定請求的資源的文件名,version是css或js文件的版本。加入這個version參數的目的是為了刷新客戶端的緩存,當css或js文件做了改動時,只需要在url中改變這個version值,客戶端瀏覽器就會認為這是一個新的資源,從而請求服務器獲取最新版本。
可能你會有疑問,加了這個路由規則之后,在View中引用css和js的方法是不是得變一下才行呢?沒錯,既然我要用程序控制js或css的輸出,那么在View中引用js和css的方式也得做些改變。引用js和css的常規方法如下:
<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();
}
}
}
添加的兩個Action為Css和Js,分別用於處理對css和js的請求。其實對css和對js請求的邏輯是差不多的,都是讀取服務器上相應資源的文件內容,然后發送到客戶端,不同的只是css和js文件所在的目錄不同而已,所以我們添加一個類來處理對資源的請求。
在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秒的原因是request的header里的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負責處理對css和js的請求,先判斷資源是否在客戶端瀏覽器中緩存了,如果沒有緩存,再讀取css或js文件,並在header中加入和緩存相關的header,發送到客戶端。
在這里有必要解釋一下IsCachedOnBrowser這個方法。你可能會質疑這個方法是否有存在的必要:既然瀏覽器已經緩存了某個資源,那么在緩存過期之前,瀏覽器就不會再對服務器發出請求了,所以這個方法是不會被調用的。這個方法一旦被調用,那說明瀏覽器在重新請求服務器,再次讀取資源文件不就行了嗎,為什么還要判斷一次呢?
其實,即使客戶端緩存的資源沒有過期,瀏覽器在某些時候也會重新請求服務器的,例如按F5刷新的時候。用戶按了瀏覽器的刷新按鈕之后,瀏覽器就會重新請求服務器,並利用LastModified或ETag來詢問服務器資源是否已經改變,所以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.config的appSettings節點下。然后修改Site.Master中對css和js的引用:
<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的錯誤,如下圖:
由於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的請求路由到了CacheController的CssImage這個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已經正常了:
到這里,緩存的實現可以說已經完成了,但總覺得還有個問題很糾結,那就是在修改css或js之后,如何更新緩存?上面的代碼中,可以修改web.config中的一個配置來改變version值,從而達到更新緩存的目的,但這是一個全局的配置,改變這個配置后,所有的css和js的url都會跟着變。這意味着即使我們只改動其中一個css文件,所有的資源文件的緩存都失效了,因為url都變了。為了改進這一點,我們需要修改version的取值方式,讓他不再讀取web.config中的配置,而是以資源的最后修改時間作為version值,這樣一旦某個資源文件的最后修改時間變了,該資源的緩存也就跟着失效了,但並不影響其他資源的緩存。修改UrlHelperExtensions的Cache方法如下:
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壓縮
在文章的開頭已經提到,DeflateStream和GZIPStream可以幫助我們實現Http壓縮。讓我們來看一下如何使用這兩類。
首先要清楚的是我們要壓縮的是文本內容,例如css、js以及View(aspx),圖片不需要壓縮。
為了壓縮css和js,需要修改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秒的原因是request的header里的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壓縮,有興趣的同學可以改一改。
為了壓縮View(aspx),我們需要添加一個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了。
最終的效果如下圖:
第一次訪問:
第二次訪問: