ASP.NET MVC ScriptBundle Cache 原理剖析
0 | 9,536 |
工作上遇到幾起 ASP.NET MVC ScriptBundle 機制在更新 JS 檔後卻讀到舊版內容的問題,沒搞清楚原理查起問題有些茫然,做功課的時間又到了。
依據官方文件(見 Bundle Caching 一節),@Scripts.Render("~/bundles/blah") 會被轉成 <script src="/img/loading.svg" data-src="/bundles/blah?v=FVs3…ulE1" type="text/javascript"></script>,並宣告長達一年的 Cache 有效期限。除非使用者強制重新整理(Ctrl-F5)或清除 Cache,省去由伺服器重新下載,有利提升效能。但啟用 Cache 後必須避免伺服器端更新瀏覽器還渾然不知的狀況,URL 後方的 v 參數就是關鍵。只要 JS 或 CSS 一更新, v 值就不同,即可確保使用者一定讀到新版。
如此已大致了解原理,但有兩點疑問:
- 更新 JS/CSS 後,需要重啟網站應用程式 v 值才會更新嗎?
- v 值如何決定?是時間標籤?還是檔案內容 Hash?
簡單,做個實驗不就知道答案了。
為求簡便,我直接用 /bundles/jquery 測試,ASP.NET MVC 專案在 BundleConfig.cs 預設宣告 bundles.Add(new ScriptBundle("~/bundles/jquery").Include("~/Scripts/jquery-{version}.js"));,我設計了如下 TestBundle.cshtml,用 JavaScript 直接顯示 <script> src 在網頁上:
@{
Layout = null;
BundleTable.EnableOptimizations = true;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>TestBundle</title>
@Scripts.Render("~/bundles/jquery")
</head>
<body>
<div>
<script>
document.write($("script:first").attr("src"));
</script>
</div>
</body>
</html>
Scripts 目錄下有 jquery-1.10.2.js 及 jquery-1.10.2.min.js,依據 ScriptBundle 運作原理,min.js 存在時將取用壓縮版,否則會由 js 壓縮。實驗開始!
- 初始 URL 如下:
/bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1
- jquery-1.10.2.js 結尾加上var t="Jeffrey";
/bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1
v 未發生任何變化,因為實際讀取的是 jquery-1.10.2.min.js,修改 .js 不發生影響
- jquery-1.10.2.min.js 加一個空白
(function(e,t) 改成 ( function(e,t) (function 前方插入一個空白)
/bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1
一樣沒有改變,推論空白或註解不影響 v 值
- jquery-1.10.2.min.js 增加一小段程式
(function(e,t) 改 var t="Jeffrey";(function(e,t)
/bundles/jquery?v=AhaCLni7VxBk8MOj_UILsTsQ_UHx-uhYLNlfOIqHpL41
v 值改變
- 刪除 jquery-1.10.2.min.js
/bundles/jquery?v=yLrUYw8wJsDVphfZd34hBbV8EDUqbXgqcJTvyPCUCRg1
v 值跟一開始不同,推測此時改由 jquery-1.10.2.js 決定 v 值
- jquery-1.10.2.js 結尾加上//Comment Test
})( window );
//Comment Test
/bundles/jquery?v=yLrUYw8wJsDVphfZd34hBbV8EDUqbXgqcJTvyPCUCRg1
加註解不影響 v 值
- jquery-1.10.2.js 結尾加上var t="Jeffrey";
})( window );
var t="Jeffrey";
/bundles/jquery?v=_LgVA5lYsIBF-Ewy4gYBtrCBcqipfPdNC1xBqwwYAMg1
v 值改變
- jquery-1.10.2.js 恢復原樣
/bundles/jquery?v=yLrUYw8wJsDVphfZd34hBbV8EDUqbXgqcJTvyPCUCRg1
恢復第 5 點的 v 值
想更進一步了解 v 值的由於,我追進 System.Web.Optimization Open Source。v 值來自 GetBundleResponse(BundleContext context).GetContentHashCode(),判斷是以 Script 壓縮後內容計算而得的 Hash 值,此與我們的觀察一致:
/*** Bundle.cs ***/
/// <summary>
/// Returns the full url with content hash if requested for the bundle
/// </summary>
/// <param name="context"></param>
/// <param name="includeContentHash"></param>
/// <returns></returns>
internal string GetBundleUrl(BundleContext context, bool includeContentHash = true) {
string bundleVirtualPath = context.BundleVirtualPath;
if (includeContentHash) {
BundleResponse bundleResponse = GetBundleResponse(context);
bundleVirtualPath += "?" + VersionQueryString + "=" + bundleResponse.GetContentHashCode();
}
return AssetManager.GetInstance(context.HttpContext).ResolveVirtualPath(bundleVirtualPath);
}
/*** BundleResponse ***/
internal static string ComputeHash(string input) {
using (SHA256 sha256 = CreateHashAlgorithm()) {
byte[] hash = sha256.ComputeHash(Encoding.Unicode.GetBytes(input));
return HttpServerUtility.UrlTokenEncode(hash);
}
}
/// <summary>
/// Returns a hashcode of the bundle contents, for purposes of generating a 'versioned'
/// url for cache busting purposes.
/// This is not used for cryptographic purposes, just as a quick and dirty way to
/// give browsers a different url when the bundle changes
/// </summary>
/// <returns></returns>
internal string GetContentHashCode() {
if (_contentHash == null) {
if (String.IsNullOrEmpty(Content)) {
_contentHash = String.Empty;
}
else {
_contentHash = ComputeHash(Content);
}
}
return _contentHash;
}
至於 GetBundleResponse(context),背後有個 Cache 機制。若 Cache 裡沒有要存取的內容,GetBundleResponse 會呼叫 GenerateBundleResponse() 並使用 UpdateCache() 將內容存入 Cache 供下次取用:
/// <summary>
/// Uses the cached response or generate the response, internal for BundleResolver to use
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
internal BundleResponse GetBundleResponse(BundleContext context) {
// Check cache first
BundleResponse bundleResponse = CacheLookup(context);
// Cache miss or its an instrumentation request (which we never cache)
if (bundleResponse == null || context.EnableInstrumentation) {
bundleResponse = GenerateBundleResponse(context);
UpdateCache(context, bundleResponse);
}
return bundleResponse;
}
關鍵來了,UpdateCache() 將內容存入 Cache 的同時,透過 VirtualPathProvider.GetCacheDependency() 產生相依於檔案的 CacheDependency 作為 Cache.Add() 相依物件參數。一旦檔案內容有所更動,該檔案關聯的內容便會從 Cache 移除,下次 CacheLookup() 存取時再重新讀檔壓縮打包,進而產生不同的 Hash 值:
/// <summary>
/// Stores the response for the bundle in the cache, also sets up cache depedencies for
/// the virtual files used for the response
/// </summary>
/// <param name="context"></param>
/// <param name="bundle"></param>
/// <param name="response"></param>
public void Put(BundleContext context, Bundle bundle, BundleResponse response) {
List<string> paths = new List<string>();
paths.AddRange(response.Files.Select(f => f.VirtualFile.VirtualPath));
paths.AddRange(context.CacheDependencyDirectories);
string cacheKey = bundle.GetCacheKey(context);
// REVIEW: Should we store the actual time we read the files?
CacheDependency dep = context.VirtualPathProvider.GetCacheDependency(context.BundleVirtualPath,
paths, DateTime.UtcNow);
context.HttpContext.Cache.Insert(cacheKey, response, dep);
bundle.CacheKeys.Add(cacheKey);
}
由以上的觀察與分析,結論如下:
ScriptBundle URL 透過 v 參數避免客戶端使用過期內容。v 值來自 min.js 或 js 檔案內容的 Hash(排除空白及註解),其背後以 Cache 機制提升效能,並使用 CacheDependency 偵測檔案改變(不需重啟或重新編譯),檔案更新時將刪除 Cache 以便下次重新讀取並產生不同的 v 值,確保不會誤用過時內容。
Comments
Be the first to post a comment