淺談第三方 script cache 與自動升級的作法

Standard

最近工作上遇到了「實作一段類似廣告嵌入的 JavaScript 程式碼(有點拗口,實際上就像是加入 Google Analytics 的追蹤碼那樣,動態產生 script tag,然後指定其 src 是某一段網址。),而該 script 於 server 端打了 expire max 的 cache,但又希望安裝該段 js 的網站不必更動 js 檔名(或 querystring)的原則下,能夠自動吃到最新修改版本的程式碼」的 issue,解票過程中嘗試了幾種方案,雖最後未採用此篇提到的「自動升級法」解決,不過覺得此解滿有意思的,特此記錄。

為什麼要做 cache

這邊先簡單提一些 cache 概念,以及為什麼我們要做 cache 這件事。

上網的過程其實是 Client 與 Server 的來回互動,Client(例如透過瀏覽器)向 Server 發出所要求的檔案名稱的 Request,其中 Request Header 包含許多東西,像是 User-Agent、Accept-Encoding、Cookie 以及本篇相關的 Cache-Control 等等;然後 Server 接到 Request 後,在 Server 上處理完畢後會回傳 Response 到 Client,一樣有 Header,以及 Response 內容本身(可能是 HTML 內容,或是 CSS、圖檔等等),其中 Header 也包含許多東西,Cache-Control、Expires、Last-Modified…等等。

雖然隨時代進步,已經不是好幾年前等一張圖載入要等上好幾十秒的網速了(噢,但那悲劇的 3G 網速還是很慢…),但我們還是希望盡可能提高網頁載入速度,帶給使用者更好的使用經驗,另一方面也省下了網站 Server 的負載與流量。為了提高網頁載入速度,cache 是眾多技巧之一。更多的技巧可以參考《高效能網站建置指南》(作者:Steve Souders, 出版社:歐萊禮)一書。

而 cache 簡單說就是「瀏覽器從自身的 cache 空間中取得要求的檔案內容、省下至 Server 端撈回檔案內容的這段傳輸成本」,不用去 Server 下載、直接從瀏覽器 cache(一般來說會存放在本機的硬碟)中取得,自然就很快了!

這會根據瀏覽器與伺服器雙方的設定有不同情況,瀏覽器送出的 http header 中的 cache 設定(主要包含 HTTP 1.1 規定的 Cache-Control,以及 HTTP 1.0 規定的 Pragma,也要看這個檔案的 If-Modified-Since、過期時間 等),以及 Server Response 回來的 Header(如果有設定的話,會有 Cache-Control/Pragma,以及 Expires 過期時間、Last-Modified 最後修改時間 等等)。

整個流程大致上是這樣的:先看瀏覽器本身有沒有要忽略 cache 設定(可能使用者把瀏覽器 cache 機制給關了,或是使用者按下 Ctrl + R 或 F5 強制重新整理、讓瀏覽器端發送 Cache-Control: max-age=0 的情況),若沒有忽略 cache,再看瀏覽器本機端有無 cache、檔案有無過期(看過期時間 Expires,如果未過期,直接從 cache 中取得就行了,這時 Status Code 會顯示 200 OK (from cache)),如果過期了,就發個 request 到 Server 端,比對瀏覽器 Header 的 If-Modified-Since 及 Server 返回 Header 的 Last-Modified 時間來決定是否要取回最新版本的檔案內容,要是比對其實過期了但檔案也沒更新版本,那就返回 Header 就好,Status Code 此時為 304 Not Modified,省下取回檔案內容的時間。

嗯,cache 原理「大致上」就是這樣子,當然還有很多細節沒講的呢。

打上 cache 可能遇到的問題與應對解法

回到主題,如果我們要實作一段提供給其他網站嵌入的 JavaScript 程式碼(可能以 URL 方式嵌入的),又為了省下傳輸流量而打上了 expire 時間很長的 cache,某天針對這段程式碼進行修改,那麼那些嵌入我們這段 JavaScript 片段 URL 的網站,除非使用者把 cache 給關了或是強制重整,不然使用的應該都還是舊版的程式碼 – 直到過期時間到了。

直覺的想法是修改那段 URL 網址,例如在最後面加上 querystring,像是 http://xxxxxx/ga.js?v=20131225001 這樣、或是乾脆修改檔案名稱(ga.js 改成 ga-v1.js,或是 Rails Asset Pipeline 會加上的 fingerprint),讓瀏覽器認為它是新的檔案而去跟 Server 端再要求最新內容。不過情境是你是這段 JavaScript 嵌入碼的提供者,不大可能去要求所有安裝這段程式碼的網站都乖乖地照著改齁。

