先上靈魂拷問
歡迎轉載,轉載請標明文章出處來自字節架構前端
在文章之前,先拋一些靈魂拷問:
- 前端代碼從 tsx/jsx 到部署上線被用戶訪問,中間大致會經歷哪些過程?
- 上述過程中分別都有哪些考慮、指標和優化點,以滿足復雜的業務需求?
- 可能大部分同學都知道強緩存/協商緩存,那前端各種產物(HTML、JS、CSS、IMAGES 等)應該用什么緩存策略?以及為什么?
- 若使用協商緩存,但靜態資源卻不頻繁更新,如何避免協商過程的請求浪費?
- 若使用強緩存,那靜態資源如何更新?
- 配套的,前端靜態資源應該如何組織?
- 配套的,自動化構建 & 部署過程如何與 CDN 結合?
- 如何避免前端上線,影響未刷新頁面的用戶?
- 剛上線的版本發現有阻塞性 bug,如何做到秒級回滾,而非再次部署等 20 分鍾甚至更久?
- 如何實現一個預發環境,除了前端資源外都是線上環境,將變量控制前端環境內?
- 部署環節如何方便配套做 AB 測試等?
- 如何實現一套前端代碼,發布成多套環境產物?
- 如何實現按 feature 發布產物供用戶使用,並逐步擴大 feature 灰度,將影響減到最小(即線上同時存在多 feature 產物)?
- CDN 域名突然掛了,如何實現秒級 CDN 降級修補而非再次全部業務重新部署一次?
本文將會帶着這些問題,試着一起探索在2021年,系統化的前端部署解決方案。
PS:本篇關於靜態資源組織的問題&思路等,借鑒自知乎大佬張雲龍這篇回答 大公司里怎樣開發和部署前端代碼
靜態資源組織
一個簡單的頁面
先從簡單的靜態頁面開始,眾所周知,前端資源由 HTML
、JavaScript
、CSS
三劍客組成,假設我們有一個簡單的頁面,用Nginx
作為 Web 服務器,資源組織結構大概如下:
此時, 只需將 HTML
、JavaScript
、CSS
等靜態資源通過 FTP
等軟件,上傳到 Web
服務器(如 Nginx
)某目錄,將 Nginx
啟動做簡單配置即可讓用戶訪問。
用戶一訪問,狀態 200,頁面渲染出來,前端十分簡單,對不對?
利用緩存
但仔細觀察,用戶每次訪問都會請求 foo.css
, bar.css
等靜態文件,即使該文件並無變更。對帶寬甚是浪費,對頁面首屏性能等也有影響。於是在網絡帶寬緊張的互聯網早期,計算機先賢們在 HTTP
協議上制定了多種緩存策略。
瀏覽器緩存:瀏覽器緩存(
Brower Caching
)是瀏覽器對之前請求過的文件進行緩存,以便下一次訪問時重復使用,節省帶寬,提高訪問速度,降低服務器壓力。
協商緩存
一種策略是瀏覽器先問問服務器有沒有變化,沒變化就用舊資源。畢竟"問一問"的通信成本,遠小於每次重新加載資源的成本。大致流程如下:
協商緩存: 向服務器發送請求,服務器會根據這個請求的
Request Header
的一些參數來判斷是否命中協商緩存,如果命中,則返回304
狀態碼並帶上新的Response Header
通知瀏覽器從緩存中讀取資源;
此時,使用協商緩存后,Network
大致變成了這樣:
注:協商緩存一般可在服務端通過設置
Last-Modifed
、ETag
等ResponseHeader
實現。
注:304
狀態碼,表示資源未發生變更,可使用瀏覽器緩存。
強緩存
這樣,通過協商緩存,我們大幅優化了資源未變更時的網絡請求,節約大量帶寬,網站首屏性能也有不錯的提升,美滋滋!
然而仔細觀察,發現仍然有協商的過程,一百個靜態文件就有一百個協商請求。在資源未發生變更時,追求極致的我們也應該優化掉這個協商請求,畢竟沒有買賣就沒有傷害!
和協商緩存對應的是使用強緩存,大概過程如下:
強緩存:瀏覽器不會向服務器發送任何請求,直接從本地緩存中讀取文件並返回
Status Code: 200 OK
。
此時,強緩存的大致對話過程如圖:
注意,緩存生效期間,瀏覽器是【自言自語】,和服務器無關。
此時,設置強緩存后,Network 大致變成了這樣:
From DiskCache
:從硬盤中讀取。
From MemoryCache
:從內存中讀取,速度最快。
注:強緩存一般可在服務端通過設置Cache-Control:max-age
、Expires
等ResponseHeader
實現。
用上強緩存后,協商的請求也被消滅了,網站加載的性能達到極致了。美滋滋!
附錄:協商緩存和強緩存詳解
注:校招生或客戶端轉前端同學,關於強緩存/協商緩存的實現及使用先了解即可。
后續再熟練掌握。
緩存更新問題
鑒於頁面(index.html
)會頻繁更新,而靜態資源則相對穩定。所以,我們能推斷出的一種緩存策略是 index.html
適合走協商緩存,相對穩定 & 不常更新的靜態資源(JS
、CSS
、IMAGE
S) 等應該消滅協商請求,使用強緩存。
然而問題很快就來了,都不讓瀏覽器發請求,但緩存還未到期我們發現有 bug
,想更新 foo.css
怎么辦?
又想設置盡量長的時間走緩存,又想要能隨時更新?
又想馬兒跑又不給馬兒吃草?
相信大家很快就能得出一種思路,給資源加版本號!比如通過 query
加版本號,每次上線統一改版本號就搞定了。此時 HTML
變成如圖:
注意,此時服務器內只有一份文件
foo.css
文件。
統一加版本號的優點是簡單粗暴快捷,但缺點則是:假如我們只想更新 foo.css
,但 bar.css
緩存也失效了,又造成了帶寬的浪費。
大家應該很快就能想到辦法,需要將文件內容與版本號(URL)綁定,當文件內容發生變更時才變更版本號(URL
),這樣就能實現每個文件精確的緩存控制。
什么東西與文件內容相關呢? 消息摘要算法 ,對文件求摘要信息,摘要信息與文件內容一一對應,就有了一種可以精確到單個文件粒度的緩存控制依據。現在,我們把 URL
改成帶文件摘要信息的:
我們可以稱這種這個方式為 query-hash
,后續發版上線時,只有被變更文件的 URL
會更新,實現了精確的緩存控制,完美!
注意,此時服務器內只有一份文件
foo.css
文件。
覆蓋式發布引發的問題
然而假如我們就按上述部署方案就上了線,很快就會 Fatal 滿天飛,每次更新上線都可能會出現災難。
我們回顧一下,網站的靜態文件只有一份,部署在 Nginx
服務器某目錄下,並且通過 query-hash
的方式實現按文件做精確緩存控制,問題出在哪了呢?
回顧一下,我們某次更新時,更改了 foo.css
樣式,此時會將 HTML 中的foo.css url更新為最新的 hash,並將服務器中存儲的 foo.css & index.html 文件覆蓋為最新(V2版本),看似HTML和靜態資源都對應更新了,但是沒有考慮極端情況。那就是:
- 先部署靜態資源,部署期間訪問時,會出現V1版本HTML訪問到V2版本新靜態資源,並按V1-hash緩存起來。
- 先部署HTML,部署期間訪問時,會出現V2版本HTML訪問到V1版本舊靜態資源,並按V2-hash緩存起來。
如下圖所示,展示了不同版本HTML與不同版本靜態資源互相匹配到出現的異常Case。
綠色走向:正常訪問並建立緩存的路徑。
紅色走向:先部署靜態資源(V2),V1-HTML訪問V2靜態資源並緩存Case
黑色走向:先部署HTML(V2),V2-HTML訪問V1資源並緩存Case
對於問題1,會有兩種子Case:
- 用戶本地有緩存,此時無影響可正常訪問。
- 用戶本地無緩存,則會將V2版本靜態資源加載並按V1版本 hash 緩存起來。用戶報錯。當V2版本HTML部署完成后,用戶再次訪問時恢復。
對於問題2,則會出現嚴重的Case:
V2 版本HTML,會將V1版本靜態資源按V2版本Hash緩存起來。此時頁面會出錯,且緩存過期之前會持續報錯。直到用戶手動清除緩存,或者緩存過期,或者將來發布V3版本更新靜態資源版本。否則用戶會持續出錯。
上面方案的問題起源於靜態資源只有一份,每次發布時都是覆蓋式發布,導致頁面與靜態資源出現匹配錯誤的情況!解決問題方案也極其簡單,使用非覆蓋式發布,一種簡單的改造方式是將文件摘要(hash
)放置到URL
中,即將 query-hash
改為 name-hash
。
此時 HTML 變成如圖:
這樣,每次部署時先全量部署靜態資源,再灰度部署頁面,就能比較完美的解決了緩存的問題。
此時,服務器上會存在多份
foo.[$hash].css
文件
與 CDN 結合
現在我們開開心心將網站部署上線了,但我們此時仍然將靜態資源部署在 Nginx 服務器目錄下,然后新的問題來了,隨着時間推移,非覆蓋部署導致文件逐漸增加多,硬盤逐漸吃緊。而且將文件存儲在 Nginx
Web
服務器內某目錄下,深度的將 Nginx
、網站、部署過程等強耦合在一起,無法使用 CDN
技術。
CDN 是一種內容分發網絡,部署在應用層,利用智能分配技術,根據用戶訪問的地點,按照就近訪問的原則分配到多個節點,來實現多點負載均衡。
簡單來說,用戶就近訪問,訪問速度更快,大公司也無需搞一台超級帶寬的存儲服務器,只需使用多台正常帶寬的 CDN 節點即可。
而 CDN 的常見實現是有一台源站服務器,多個 CDN 節點定時從源站同步。
那如何將 CDN 與 Nginx 等 Web 服務器結合呢?
答案是將靜態資源部署到 CDN
上,再將 Nginx
上的流量轉發到 CDN
上,這種技術我們稱之為『反向代理』。
此時,用戶訪問時流量走向 & 研發構建部署過程大致如下:
此時,我們總體部署方案需要進一步做三步改造。
- 構建時依據環境變量,將
HTML
中的靜態資源地址加上CDN
域名。 - 構建完成后將靜態資源上傳到
CDN
。 - 配置
Nginx
的反向代理,將靜態資源流量轉發到CDN
。
其中,第 1、2 條涉及構建過程調整,以 Webpack
為例,我們需要做以下配置改造:
a. 配置 `output` 為 `content-hash` & `publicPath`
b. 配置 `Webpack-HTML-Plugin`
復制代碼
下面是一個配置示例:
// webpack.config.js const CDN_HOST = process.env.CDN_HOST;// CDN 域名 const CDN_PATH = process.env.CDN_PATH; // CDN 路徑 const ENV = process.env.ENV; // 當前的環境等等 const VERSION = process.env.VERSION; // 當前發布的版本 const getPublicPath = () => { // Some code here return `${CDN_HOST}/${CDN_PATH}/${ENV}/`;// 依據 ENV 等動態構造 publicPath } const publicPath = process.env.NODE_ENV === 'production' ? getPublicPath() : '.'; module.exports = { output: { filename: 'bundle.[name][contenthash:8].js', publicPath, }, plugins: [ new HtmlWebpackPlugin() ] }
備注1:我們往往會將一套代碼部署到多套前端環境,還需要在構建時注入當前部署相關環境變量(如staging
、prod
、dev
、pre
等),以便動態構建publicPath
。
備注 2:這里動態構造的 publicPath 里,嚴格的將產物按環境 + 發布版本做了隔離 & 收斂。 某業務前端曾將所有環境的靜態資源放到一起,以Hash做區分。但疑似出現了文件名 + hash 沖突,但文件內容不一樣,導致了線上事故。故牆裂建議嚴格對產物做物理隔離。
備注 3:publicPath
詳解webpack.docschina.org/configurati…
備注 4:此處使用了content-hash
,與hash
、chunkhash
的區別請見:詳解webpack中的hash、chunkhash、contenthash區別
備注 5:使用contenthash
時,往往會增加一個小模塊后,整體文件的hash
都發生變化,原因為Webpack
的module.id
默認基於解析順序自增,從而引發緩存失效。具體可通過設置optimization.moduleIds
設置為'deterministic'
。
具體詳見 webpack 官方文檔-緩存
注:關於 Webpack 的配置,校招生或客戶端轉前端同學,前期了解即可,后續建議深入學習。
- 構建完成后靜態資源上傳
CDN
源站
上傳 CDN
源站往往通過 CLI
調用各種客戶端工具上傳,此時要注意的是上傳 CDN
依賴配置鑒權信息(如 文件存儲的 Bucket Name/accessKey、ftp的賬號密碼)。這些鑒權信息不能直接寫代碼里,否則可能會有事故風險(想想為什么)!
第 3 步改造是 Nginx
層反向代理改造
反向代理(reverse proxy):是指以代理服務器來接受網絡請求,並將請求轉發給內部的服務器,並且將內部服務器的返回,就像是二房東一樣。
一句話解釋反向代理 & 正向代理:反向代理隱藏了真正的服務器,正向代理隱藏了真正的客戶端。
詳見:漫話:如何給女朋友解釋什么是反向代理?
Nginx
可通過設置 proxy_pass
配置代理轉發,如
location ^~/static/ {
proxy_pass $cdn;
}
具體詳見 nginx 之 proxy_pass詳解
注:校招生或客戶端轉前端同學,前期了解即可,后續建議熟悉 ~ 掌握。
靜態資源組織總結
最后,回顧一下
- 為了最大程度利用緩存,將頁面入口(
HTML
)設置為協商緩存,將JavaScript
、CSS
等靜態資源設置為永久強緩存。 - 為了解決強緩存更新問題,將文件摘要(
hash
)作為資源路徑(URL
)構成的一部分。 - 為了解決覆蓋式發布引發的問題,采用
name-hash
而非query-hash
的組織方式,具體需要配置Wbpack
的output.filename
為contenthash
。 - 為了解決
Nginx
目錄存儲過大 + 結合CDN
提升訪問速度,采用了Nginx 反向代理
+ 將靜態資源上傳到CDN
。 - 為了上傳
CDN
,我們需要按環境動態構造publicPath
+ 按環境構造CDN
上傳目錄並上傳。 - 為了動態構造
publicPath
並且隨構建過程插入到HTML
中,采用Webpack-HTML-Plugin
等插件,將編譯好的帶hash
+publicPath
的靜態資源插入到HTML
中。 - 為了保證上傳
CDN
的安全,我們需要一種機制管控上傳CDN
秘鑰,而非簡單的將秘鑰寫到代碼 /Dockerfile
等明文文件中。
簡直是層層套娃!
此時,我們已經基本獲得了一套相對完備的前端靜態資源組織方案。
此時你可能已經發現了,前端靜態資源部署后,還有被 Nginx 加工消費過程,才能被用戶訪問到。
自動化構建
現在我們已經探索出一套靜態資源組織的解決方案。現在探討一下構建的過程。我們每次構建時大約需要進行這些步驟:
- 拉取遠程倉庫
- 切換到 XX 分支
- 代碼安全檢查(非必選)、單元測試等等
- 安裝
npm/yarn
依賴- 設置
node
版本 - 設置
npm/yarn
源 - 安裝依賴等
- 設置
- 執行編譯 & 構建
- 產物檢查(比如檢測打包后
JS
文件 / 圖片大小、產物是否安全等,保證產物質量,非必選) - 人工卡點(非必選,如必須
Leader
審批通過才能繼續) - 打包上傳
CDN
- 自動化測試(非必選,
e2e
) - 配套剩余其他步驟
- 通知構建完成
這其中,迎面而來的問題有:
- 在什么環境執行構建?
- 如何保證每次構建部署環境相同?
- 由誰觸發構建?
- 如何管理前面所述上傳
CDN
等密鑰(不增加成本、保證安全、保證構建上傳可靠性)? - 如何自動化觸發構建 & 自動化執行上述步驟?
假如每次都由人工執行,估計發版日就守着編譯打包了,而且較為容易引發問題,比如某步驟遺漏或順序錯了。
- 如何提升構建速率?
- 構建完成如何通知研發同學構建完成了?
靈魂拷問有沒有!
為了解決上面問題,業界有一些解決方案:
- 保證環境一致性:Docker
- 按流程構建:Jenkins
- 自動化構建觸發:Gitlab webhook 通知
- 開始構建通知:依賴賬號體系打通+ Gitlab Webhook
- 構建完成通知:依賴賬號體系打通
業界的大致實現,一般都為 Jenkins + Docker + GitlabWebHook,比如下面是一些實踐:
前端項目自動化部署——超詳細教程(Jenkins、Github Actions)
iDeploy-為前端團隊構建部署工程化而開發的一個持續交付平台
此時還有一些其他問題:
比如宇宙最重物質 node_modules 安裝速度過慢的問題?
如何提升 Build 構建速
上述往往在各大公司都有相對完善的構建系統 & 解決方案等,各公司各不相同但大致類似,故本文跳過該步驟。
前端發布服務 - 預發環境、版本管理(秒級回滾)、小流量、灰度、AB測試
假定我們靜態資源組織完成,也搞定了自動化構建部署,也配好了 Nginx
的反向代理,我們的網站終於第一次上線了。
但第二次第三次上線怎么辦?直接發到生產環境做回歸測試的風險極大,但又不能本地部署前端測試環境去連接后端生產庫(可以想想為什么),所以我們需要一個預發(Pre
)環境,除了非測試人員訪問不到之外,其他所有環節都和生產環境保持一致!
此時遇到第一個需求,預發環境功能。
假如我們某個功能是元旦零點發布,跨年時守在服務器面前點發布?萬一 npm
抽風拉取依賴失敗導致構建失敗,或者上線后發現有bug,那就只能涼涼。
或者,隨着時間推移大家前端項目越積越大,node_modules
質量逐漸超越銀河系總質量,構建的時間往往會超過二十分鍾甚至更久。某天某次我們新上線了功能后,卻發現有致命阻塞性 bug
,收款后自動退款 1.5 倍!想立即回滾版本?那就且等着,大眼瞪小眼的等它慢慢編譯吧。這個時候才真的是時間就是金錢,再編譯慢點公司就破產啦。
此時有沒有一種辦法,能在發現問題后,立即將版本回滾呢?並且這個回滾操作,回滾的同學也不應該登陸服務器去做操作(想想為什么?)。
此時遇到第二個需求,版本管理功能。 即可提前將靜態資源上線,也需要保留每個歷史版本,並且能實現瞬間切換版本,且切換過程不應該登陸服務器操作(想想為什么)。
其次是,假定 PM 對功能不斷優化,想先灰度一部分用戶,或者想做一些 AB 測試,比如給廣東用戶推廣福建美食,給重慶用戶推廣缽缽雞。
此時我們有兩種方案,方案一是將把缽缽雞和福建美食都打包到一份代碼產物里,再在運行時根據地域做切換。但很快你的代碼產物里就有缽缽雞冷鍋串串熱鍋串串老媽兔頭蹺腳牛肉狼牙土豆以及福建美食等等,會串味兒的對不對?況且熱鍋串串和冷鍋串串打包混到一起我就第一個不同意,簡直是對美食的褻瀆!所以方案一不可取。
實際上,現實中往往會熱鍋串串冷鍋串串這樣完全不兼容的兩份改動同時在線上運行做 AB 測試。
方案二是我們將熱鍋串串和冷鍋串串分開打包,讓熱鍋不犯冷鍋。再設計一些機制,比如攜帶了香蕉糖果(cookie)的同學給蹺腳牛肉鍋,講港東話的同學福建美食鍋,四川地域的同學隨機給火鍋干鍋湯鍋魚火鍋。豈不樂哉?
大家應該很容易發現,這種機制是極其多變的,大概率朝令夕改。難道我們每次想調整干鍋、魚火鍋的比例,就要登陸服務器做調整?某天干鍋賣完了但又沒帶電腦回家怎么辦?
此時遇到第三個需求,隨時調整的小流量測試、AB-Test測試、灰度上線等等功能。
總結一下,為了滿足復雜的線上需求,在部署層面總體來說需要:預發環境、版本管理、小流量、灰度、AB測試等功能。
靜態資源的加工
如前所述,前端靜態資源部署到 CDN
后,有一道 Nginx
反向代理做轉發的加工工序。事實上,為了解決各種部署問題或為了提升性能,人們往往而需要對靜態資源做更多的加工工序。
比如,部分 Web
應用為了提升首屏性能,一種常見的方式為通過 BFF
層或通過后端直出 HTML
,並且在過程中注入若干信息,如 userInfo
、用戶權限信息、灰度信息等等,從而大幅降低前端登陸研發成本 & 降低首屏耗時。
下面是后端直出 HTML
的一種簡要流程。
主要流程為前端構建出的 HTML
包含若干模板變量,后端收到請求后,通過各種 Proxy
層將 Cookie
轉換成用戶信息,再按依據版本配置從 CDN
加載 index.html
, 並使用模板引擎等方式將模板變量替換為用戶信息,最終吐回給瀏覽器的則是已經包含用戶信息的 HTML
了!
Pre 環境、灰度上線的常見實現
如前所述,我們的靜態資源為非覆蓋式發布,多次部署后,線上存在若干版本靜態資源。實現Pre
環境/灰度上線的思路則是:通過一定的機制,讓特定用戶訪問特定靜態資源版本,從而達到訪問Pre
/灰度上線的能力。
方案一 Nginx 層動態轉發
一種常見的 Pre 機制是靜態資源部署多個版本后,開發者的通過 ModHeader 等瀏覽器插件,在請求中攜帶特定 Header(如xx-env=pre),在 Nginx 層消費該 Header 並動態轉發到對應環境的靜態資源上,實現訪問 Pre 環境目的。此時,除靜態資源為特定版本外,所有環境都是生產環境,可以將變量范圍控制在最小。
流程大致如圖:
Nginx 可通過配置 rewrite 設置轉發,如下所示。
詳情請查閱:nginx配置rewrite指令詳解
location /example { rewrite ^ $cdn/$http_x_xx_env/index.html break; proxy_pass $cdn/prod/index.html; } # $http_x_xx_env 表示取自定義的 Request Header 字段 xx_env
注:對於Nginx,校招生或客戶端轉前端同學,前期了解即可,后續建議熟悉 ~ 掌握。
該方案優點為配置簡單高效,適用於工程師。
缺點為每個用戶都需要手動配置,不適用於移動端,且無法讓特定用戶被動精確訪問某版本,比如 PM、KP 用戶來配置 Header 成本過高。
同理,也可以在 Nginx 層按一些其他規則處理,實現灰度上線的能力。
如通過一定隨機數 rewrite,達到小范圍隨機灰度。
獲取 ua 並 rewrite,達到按瀏覽器定向灰度。
通過 Nginx GeoIP 獲取地域信息,達到按地域灰度。
但上述灰度方案配置復雜,而灰度比例 / 范圍往往會配置較多,每次上線都需要運維登陸生產服務器修改,較容易出各種事故。故不推薦使用,僅供拓寬思路。
方案二 動態配置 + 服務端轉發
但 Pre 環境或灰度往往需要精確定位某些特定人群,如給特定PM、HR、遠端報錯的特定用戶、KP用戶 甚至給某個部門開 Pre環境等。上述同學工程背景相對缺失 / 較忙 / 通過移動端訪問,此時通過修改 Header 的方式不再適用。故我們仍然要尋找某種機制,達到能方便隨時調整 Pre/ 灰度范圍又不用重新發版上線。既然需要按用戶維度來定向,此時就依賴后端幫忙處理了。
而為了能隨時隨地調整灰度 / Pre 策略,而非依賴調整代碼發版上線,此時引入配置中心的概念。
配置中心:一般是獨立的平台 / SDK,提供動態配置管理的解決方案,提供功能有配置管理、版本管理、權限管理、灰度發布等等。后端應用通過接口消費,故配置中心和后端解耦,可以隨時修改調整配置而非重新發版。 配置中心一般是配置一個 JSON 對象。 配置中心JSON對象人工維護容易引發問題,故增加機器人來降低出錯幾率。
下圖是依賴配置中心 + 服務端轉發的流程圖:
主要流程為:
- 前端攻城獅同學部署多個版本靜態資源到 CDN 上(問題?如何管控多版本靜態資源?)。
- 后端收到請求后,通過各種 Proxy 層將 Cookie 轉換成用戶信息。
- 后端讀取配置中心數據,依據用戶信息判斷給用戶訪問什么環境,加載具體環境 index.html
- 后端返回給瀏覽器加工后的 index.html
- 若需添加具體 KP 等同學到 Pre 名單,攻城獅同學只需調用機器人/Bot 等,修改配置中心,即可生效。
注意,在上述架構下,若線上某用戶發生某些難以排查的問題,也可發布特定的版本,在配置中心修改后讓用戶訪問特定版本頁面,從而簡化排查問題的過程。
此時,一些小流量配置,AB實驗,版本管理其實也可以通過該方案實施。
該方案優點:可以隨時調整,不用后端發版,移動端也可生效。
該方案缺點:
- 和服務端強綁定(要求用戶信息,在所難免)。
- 每次都需要從
CDN
加載HTML
, 有一定性能浪費。但若緩存HTML
,發版環節還要通知服務端,總體增加復雜度。 - 若考慮
CDN
故障,服務端做CDN
降級會增加復雜度。 - 版本管理 / 小流量等為通用需求,而該方案每個后端應用都需要開發或接入。
- 常見的配置中心又一般為
JSON
配置,比較簡陋,和發版的多環境無法關聯,依賴人為配置,有出錯的風險(如發版v1.2501
,配置中心手動配置時手誤改成了v1.2051
)。
前端發布服務實現與設計
可能部分同學對線上產物實行版本管理會誤理解對代碼增加版本管理(如發版后手動 / 自動打Tag),后續需要時再次發版部署即可滿足需求。但如前所述,通過源碼做版本管理靈活性較差,無法做到一鍵 &秒級切換版本,不滿足商業化環境多變 & 復雜的需要。
那么如何進行版本管理呢?答案是對構建產物進行深層次加工 & 管理。
與此同時,版本管理/小流量是前端部署的常見公共業務需求,應該和業務后端服務脫離,故這里提出一個新的公共服務,純用於前端部署相關,此處將之稱為 Page Server
,用於具體的 index.html
文件管理 & 承接 Nginx
流量或業務后端流量等。
同時,鑒於版本管理、小流量策略等調整會特別頻繁,每次調整不應該都登錄服務器,故我們需要一個新的服務 & 界面,用於操作管理版本、調整小流量等信息,並且與上述 Page Server
同步,此處我們將該服務稱之為 Page Config Web
。
而我們的 Page Server
則可能會有很多個實例,部署在多個集群上,以滿足跨國部署、多部門項目部署等要求。所以理想情況下 Page Config Web
****還要承接 PageServer
的創建、管理、配置等工作。所以 PageConfigWeb
與 PageServer
是 1 比 N 關系(或M比N,用於跨國部署等)。
同時,我們一個前端項目可能有多套前端環境,PageSever
在固定集群算公共設施,這些環境理論上都可以由一個或多個 PageServer
承載。故一個 PageServer
和多個前端環境是 1 比 1 或者 1 比 N 關系。
此時,對於 Nginx 來的流量,我們需要一種機制來區分該流量屬於哪個環境實例,比如通過 URL 來區分,我們可以稱之為 路由。
最后,為了保證上述服務的正確性和自動化,構建部署(新增版本)完成后,要同步到上述兩個服務,以確保版本管理的正確性。
最后,大致的流程圖如下:
超大圖預警
本質上來說,相當於有一個公用的中間服務,部署在多個集群上,與構建發布過程深度綁定,用於承接HTML 的流量,並通過 Web
站點設置小流量規則、版本等等,來滿足多變的上線需求。
其中,PageServer
在承載 HTML
服務時,可做一些其他工作,比如:
- SSR
- CDN 降級,用於 CDN 異常時直出 HTML 中將靜態資源替換為可用的 CDN 站點。
- 404 處理
- 兜底頁(比如服務出現故障,短時間內無法修復時出兜底)
- 模板渲染(如做模板替換,將 query 替換到模板中等)
- 特殊時期全局處理,如注入全局樣式將頁面全局置灰
等等等等。
PageConfig Web 和 PageServer 中有構建后的所有版本信息,理論上可以緩存每個版本的 HTML文件,並且為了優化性能,PageServer 中可將最新全量版本的 HTML 文件緩存到內存中,最大程度提升響應速度,其余版本存儲到 Redis 等緩存中。
下面以發布一個正式版本 v.1.0.2502 並且回滾過程為例:
- 代碼合並,觸發自動化構建,構建產物以環境(env)+版本(env) + 版本(env)+版本(version) + name-hash 方式組織,並上傳到 CDN。
- 構建完成后,構建腳本通知攻城獅同學、同步 PageServer、PageConfig Web 服務有新版本 v.1.0.2502 。
- 攻城獅同學收到通知后,到 PageConfig Web 站點發布新版本 v.1.0.2502 (PRE),並為該版本配置 PRE 環境小流量規則,
xx-env = pre
。此時,只有設置特定 Header 才能訪問該版本。 - 若是 Nginx 直接轉發,則攻城獅通過設置 Header 訪問 PRE 版本。
- 若是通過服務端轉發,攻城獅通過配置中心設置 PRE 白名單,即可讓用戶訪問 PRE 版本。
- 在 PRE 版本驗收完成后,攻城獅登錄 PageConfig Web 站點,發布正式版本 v.1.0.2502 (不帶小流量信息)。此時立即生效。
- 生效后線上回歸,發現有 bug,攻城獅立馬登錄 PageConfig Web 站點,將版本回滾為上一版本v.1.0.2501 。此時立即生效。
關於部署的總結
靜態資源組織部分
- 為了最大程度利用緩存,將頁面(HTML)設置為協商緩存,將 JavaScript、CSS 等設置為永久強緩存。
- 為了解決強緩存更新問題,將文件摘要(hash)作為資源路徑(URL)構成的一部分。
- 為了解決覆蓋式發布引發的問題,采用 name-hash 而非 query-hash 的組織方式,具體需要配置 webpack 的 output.filename 為 contenthash 方式。
- 為了解決 Nginx 目錄存儲過大 + 結合 CDN 提升訪問速度,采用了 Nginx 反向代理+ 將靜態資源上傳到 CDN。
- 為了上傳 CDN,我們需要按環境動態構造 publicPath + 按環境構造 CDN 上傳目錄並上傳。
- 為了動態構造 publicPath 並且隨構建過程插入到 HTML 中,采用 Webpack-HTML-Plugin 等插件,將編譯好的帶 hash + publicPath 的靜態資源插入到 HTML 中。
- 為了保證上傳 CDN 的安全,我們需要一種機制管控上傳 CDN 秘鑰,而非簡單的將秘鑰寫到代碼 / Dockerfile 等明文文件中。
自動化部署部分
為了提升部署效率,100% 避免因部署出錯,需要設計 & 搭建自動化部署平台,以 Docker 等保證環境的一致性,以 Jenkins 等保證構建流程的串聯。使用es-build等提升構建效率。
前端部署 & 靜態資源加工
關於前端部署,能總結出下面幾個原則/要求:
- 構建發布后,不應該被覆蓋。
- 構建發布后,靜態資源應當永久保存在服務器/CDN 上,即只可讀。
- 靜態資源組織上,每個版本應該按文件夾存儲,做到資源收斂。這樣假如真要刪除時,可按版本刪除。(如某個版本代碼泄密)
// webpack.config.js const CDN_HOST = process.env.CDN_HOST;// CDN 域名 const CDN_PATH = process.env.CDN_PATH''; // CDN 路徑 const ENV = process.env.ENV; // 當前的環境等等 const VERSION = process.env.VERSION; // 當前發布的版本 const getPublicPath = () => { // Some code here return `${CDN_HOST}/${CDN_PATH}/${ENV}/${VERSION}/`;// 依據 ENV 等動態構造 publicPath } module.exports = { output: { filename: 'bundle.[name][contenthash].js', publicPath: getPublicPath(), }, plugins: [ new HtmlWebpackPlugin() ] }
故 publicPath 應增加 version 字段
- 發布過程應該自動化,開發人員不應該直接接觸服務器。
- 版本切換時,也應當不接觸服務器。
- 版本切換能秒級生效。(如 v0.2 切換 v0.3,立即生效)。
- 線上需要能同時生效多個版本,滿足 AB 測試、灰度、PRE 環境等小流量需求。
上述需求都相對復雜多變,為了應對復雜的線上需求,可以對靜態資源做深度加工,如通過服務端直出 HTML、通過配置中心實現按用戶 PRE 等等。
前端發布服務
面對復雜的商業化需求,方便多前端業務實現版本管理、灰度、PRE、AB 測試等小流量功能,我們設計了一個中間服務 PageConfig Web & PageServer,與 Nginx 和各種后端相結合,達到配置即時生效的能力。
靈魂拷問的部分答案
Q: 前端代碼從 tsx/jsx 到部署上線被用戶訪問,中間大致會經歷哪些過程?
A: 經歷本地開發、遠程構建打包部署、安全檢查、上傳CDN、Nginx做流量轉發、對靜態資源做若干加工處理等過程。
Q:可能大部分同學都知道強緩存/協商緩存,那前端各種產物(HTML、JS、CSS、IMAGES 等)應該用什么緩存策略?以及為什么?
- 若使用協商緩存,但靜態資源卻不頻繁更新,如何避免協商過程的請求浪費?
- 若使用強緩存,那靜態資源如何更新?
A:HTML使用協商緩存,靜態資源使用強緩存,使用name-hash(非覆蓋式發布)解決靜態資源更新問題。
Q:配套的,前端靜態資源應該如何組織?
A:搭配 Webpack 的Webpack_HTML-Plugin & 配置 output publicPath等。
Q:配套的,自動化構建 & 部署過程如何與 CDN 結合?
A:自動化構建打包后,將產物傳輸到對應環境 URL 的CDN上。
Q:如何避免前端上線,影響未刷新頁面的用戶?
A:使用name-hash方式組織靜態資源,先上線靜態資源,再上線HTML。
Q:剛上線的版本發現有阻塞性 bug,如何做到秒級回滾,而非再次部署等 20 分鍾甚至更久?
A:HTML文件使用非覆蓋方式存儲在CDN上,搭建前端發布服務,對 HTML 按版本等做緩存加工處理。當需要回滾時,更改發布服務HTMl指向即可。
Q: CDN 域名突然掛了,如何實現秒級 CDN 降級修補而非再次全部業務重新部署一次?
A1: 將靜態資源傳輸到多個 CDN 上,並開發一個加載Script的SDK集成到HTML中。當發現CDN資源加載失敗時,逐步降級CDN域名。
A2:在前端發布服務中,增加HTML文本處理環節,如增加CDN域名替換,發生異常時,在發布服務中一鍵設置即可。
Q:如何實現一個預發環境,除了前端資源外都是線上環境,將變量控制前端環境內?
A:對靜態資源做加工,對HTML入口做小流量。
Q:部署環節如何方便配套做 AB 測試等?
A:參見前端發布服務
Q:如何實現一套前端代碼,發布成多套環境產物?
A:使用環境變量,將當前環境、CDN、CDN_HOST、Version等注入環境變量中,構建時消費 & 將產物上傳不同的CDN即可。
其他
如果想深入學習前端部署,下面是一些學習建議。
-
學習負載均衡(要求:了解)。
學習和了解負載均衡的原理、都有哪些配置玩法。如參考大型網站架構系列:負載均衡詳解 -
深入學習 HTTP(要求:熟練掌握)
如掌握常見的狀態碼、常見的 Header 及其深度應用、強緩存/協商緩存、HTTP2 的新增功能等等。尤其HTTP 1.1 和 HTTP 2.0。 推薦書籍:
圖解HTTP
HTTP 權威指南 -
深入學習前端工程化 (要求:精通)
a. 了解前端工程化可以做什么,如 前端工程化:體系設計與實踐
b. 掌握前端工程師的常見實踐原理 & 實操
c. 深度學習 Webpack Webpack 官方文檔 -
學習各種對前端靜態資源加工的各種方案(要求:掌握)
-
深度學習瀏覽器原理 (要求:精通)
一些資料: 從瀏覽器多進程到JS單線程,JS運行機制最全面的一次梳理
作者:字節架構前端
來源:稀土掘金