簡單策略讓前端資源實現高可用
前幾天有朋友問我,曾經在前公司裡使用過的前端資源高可用方案是怎麼做的。資源高可用聽起來應該是後端、運維同學的“分內之事”。但是前端資源的高可用並沒有那麼簡單,在當前複雜的網路環境下,你是指望使用者多重新整理幾次、還是期望使用者把Wi-Fi切換為4G,撞大運解決問題?獲客成本如此之高的今天,放棄使用者是不明智的。
想到許久沒有寫前端相關的文章了,決定在這裡簡單聊聊。希望能幫助到創業階段的公司和團隊。
在聊技術細節之前,我們先聊聊“什麼是前端資源高可用”。
資源高可用和前端有什麼關係?
前端資源高可用這個需求,對於“大廠”的同學來說應該很陌生。
因為對於大公司來說,有大量冗餘的雲主機資源可以為業務團隊提供,並且會配套一定規模的運維團隊。當監控系統發現線上出現資源不可用的情況時,系統能夠根據策略自動切換問題資源到備份資源,而有些不能自動切換的服務,則會有值守的運維同學,在第一時間手動進行切換,保障業務的高可用。
而小一些規模的創業公司就沒那麼幸運了,資源相對緊張,甚至沒有完善的監控措施,更別提配一隻相對完善的運維團隊了。
或許會有人認為,將靜態資源扔到 CDN 上後就一勞永逸了。然而現實世界中,網路環境十分複雜,相同主機在不同線路、不同地區、不同時間段的可用性和訪問質量是不同的,所以使用 CDN 不是解決這個問題的銀彈,但是同時使用多個 CDN 或許是當前階段比較通用的方案。
比如預設不同地域的使用者通過不同線路訪問網站,如果其中一條線路出現問題,那麼一部分使用者就無法訪問網站提供的服務。
這個時候,我們通常會使用切換請求資源伺服器的方法來解決問題,比如下面這樣。
當某條 CDN / 服務線路不正常的時候,我們可以通過切換域名來解決資源獲取不到的問題,但是別忘記一件很重要的事情:
域名生效需要時間、多地域生效週期漫長,在這個切換域名的時間視窗內,你的服務質量將會持續受到影響。
並且這個方案的資源切換動作通常會在後端進行,而此時頁面已經推送到使用者側,資源已經不可用,使用者需要重新整理後才有可能請求到新的資源地址,並且是在 DNS 能夠生效的前提下,我們知道很多流行的應用客戶端為了效能優化,都為資源(甚至包含頁面)設定了很長的有效期,可以說這個方案並不是一個很有效的方案。
所以,假設你採取類似這種方案,你必須確保下面四個條件都生效,才能達到你的目的:
- 你的監控系統發現了問題,並自動進行了資源切換。
- 你的業務負責人,發現了問題,並手動進行資源切換。
- 你成功切換了資源,並且 DNS 快速生效(網路層、客戶端層)。
- 你的使用者在你切換資源、DNS 生效後,恰如其分的重新整理了頁面,而不是直接離開。
聽起來是不是很魔幻。
那麼有沒有什麼簡單可靠的方案可以解決這個問題呢?
有,讓資源在前端層面進行自動切換。
方案簡介
通過在前端環境監聽資源載入錯誤資訊,並根據一定策略自動載入其他位置的資源,實現前端依賴的資源在前端(使用者側)進行自動切換,達到前端資源高可用的目的,減少因前端資源載入失敗而導致的服務不可用和使用者流失。
環境模擬
為了更直觀的演示方案如何生效,我這裡使用 Docker 做一個常見場景的模擬。
模擬多個網路
我們先建立一個 docker-compose.yml
,裡面包含下面的內容。
version: '3' services: web: image: ${NGX_IMAGE} 4 expose: - 80 networks: - traefik labels: - "traefik.enable=true" - "traefik.frontend.rule=Host:${MAIN_HOST}" - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}" volumes: - ./public/${MAIN_HOST}:/usr/share/nginx/html extra_hosts: - "${MAIN_HOST}:127.0.0.1" cdn1: image: ${NGX_IMAGE} expose: - 80 networks: - traefik labels: - "traefik.enable=true" - "traefik.frontend.rule=Host:${CDN_HOST1}" - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}" - "traefik.frontend.headers.customResponseHeaders=Access-Control-Allow-Origin:*" volumes: - ./public/${CDN_HOST1}:/usr/share/nginx/html extra_hosts: - "${CDN_HOST1}:127.0.0.1" cdn2: image: ${NGX_IMAGE} expose: - 80 networks: - traefik labels: - "traefik.enable=true" - "traefik.frontend.rule=Host:${CDN_HOST2}" - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}" - "traefik.frontend.headers.customResponseHeaders=Access-Control-Allow-Origin:*" volumes: - ./public/${CDN_HOST2}:/usr/share/nginx/html extra_hosts: - "${CDN_HOST2}:127.0.0.1" networks: traefik: external: true
可以看到,編排檔案裡面定義了一個應用網站,和兩個 CDN 服務,為了更接近真實場景。其中一個 CDN 和應用網站根域名相同、另外一個採取完全不同的域名,比如下面這樣。
# 預設使用的映象 NGX_IMAGE=nginx:1.15.8-alpine # 支援訪問的協議 SUPPORT_PROTOCOL=https,http # 主站點的域名 MAIN_HOST=demo.lab.io # 模擬根域名相同的CDN CDN_HOST1=demo-cdn.lab.io # 模擬根域名不同的CDN CDN_HOST2=demo.cdn2.io
將上面的內容儲存為 .env
,並將上面內容中的域名繫結到本地之後,執行 docker-compose up
,就可以開始實戰了。
模擬常規場景
執行 docker-compose up
之後,我們會看到 Docker 自動幫我們建立了幾個目錄。
./public ├── demo-cdn.lab.io ├── demo.cdn2.io └── demo.lab.io
我們在 demo.lab.io 目錄下建立 index.html
檔案,作為應用入口。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> <script src="assets/app.js"></script> </head> <body> </body> </html>
然後在 ./demo.lab.io/public/assets/app.js
建立一個指令碼檔案,隨便寫點什麼,模擬被載入的資源。
document.addEventListener('DOMContentLoaded', function () { var p = document.createElement('p'); p.innerText = 'script excute success.'; document.body.appendChild(p); });
當我們訪問 http://demo.lab.io/index.html
的時候,不出意外,將會看到 由指令碼輸出的 script excute success.
內容。
我們將 ./public/demo.lab.io/assets/app.js
複製到 ./public/demo-cdn.lab.io/assets/app.js
和 ./public/demo.cdn2.io/assets/app.js
中,模擬資源分發到 CDN 的場景。
最簡單的技術實現
先將上面請求的資源地址修改為“CDN”的地址,驗證一下“CDN”服務是否可用。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> <script src="//demo-cdn.lab.io/assets/app.js"></script> </head> <body> </body> </html>
然後通過刪除 ./public/demo-cdn.lab.io/assets/app.js
這個指令碼,模擬 CDN 資源失效的場景。
如果你的瀏覽器沒有奇怪的快取行為,你將會得到一個空白的頁面,以及一行報錯資訊:
default.html:8 GET http://demo-cdn.lab.io/assets/app.js 404 (Not Found)
如果碰到域名解析錯誤的場景下,我們會獲得另外一種錯誤資訊:
GET http://demo-cdn.lab.io/assets/app.js net::ERR_NAME_NOT_RESOLVED
這個時候,我們可以在頁面上做一些修改,讓它能夠在資源加載出錯的時候,將資源切換到另外一個 CDN 資源上,比如這樣:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> <script> function loadOthers(resource) { var script = document.createElement('script'); script.src = resource.src.replace('demo-cdn.lab.io','demo.cdn2.io'); document.head.appendChild(script); } </script> <script src="//demo-cdn.lab.io/assets/app.js" onerror="loadOthers(this)"></script> </head> <body> </body> </html>
再次開啟地址,你會發現頁面又正常了。
進階版本
上面場景,我們模擬了常規場景下前端自動切換資源的方式。
接下來我們來做一些小小的優化,讓指令碼載入支援更多的資源地址,達到更高的可用性。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> <script> function loadResource(links, fnSuccess, fnError) { var script = document.createElement('script'); script.onerror = function () { document.head.removeChild(script); fnError(); }; script.onload = fnSuccess script.src = links.shift(); document.head.appendChild(script); } function autoSwitch(resourceList) { var resource = resourceList.shift(); loadResource([resource], function (success) { console.log('loaded'); }, function (err) { console.log('load error') autoSwitch(resourceList); }); } </script> </head> <body> <script> var resourceList = [ 'http://demo-cdn.lab.io/assets/app.js', 'http://demo.cdn2.io/assets/app.js', 'assets/app.js', ]; autoSwitch(resourceList); </script> </body> </html>
上面的實現中,我們將資源載入寫的更加通用,並且添加了載入成功、失敗的回撥,以及額外做了一個自動切換資源的函式,並將頁面指令碼資源載入交給了指令碼去處理。
這個方案已經能夠解決多數場景下的問題了,但是如果你的資源之間存在依賴關係,又該怎麼處理呢?
結合資源載入器使用
我們以 AMD 模組規範為例,聊聊如何結合 requirejs 使用資源自動切換。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> <script src="assets/require-v2.3.6.min.js"></script> <script> function autoSwitch(resourceList) { var resource = resourceList.shift(); requirejs([resource], function (success) { console.log('loaded'); }, function (err) { console.log('load error') autoSwitch(resourceList); }); } </script> </head> <body> <script> var resourceList = [ 'http://demo-cdn.lab.io/assets/app.js', 'http://demo.cdn2.io/assets/app.js', 'assets/app.js', ]; autoSwitch(resourceList); </script> </body> </html>
將 requirejs 引入頁面,然後使用 requirejs
方法替換 loadResource
方法後,你會發現似乎一切沒有什麼不同。
但是你其實可以通過配置 requirejs.config
來讓資源在載入的過程中,將依賴資源先進行下載和初始化,舉兩個實際的例子:
requirejs.config({ map: { // 這是一個 hack 用法,具體含義參考官方 API 文件 '*': { 'http://demo.cdn2.io/assets/app.js': 'lodash' }, } });
requirejs.config({ shim:{ // 或者這樣宣告 'http://demo.cdn2.io/assets/app.js':{ deps:['vue'] } } });
當然,你也可以改造 autoSwitch
函式,自己動態維護依賴關聯。
其他的坑
講到這裡,資源自動載入幾乎講完了,但是實際上還存在一些額外的坑。
比如結合當前最流行的構建工具 webpack
使用,圖片資源是一次性寫死的,需要支援動態化。
17年的時候,我曾經提交了一個解決方案,有興趣的同學可以圍觀一下: https://github.com/soulteary/webpack-custom-plugin ,主要解決了 Not generating ouput with multiple entries 的問題。
最後
許多看似高大上的方案,本質其實都十分簡單。與其追求高大上的概念,不如靜下心來,踏實鑽研細節,思考技術到底該如何有效的服務業務、產生價值。
—EOF