好吧,那更簡單的作法是 – 過期時間乾脆別設那麼長,可能設個 30 分鐘或幾天就好,不過假設嘛,我們想追求完美、學台灣邪惡資方 costdown 到極致、硬是要把 expire 打上一段很長的時間,連去比對 If-Modified-Since & Last-Modified 的 request 都不想發,那該怎麼辦呢?

這個「自動升級法」是來自 Steve Souders 的 Self-updating scripts 這篇文章,就提供了相關的解法,這邊簡單做個翻譯,英文沒有很輪轉,有誤之處請不吝指正,另外若有更好的想法歡迎一起討論噢。

Self-updating scripts 譯文

使用 Page Speed 或 YSlow 分析你的網站,通常會因為第三方資源太短的 cache 時間而造成出乎意料的低分。第三方資源的開發者之所以使用較短的 cache 時間,是因為這樣能及時讓使用者獲得更新的內容(雖然這會造成主站的速度下降)。

Stoyan 跟我討論這個問題,並探究是否有方法讓「長時間的 cache」與「必要時能夠更新」這兩個需求同時達成。我們想出了一個簡單又可靠的解決方案,採用這個模式可以減少不必要的 HTTP request 傳輸,讓頁面載入更快、使用者體驗更佳,同時也能提昇 Page Speed 與 YSlow 的評分。

長時間的 cache 與修改 URL

Cache 是一項讓網站載入更快速的重要最佳實踐(若你已經對 cache 與 304 狀態碼很熟悉了,可直接跳到「自動升級」一節)。要達成 Cache 很簡單,只要針對目標資源對象、藉由 http response header 的 Cache-Control 設定較長過期時間。舉個例子,這段設定告訴瀏覽器「這個回應內容可被 cache 一年喔」(譯註:max-age 所指定的是秒數):

Cache-Control: max-age=31536000

但如果我們在一年到期之前想要更新內容怎麼辦?那些已經擁有舊版內容 cache 的使用者,在到期之前將不會取得最新版本的內容,以上面設定為例,需要長達一年才能確保所有的使用者都更新了。簡單的解法是:開發者去更改資源的 URL,通常是透過加入「指紋」(fingerprint)到路徑中,例如版本控制號、檔案時間戳記,或是校驗碼。
以來自 Facebook 的某個 script 為例:

http://static.ak.fbcdn.net/rsrc.php/v1/yx/r/N-kcJF3mlg6.js

若你每過一段時間去比對一些大型網站的資源檔案的 URL,你會發現它們的指紋(fingerprint)都不一樣。我們可以透過 HTTP Archive 這個網站來觀察 Facebook 這個主要的 JavaScript 的網址變化:

http://static.ak.fbcdn.net/rsrc.php/v1/y2/r/UVaDehc7DST.js (March 1)
http://static.ak.fbcdn.net/rsrc.php/v1/y-/r/Oet3o2R_9MQ.js (March 15)
http://static.ak.fbcdn.net/rsrc.php/v1/yS/r/B-e2tX_mUXZ.js (April 1)
http://static.ak.fbcdn.net/rsrc.php/v1/yx/r/N-kcJF3mlg6.js (April 15)

Facebook 針對這個 script 設定了一年的 cache 時間,所以當他們要修改 script 內容時,他們會更改這段 URL 來確保所有的使用者可以立即取得最新版本。對注重效能的網站來說,設定長時間的 cache 並更換 URL 是個常見的解法。不幸的是,這個方法對來自第三方資源而言並不適用。

無法更改的第三方資源

對網站自有資源來說,要讓使用者取得最新更新版本,修改資源檔 URL 是個簡單解法。網站管理者知道何時有升級版,又因為網站是自有的,所以可以自行更改資源檔的 URL。

但第三方資源就是另一回事了… 在大部分的情況下,第三方資源包含的是一段「引導啟動用的 script」(不知道怎麼翻譯較好,以下仍採原文的 bootstrap script),以 Tweet Button snippet 為例:

<a href="https://twitter.com/share" class="twitter-share-button" data-lang="en">Tweet</a>
<script>
!function(d,s,id){
  var js,fjs=d.getElementsByTagName(s)[0];
  if(!d.getElementById(id)){
    js=d.createElement(s); js.id=id;
    js.src="//platform.twitter.com/widgets.js";
    fjs.parentNode.insertBefore(js,fjs);
  }
}(document,"script","twitter-wjs");
</script>

