工作上遇到幾起 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 值就不同,即可確保使用者一定讀到新版。

如此已大致了解原理,但有兩點疑問:

  1. 更新 JS/CSS 後,需要重啟網站應用程式 v 值才會更新嗎?
  2. 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 壓縮。實驗開始!

  1. 初始 URL 如下:
    /bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1              
  2. jquery-1.10.2.js 結尾加上var t="Jeffrey";
    /bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1
    v 未發生任何變化,因為實際讀取的是 jquery-1.10.2.min.js,修改 .js 不發生影響
  3. jquery-1.10.2.min.js 加一個空白
    (function(e,t) 改成 ( function(e,t) (function 前方插入一個空白)
    /bundles/jquery?v=FVs3ACwOLIVInrAl5sdzR2jrCDmVOWFbZMY6g6Q0ulE1
    一樣沒有改變,推論空白或註解不影響 v 值
  4. jquery-1.10.2.min.js 增加一小段程式
    (function(e,t) 改 var t="Jeffrey";(function(e,t)
    /bundles/jquery?v=AhaCLni7VxBk8MOj_UILsTsQ_UHx-uhYLNlfOIqHpL41
    v 值改變
  5. 刪除 jquery-1.10.2.min.js
    /bundles/jquery?v=yLrUYw8wJsDVphfZd34hBbV8EDUqbXgqcJTvyPCUCRg1
    v 值跟一開始不同,推測此時改由 jquery-1.10.2.js 決定 v 值
  6. jquery-1.10.2.js 結尾加上//Comment Test
    })( window );
    //Comment Test
    /bundles/jquery?v=yLrUYw8wJsDVphfZd34hBbV8EDUqbXgqcJTvyPCUCRg1
    加註解不影響 v 值
  7. jquery-1.10.2.js 結尾加上var t="Jeffrey";
    })( window );
    var t="Jeffrey";
    /bundles/jquery?v=_LgVA5lYsIBF-Ewy4gYBtrCBcqipfPdNC1xBqwwYAMg1
    v 值改變
  8. 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

Post a comment