網站開發者將這段程式碼片段複製到自己的網頁中。假設今天 widgets.js 有個緊急更新,Twitter 團隊也無法修改這段程式碼片段的 widgets.js 的 URL,因為他們並沒有修改「那些嵌入這段 script 的網頁」的權限。那麼通知所有的網站管理者「嘿,該更新囉」呢?也不是個好解法。也因為沒有辦法修改資源的 URL 的關係,所以 bootstrap script 通常都使用較短的 cache 時間,來確保使用者可以儘快得到更新。Twitter 的 widget.js cache 時間為 30 分鐘、Facebook 的 all.js 則是 15 分鐘,而 Google Analytics 的 ga.js 則為 2 小時,這都比 Google Developers 建議的設定 1 個月以上的過期時間來的短上很多。

有條件的 GET(Conditional GETs)對於效能的負面影響

不幸的是,這些 bootstrap script 的短時間 cache 對於網站效能而言有著負面影響。當程式碼片段的資源檔在 cache 過期後,它不是從瀏覽器 cache 中讀出內容,而是會對 server 發出一條新的 request,包含 If-Modified-Since 與 If-None-Match 的 request header。(譯註:因為偵測到過期了,所以發一條 request 去 server 檢查是否有更新內容也是相當合理的。)即便 response 結果是並不包含資源內容的 304 Not Modified,發出 request 後的往返時間仍會對使用者經驗有影響。影響的程度會依據 bootstrap script 是透過一般方式或是非同步方式載入的。

一般方式載入指的是使用 HTML:

<script src=""></script>

這樣載入有幾個負面影響:它會阻塞後續的 DOM 元素繪製(render),而且在舊版本的瀏覽器中,更會阻塞後續資源的下載。就算是 304 Not Modified 的 request 結果,上述情況仍會發生。

若使用非同步方式載入呢?就像是 widgets.js 的方式 – 負面影響被改善了!不過最大的缺點反而是 widgets.js 本身,它必須等到 304 Not Modified 的 Conditional GET 得到回應之後才會執行繪製(render)。這可能對使用者來說有點困擾,因為他們可能會看到那些非同步的 widgets 在整個頁面繪製(render)完畢後才一一呈現。

增加 bootstrap script 的 cache 時間、減少 Conditional GET 的 request 可以避免造成使用者經驗上的負面影響。但我們該如何增加 cache 時間,同時又在不更動 URL 的前提下,能夠讓使用者及時獲得更新內容呢?

自動升級的 bootstrap script

bootstrap script 的定義是一個第三方的 script,並且擁有無法被更改的 URL。我們想要指定這些 script 擁有長時間的 cache,因此它們便不會拖慢網頁速度,但我們同時也想要在有更新時讓 cache 能夠被更新至新版本。這邊有兩個必須解決的問題:第一個是在有更新時如何通知瀏覽器,第二個是如何替換 bootstrap script 的 cache 成新版本。

更新通知:
假設 bootstrap script 程式碼片段發了一些後續的 request 到第三方 server 中,送出信號(beacon)去要求動態資料。我們來把這個特性實做上去。在 Tweet Button 這個例子中,有 4 個後續的 request 發到 server 上,第一個是包含 HTML 文件的 iframe、第二個是包含 tweet 數的 JSON,以及另外兩個 1×1 的圖片信號(應該是為了登入用)。上述任何一個都能被用來觸發更新。關鍵是 bootstrap script 中必須包含版本號。這個版本號會被傳回到 Server 上,來偵測是否有新版本。

更新 bootstrap script 的 cache 內容:
這是比較棘手的部份。如果指定給 bootstrap script 一段較長的 cache 時間(就是這個實作的重點),我們又需要在它尚未過期前變更 cache 的內容。透過動態地重新要求 bootstrap script URL 只會得到從 cache 中讀出來的內容;那麼若是修改 URL(像是加入 querystring)再要求呢?這只會產生新的 cache,而非覆蓋掉原有 URL 時存下的 cache 版本。我們還可以透過 XHR – 修改 header 中的 setRequestHeader 來排除 cache 的情況,不過這個方式沒辦法在所有瀏覽器中成功運作。

Stoyan 想到一個解法:動態產生一個包含 bootstrap script 的 iframe,並重新載入該 iframe。當 iframe 被重新載入後,它會產生一個對 bootstrap script 的 Conditional GET request(即便 bootstrap script 有 cache 且尚未過期),然後 Server 會回應一個更新版本的 bootstrap script 來覆蓋掉瀏覽器 cache 中的舊版本。至此,兩個目標我們都解決了:長時間的 cache、同時保有需要更新時可以及時更新的方法。而且在 bootstrap script 真的有修改時,我們僅僅用了一條 Conditional GET request 來替換掉大量的 Conditional GET request(以本例來說就是每 30 分鐘的 widgets.js)。

範例

來看一下 自動更新範例(譯註:我測試的時候似乎無法正常載入 bootstrap.js),這個範例包含 4 個頁面。

第一頁:

第一頁載入下面這段包含 bootstrap.js 的程式碼片段:

(function() {
    var s1 = document.createElement('script');
    s1.async = true;
    s1.src = 'http://souders.org/tests/selfupdating/bootstrap.js';
    var s0 = document.getElementsByTagName('script')[0];
    s0.parentNode.insertBefore(s1, s0);
})();

你可以注意到這範例頁面架設於 stevesouders.com,但 bootstrap.js 是放在 souders.org,這是為了模擬存放於不同網域的第三方程式碼的情況下,並證明這樣也可以運作。

你的瀏覽器目前包含一份 bootstrap.js 的快取,expire 設定為一週,且包含了一個「版本號」(這邊是用時間戳記來表示)。舉個例子,在此頁中,bootstrap.js 的版本(時間戳記)是 16:23:53

bootstrap.js 的附帶作用是它會向伺服器要求 beacon.js,但 beacon.js 在此步驟中只會返回 204 No Content(沒有任何內容)。

第二頁:

第二頁再次載入了程式碼片段,用來確認我們設定的 cache expire 的確有生效。這次 bootstrap.js 是讀取自 cache,所以版本(時間戳記)理應是相同的,以本例來說就是 16:23:53

這邊同樣發出一個 beacon.js 的要求,但依然返回 204 No Content。

第三頁:

Watch!見證奇蹟的時刻到了!雖然 bootstrap.js 依然是讀取自 cache,所以你也可以發現版本(時間戳記)依然相同。但這次 beacon.js 返回的是「有升級版囉!」的通知。這是藉由 beacon.js 中的一段 JavaScript 達成的:(譯註:由第三方網站管理者 – 也就是你,在更新 bootstrap.js 後,要讓各個安裝這段 script 的網站可以取得更新,而透過修改 beacon.js 來達成這件事 – 你可以透過後端程式判斷回傳的版本號,視情況讓 beacon.js 輸出不同的內容,有更新就印出下面程式碼,若無,則返回 204 No Content 即可。)

(function() {
  var doUpdate = function() {
    if ( &amp;quot;undefined&amp;quot; === typeof(document.body) || !document.body ) {
      setTimeout(doUpdate, 500);
    }
    else {
      var iframe1 = document.createElement(&amp;quot;iframe&amp;quot;);
      iframe1.style.display = &amp;quot;none&amp;quot;;
      iframe1.src = &amp;quot;http://souders.org/tests/selfupdating/update.php?v=[ver #]&amp;quot;;
      document.body.appendChild(iframe1);
    }
  };
  doUpdate();
})();

iframe 的 src 指向 update.php,它的內容是:

    <html>
    <head>
    <script src="http://souders.org/tests/selfupdating/bootstrap.js"></script>
    </head>
    <body>
    <script>
    if (location.hash === '') {
        location.hash = "check";
        location.reload(true); // 譯註:這邊指定 true 參數,用以忽略 cache、強制瀏覽器發 request 至 server
    }
    </script>
    </body>
    </html>

update.php 的兩個關鍵:載入了 bootstrap.js、使 iframe reload 的程式碼。(譯註:location.reload(true) 中的 true 參數表示要強制重新整理)

在 location.hash 指定了一個字串用以避免無限重新載入(就是指 “check” 這個字串啦)。要理解這個順序的最佳方法就是觀看 waterfall 圖表(譯註:以 Chrome 來說,在 Dev Tools 中的 Network 頁籤中)。

newver.php 這個頁面讀取的 bootstrap.js 是從 cache 來的 (1)。

beacon.js 的內容包含一段「在 iframe 中載入 upload.php 並自 cache 讀取 bootstrap.js 」的 JavaScript (2)。

但當 update.php 被重新載入,會發出對 bootstrap.js 的要求,且伺服器會回傳更新版本、並覆蓋掉瀏覽器 cache 中的舊版本 (3)。(譯註:由於強制重新整理,會送出 Cache-Control: max-age:0 的 header,以向伺服器取得最新的內容)

第四頁:

最後一個頁面再次載入 bootstrap.js,但這次它讀取的是剛剛才存到 cache 中的新版本,擁有新版本的時間戳記,例如:16:24:17

結論

你可以發現這個方法是在使用者「下一次」訪問頁面、要求該資源檔時,才得到更新版本(與 app cache 的方法類似)。我們可在範例的第三頁看到這個情況:程式碼片段使用舊版本的 bootstrap.js、而新版本隨後被下載了。使用目前常見的「短時間 cache 搭配眾多 Conditional GET request」的方法可以立即獲得新版,然而,使用這個方式會讓那些剛好在過期時間(30 分鐘或 2 小時,或是更短)之內的使用者,在這段時間內就無法獲取更新版本了。相較而言,新方法能更即時有效的取得更新版本。

另一個選擇是:總是檢查更新。這可以透過 bootstrap script 附加並重新載入 update.php 的 iframe 來達成。不過缺點是會增加大量的 Conditional GET request,但好處是第三方程式碼的提供者就不用處理版本號的問題了。

你更可以把 update.php 當作清單(manifest)檔案使用,如同參考了 bootstrap.js 一樣,你也可以加入其他擁有長時間 cache 卻又需要更新瀏覽器中的 cache 版本的檔案。必須特別提出的是,update.php 不必是動態頁面,它可以是個擁有長時間 cache 的靜態頁。這份資源清單可被修改,列出那些需要被升級的資源。(透過傳入的版本號來判斷)

這個方法的另一個好處是不必對既有的程式碼做太多變動。作為第三方 script,你所要做的就只有加入這個自動升級方法:

  • 在 bootstrap script 中加入版本號。
  • 透過其他 request(只要是可回傳 JavaScript 的檔案)將版本號傳回 Server。
  • 當版本過期時,修改所要求的 request 目標檔案,讓它回傳一段會動態產生 iframe 的 JavaScript 程式碼。
  • 新增 update.php 頁,它引用了 bootstrap script。(以及那些你想要更新 cache 版本的檔案)
  • 增加 bootstrap script 的 cache 時間,就設個 10 年好了,不過就算設 30 分鐘到 1 週也有很大的幫助喔。

若你擁有自己的 bootstrap script 程式碼片段,我鼓勵你可以實作自動升級的方法,不僅載入更快、使用者體驗更佳,更減少了 server 的 request 數量。


後記

有點可惜的是作者提供的範例中 bootstrap.js 與 beacon.js 都失效了,所以我這邊自己重新實作了一次,我放在這個 GitHub repo 上面。你可以 clone 下來玩玩看,實際做一次比較好懂 :)

另外我的 Apache 將下面檔案設定了 Cache:

  • bootstrap.js
  • beacon.php
  • update.php

還是直接列一下流程吧!

我嵌入了 patw.twbbs.org 提供的 bootstrap.js 這個第三方 script,
bootstrap.js 一開始是 version 2:

  1. index.html 載入 bootstrap.js
  2. bootstrap.js (第一次載入, 200 OK,這時候是 version 2)
  3. bootstrap.js 內的 script 載入了 beacon.php?v=2 (發現沒有比 2 更新的版本,直接返回 204 No Content)
  4. 結束

此時,提供 bootstrap.js 第三方服務的開發者更新了 bootstrap.js 的內容,這時候版本號是 3:

之前來過的使用者再度進站,這時候它瀏覽器中的 cache 尚未過期:

  1. index.html 載入 bootstrap.js
  2. bootstrap.js (已經有 cache 了, 200 OK,直接從 cache 中取出,沒有發送任何 request,這個版本是 2)
  3. bootstrap.js 內的 script 載入了 beacon.php?v=2 (發現有比 2 更新的版本,也就是版本 3,返回載入 update.php iframe 的 script)
  4. update.php 載入
  5. update.php 中引入的 bootstrap.js (200 OK, from cache)
  6. update.php 被強制重整 (200 OK)
  7. 被強制重整後的引入,bootstrap.js 這時候從 server 取回最新內容 (不過注意到喔,這個新版本雖然蓋掉 cache 了,但也只是在 iframe 裡面而已)
  8. 使用者點到下一個頁面,這時候才真正執行了新版本(第 3 版)的 bootstrap.js

參考資料:
https://blog.othree.net/log/2012/12/22/cache-control-and-etag/
http://www.cnblogs.com/jecray/archive/2007/09/09/HttpWebCahce.html

發表迴響

你的電子郵件位址並不會被公開。 必要欄位標記為 *