整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          CSS 與網絡性能,看了都說好

          CSS 與網絡性能,看了都說好

          擊上方 "程序員小樂"關注, 星標或置頂一起成長

          近,我們把 Universe.com 的主頁性能提高了 10 幾倍。讓我們一起來探索一下我們是如何實現這個結果的,涉及到了哪些技術。

          一開始,我們先來看看,為什么網站性能如此重要(在本文末尾附有本案例研究的鏈接):

          • 用戶體驗:糟糕的性能導致無響應,從 UI 和 UX 角度來看,這可能會讓用戶感到沮喪。
          • 轉化率和收入:通常,響應緩慢的網站會導致客戶流失,并對轉化率和收入產生不好的影響。
          • SEO:從2019 年 7 月 1 日開始,谷歌將對所有新網站默認啟用移動優先索引。如果網站在移動設備上的響應緩慢,并且沒有適合移動設備的內容,那么它們的排名會降低。

          在本文中,我們將簡要介紹幫助我們提高頁面性能的以下幾個主要方面:

          • 性能測量:實驗室和現場測試工具。
          • 渲染:客戶端和服務器端渲染,預渲染和混合渲染方法。
          • 網絡:CDN、緩存、GraphQL 緩存、編碼、HTTP/2 和服務器推送(Server Push)。
          • 瀏覽器中的 JavaScript:包大小預算、代碼拆分、異步和延遲腳本、圖像優化(WebP、延遲加載、漸進)和資源提示(預加載、預取、預連接)。

          針對某些情況,我們的主頁是用 React(TypeScript)、Phoenix(Elixir)、Puppeteer(無頭 Chrome)和GraphQL API(Ruby on Rails )構建的。在移動設備上的界面如下所示:

          Universe homepage 和 explore

          性能測量

          沒有數據,只不過是空談。—— W. Edwards Deming

          實驗室測試工具(Lab instruments)

          實驗室測試工具允許在受控環境中,用預定義設備和網絡設置采集數據。借助這些工具,調試任何性能問題和具有良好重現性的測試就變得更加簡單。

          Lighthouse是在本地計算機上審核 Chrome 頁面的出色工具。它還提供一些關于如何提高性能、可訪問性、SEO 等有用技巧。下面是一些模擬 Fast 3G 和 4 倍 CPU 減速的 Lighthouse 性能審核報告:

          用 First Contentful Paint (FCP) 提高 10 倍性能的前后對照

          然而,只使用實驗室測試工具的缺點是:它們不一定能發現真實世界的瓶頸問題,這些問題可能取決于終端用戶的設備、網絡、位置和很多其他因素。這就是為什么使用現場測試工具也很重要的原因。

          現場測試工具(Field instrument)

          現場測試工具使我們可以模擬和測量真實的用戶頁面負載。有很多有助于從實際設備中獲取真實性能數據的服務:

          • WebPageTest——允許在不同位置的實際設備上執行來自不同瀏覽器的測試。
          • Test My Site——使用基于 Chrome 使用情況統計的 Chrome 用戶體驗報告(Chrome User Experience Report,簡稱CrUX);它對公眾開放,并每月更新一次。
          • PageSpeed Insights——結合了實驗室(Lighthouse)和現場(CrUX)數據。


          WebPageTest 報告

          渲染

          渲染內容的方法有很多,每種方法都有其優缺點:

          • 服務器端渲染(Server-side rendering,簡稱 SSR)是在服務器端為瀏覽器獲取最終 HTML 文檔的過程。優點:搜索引擎可以爬取網站而不執行 JavaScript(SEO)、快速初始頁面加載、代碼只存在于服務器端。缺點:沒有豐富的網站交互、重新下載整個頁面,對瀏覽器功能的訪問有限制。
          • 客戶端渲染是使用 JavaScript 在瀏覽器中渲染內容的過程。優點:豐富的網站交互、在初始下載后根據路由變化快速渲染、可以訪問現代瀏覽器功能(如,Service Workers的離線支持)。缺點:不支持 SEQ、初始頁面加載速度慢、通常需要在服務器端執行單頁面應用程序(Single Page Application、簡稱 SPA)和 API。
          • 預渲染和服務器端渲染類似,但是它是在構建時期提前發生的,而不是在運行時發生的。優點:服務構建靜態文件通常比運行服務器簡單、支持 SEO、初始頁面加載快。缺點:如果代碼有任何變化,需要預先預渲染所有可能的頁面、重新加載整個頁面、站點交互不夠豐富、訪問瀏覽器功能受限。

          客戶端渲染

          之前,我們把我們的主頁和 Ember.js 框架一起實現為具有客戶端渲染的 SPA。我們遇到的一個問題是,Ember.js 應用程序包太大。這意味著,在瀏覽器下載、解析、編譯和執行 JavaScript 文件時,用戶只能看到一個空白的屏幕。

          白屏

          我們決定用React重建該應用程序的某些部分。

          • 我們的開發人員已經熟悉構建 React 應用程序(如,嵌入式小部件)。
          • 我們已經有了一些 React 組件庫,可以在多個項目之間共享它們。
          • 新頁面有一些交互式 UI 元素。
          • 有一個擁有大量工具的龐大的 React 生態系統。
          • 借助瀏覽器中的 JavaScript,可以構建具有大量良好功能的漸進式 web 應用程序(Progressive Web App)。

          預渲染和服務器端渲染

          例如,用React Router DOM構建的客戶端渲染應用程序的問題, 仍然和 Ember.js 的相同。JavaScript 開銷大,并且需要一些時間才能看到瀏覽器中的首次內容繪制(First Contentful Paint)。

          當我們決定使用 React 后,我們馬上就用其它潛在的渲染選項進行試驗,以讓瀏覽器更快地渲染內容。

          使用 React 的常規渲染選項


          • Gatsby.js使我們可以用 React 和 GraphQL 預渲染頁面。Gatsby.js 是個很棒的工具,它支持很多開箱即用的性能優化。然而,對我們來說,預渲染沒有用,因為我們可能有無數個頁面,它們包含用戶生成的內容。
          • Next.js是流行的 Node.js 框架,它允許服務器端用 React 渲染。然而,Next.js 很自我,需要使用其路由、CSS 解決方案等等。我們現有的組件庫是為瀏覽器而構建的,與 Node.js 不兼容。

          這就是我們為什么決定嘗試一些混合方法的原因,嘗試從每個渲染選項中獲得最佳效果。

          運行時預渲染

          Puppeteer是個 Node.js 庫,它允許使用無頭 Chrome。我們希望讓 Puppeteer 試試在運行時進行預渲染。這支持使用一種有趣的混合方法:服務器端用 Puppeteer 渲染,客戶端用激活渲染。這里有一些谷歌提供的有用竅門,關于如何使用無頭瀏覽器來進行服務器端渲染。

          用于運行時預渲染 React 應用程序的 Puppeteer


          使用這種方法有如下優點:

          • 可以使用 SSR,對 SEO 來說,這很棒。爬蟲程序不需要執行 JavaScript 就能看到內容。
          • 允許構建簡單瀏覽器 React 應用程序一次,然后把它用在服務器端和瀏覽器中。讓瀏覽器應用程序更快地自動讓 SSR 更快,這是雙贏。
          • 在服務器上用 Puppeteer 渲染頁面通常比在終端用戶的移動設備上更快(連接更好, 硬件更好)。
          • 激活允許用對 JavaScript 瀏覽器功能的訪問來構建豐富的 SPA。
          • 我們無需事先知道所有可能的頁面來預渲染它們。

          然而,我們在使用這個方法時遇到了一些挑戰:

          • 吞吐量是主要問題。在單獨的無頭瀏覽器進程中執行每個請求消耗了大量資源。你可以使用單個無標題瀏覽器進程,并在單獨的選項卡中運行多個請求。然而,使用多個選項卡將會使整個進程的性能下降了。


          使用 Puppeteer 進行服務器端渲染的體系結構


          • 穩定性。擴展或縮小很多無頭瀏覽器,讓流程保持“熱度”及平衡工作負載是個挑戰。我們嘗試了不同的托管方法:從 Kubernetes 集群自托管到用 AWS Lambda 和 Google Cloud Functions 的無服務器。我們注意到,后者在用到 Puppeteer 時有一些性能問題:


          在 AWS Lambdas 和 GCP 函數上的 Puppeteer 響應時間


          隨著我們越來越熟悉 Puppeteer,我們已經迭代了我們的初始方法(如下所示)。我們還進行著一些有趣實驗,通過一個無頭瀏覽器來渲染 PDF。還可以使用 Puppeteer 來進行自動端到端測試,甚至都不用寫任何代碼。現在,除了 Chrome,它還支持 Firefox。

          混合渲染方法

          在運行時使用 Puppeteer 很具挑戰性。這是我們為什么決定在構建時使用它,并借助一個在運行時可以從服務器端返回實際用戶生成內容的工具。與 Puppeteer 相比,它更穩定,并且吞吐量更大。

          我們決定嘗試一下 Elixir 編程語言。Elixir 看起來像 Ruby,但是運行于 BEAM(Erlang VM)之上,旨在構建容錯且穩定的系統。

          Elixir 使用Actor 并發模型。每個“Actor”(Elixir process)只占用很少的內存,約為 1-2KB。這樣允許同時運行數千個獨立進程。Phoenix是一個 Elixir web 框架,支持高吞吐量,并在獨立的 Elixir 過程中處理每個 HTTP 請求。

          我們結合了這些方法,充分利用了它們各自的優點,滿足了我們的需要:

          Puppeteer 用于預渲染,而 Phoenix 用于服務器端渲染


          • Puppeteer在構建時用我們希望的方式預渲染 React 頁面,并以 HTML 文件形式保存它們(App Shell 來自PRPL 模式)。

          我們可以繼續構建一個簡單的瀏覽器 React 應用程序,不需要在終端用戶設備上等待 JavaScript 就可以快速加載初始頁面。

          • 我們的Phoenix應用程序服務于這些預渲染頁面,并動態地把實際內容注入到 HTML 中。

          這讓內容 SEO 變得很友好,允許根據需要處理大量不同的頁面,并且更容易擴展。

          • 客戶端接收并立即顯示 HTML,然后激發Recat DOM狀態以繼續作為常規 SPA。

          這樣,我們可以構建高度交互的應用程序,和訪問 JavaScript 瀏覽器功能。

          使用 Puppeteer 進行預渲染、使用 Phoenix 進行服務器端渲染和激發使用 React


          網絡

          內容分發網絡(CDN)

          使用 CDN 可以實現內容緩存,并可以加速其在世界范圍內的分發。我們使用Fastly.com,它為超過 10% 的互聯網請求提供服務,并為各種公司使用,如 GitHub、Stripe、Airbnb、Twitter 等等。

          Fastly 允許我們通過使用名為VCL的配置語言編寫自定義緩存和路由邏輯。下圖顯示了一個基本請求流的工作原理,根據路由、請求標頭等等來自定制每個步驟:

          VCL 請求流

          另一個提高性能的選擇是在邊緣使用 WebAssembly(WASM)和 Fastly。把它想象成使用無服務器,但是在邊緣使用這些編程語言,如 C、Rust、Go、TypeScript 等等。Cloudflare 有個類似的項目支持Workers上的 WASM.

          緩存

          盡可能多地緩存請求對提高性能很重要。CDN 級別上的緩存可以更快地為新用戶提供響應。通過發送 Cache-Control 頭來緩存可以加快瀏覽器中重復請求的響應時間。

          大多數構建工具(如Webpack)允許給文件名添加哈希值。可以安全地緩存這些文件,因為更改文件將創建新的輸出文件名。

          通過 HTTP/2 緩存和編碼的文件

          GraphQL 緩存

          發送 GraphQL 請求最常見的方法之一是使用 POST HTTP 方法。我們使用的一種方法是在 Fastly 級緩存一些 GraphQL 請求:

          • 我們的 React 應用程序注釋了可以緩存的 GraphQL 請求。
          • 發送 HTTP 請求前,我們通過從請求正文構建哈希值來附加 URL 參數,該請求正文包括 GraphQL 請求和變量(我們使用Apollo Client自定義 fetch)。
          • 默認情況下,Varnish(和 Fastly)使用整個 URL 作為緩存鍵的一部分。
          • 這允許我們繼續在請求正文中使用 GraphQL 查詢發送 POST 請求,并在邊緣緩存,而不會訪問我們的服務器。


          發送帶有 SHA256 URL 參數的 POST GraphQL 請求


          以下是一些其它潛在的 GraphQL 緩存策略:

          • 在服務器端緩存:整個 GraphQL 請求,在解析器級別上或通過注釋模式聲明性地進行緩存。
          • 使用持久的 GraphQL 查詢和發送 GET/graphql/:queryId 以便能夠依賴 HTTP 緩存。
          • 通過使用自動化工具(如Apollo Server 2.0)或使用 GraphQL 特定的 CDN(如FastQL)與 CDN 集成。

          編碼

          所有主流瀏覽器都支持帶有Content-Encoding頭的 gzip 來壓縮數據。這可以讓我們給瀏覽器發送的字節更少,這通常意味著內容傳遞會更快。如果瀏覽器支持的話,你還可以使用更有效的 brotli 壓縮算法。

          HTTP/2 協議

          HTTP/2是 HTTP 網絡協議(在 DevConsole 中是 h2)的新版本。切換到 HTTP/2 可以提升性能,這歸結于它和 HTTP/1.x 的這些不同之處:

          • HTTP/2 是二進制的,不是文本。解析更高效,更緊湊。
          • HTTP/2 是多路復用的,這意味著 HTTP/2 可以通過單個 TCP 連接并行發送多個請求。它讓我們不用擔心每個主機限制和域分片的瀏覽器連接。
          • 它使用頭壓縮來減少請求 / 響應大小開銷。
          • 允許服務器主動推送響應。該功能相當有趣。

          HTTP/2 服務器推送

          有很多編程語言和庫并不完全支持所有 HTTP/2 功能,原因是它們為現有工具和生態系統(如,rack)引入了破壞性更改。但是,即使在這種情況下,仍然可以使用 HTTP/2,至少可以部分使用。如:

          • 在常規 HTTP/1.x 服務器前使用 HTTP/2 設置代理服務器,如h2o或nginx。例如 Puma 和 Rails 上的 Ruby 可以發送Early Hints,這可以啟用 HTTP/2 服務器推送,但受到一些限制。
          • 使用支持 HTTP/2 的 CDN 提供靜態資產。例如,我們用這種方法給客戶端推送字體和一些 JavaScript 文件。


          HTTP/2 推送字體

          推送關鍵的 JavaScript 和 CSS 也可以很有用。只是不要過度推送,并提防某些陷阱。

          瀏覽器中的 JavaScript

          包大小的預算

          第一條 JavaScript 性能規則是不要使用 JavaScript。我這么認為。

          如果我們已經有現成的 JavaScript 應用程序,那么設置預算可以改進包大小的可見性,并讓所有人都停留在同一個頁面上。超預算迫使開發人員三思而后行,并把規模的增加控制在最小程度。關于如何設置預算,在此舉幾個例子:

          • 根據我們的需要或一些推薦值使用數字。例如,小于 170KB的縮小和壓縮的 JavaScrip。
          • 把當前的包大小作為基準,或嘗試把它減少,例如 10%。
          • 試試我們的競爭對手中最快的網站,并相應地設置預算。

          我們可以使用 bundlesize 包或 Webpack 性能提示和限制來追蹤預算:

          Webpack 性能提示和限制

          刪除依賴項

          這是由 Sidekiq 的作者所寫的一篇熱門博文的標題

          沒有代碼能比沒代碼運行得更快。沒有代碼能比沒代碼有更少的錯誤。沒有代碼能比沒代碼使用更少的內存。沒有代碼能比沒代碼更容易讓人理解。

          不幸的是,JavaScript 依賴項的現實是,我們的項目很有可能使用數百個依賴項。試試 Is node_modules | wc -l。

          在某些情況下,添加依賴項是必須的。在這種情況下,依賴項包的大小應該是在多個包之間進行選擇時的標準之一。我強烈推薦使用BundlePhobia:

          BundlePhobia 發現向包中添加 npm 包的成本

          代碼拆分

          使用代碼拆分可能是顯著提高 JavaScript 性能的最佳方法。它允許拆分代碼,并只傳遞用戶當前需要的那部分。以下是一些代碼拆分的例子:

          • 在單獨的 JavaScript 塊中分別加載路由
          • 頁面上可以不立即顯示的組件,例如,在頁面下方的模態、頁腳。
          • 在所有主流瀏覽器中,polyfills和ponyfills都支持最新的瀏覽器功能。
          • 通過使用 Webpack 的 SplitChunksPlungin,避免代碼重復。
          • 根據需要定位文件,以避免一次性發送所有我們支持的語言。

          借助 Webpack動態導入和具有Suspense的React.lazy,我們可以使用代碼拆分。

          借助動態引入和具有 Suspense 的 React.lazy 的代碼拆分

          我們構建了一個取代 React.lazy 的函數來支持命名導出,而不是默認導出。

          異步和延遲腳本

          所有主流瀏覽器支持腳本標簽上的異步和延遲屬性


          加載 JavaScript 的不同方法

          • 內聯腳本對于加載小型關鍵 JavaScript 代碼非常有用。
          • 當用戶或任何其他腳本(例如,分析腳本)不需要該腳本,要獲取 JavaScript 而不妨礙 HTML 解析時,使用帶async的腳本非常有用。
          • 從性能的角度看,要獲取和執行非關鍵 JavaScript,并且不阻礙 HTML 解析,那么,使用帶defer的腳本可能是最佳方法。此外,它確保調用腳本時的執行順序,如果一個腳本依賴另一個腳本,那么這個方法會很有用。

          以下顯示了在頭標簽中這些腳本之間的差異:

          腳本獲取和執行的不同方法

          圖像優化

          盡管 JavaScript 的 100KB 與圖像的 100KB 相比,性能成本有很大的不同,但是,通常來說,盡量讓圖像保持比較小的文件大小很重要。

          一種減小圖像大小的方法是,在受支持的瀏覽器中使用更輕量級的WebP圖像格式。對于那些不支持 WebP 的瀏覽器來說,可以使用以下策略:

          • 退回到常規 JPEG 或 PNG 格式(一些 CDN 根據瀏覽器的 Accept 請求頭自動執行)
          • 在檢測到瀏覽器支持后,加載并使用WebP polyfill。
          • 使用 Service Workers 來偵聽以獲取請求,如果 WebP 受到支持,那么就更改實際的 URL 以使用 WebP。



          WebP 圖像

          僅當圖像在位于或接近視圖端口時才延遲加載圖像,對于具有大量圖像的初始頁面加載來說,這是最顯著的性能改進之一。我們可以在支持的瀏覽器中使用 IntersectionObserver功能,或使用一些可替換的工具來實現同樣的結果,例如,react-lazyload。

          在滾動期間延遲加載圖像

          其他一些圖像優化可能包括:

          • 降低圖像的質量以減少圖像的尺寸。
          • 調整大小并盡可能加載最小的圖像。
          • 使用srcset圖像屬性為高分辨率視網膜顯示器自動加載高質量圖像。
          • 使用漸進式圖像,先立即顯示出模糊的圖像


          加載常規圖像和漸進圖像的對比

          我們可以考慮使用一些通用 CDN 或專用圖像 CDN,它們通常實現了這些圖像優化的大部分工作。

          資源提示

          資源提示讓我們可以優化資源的交付,減少往返次數,以及資源的獲取,以便在用戶瀏覽頁面時更快地傳遞內容。

          帶有 link 標記的資源提示

          • 預加載(preload)在當前頁面加載的后臺下載資源,并會實際用于當前頁面(高優先級)。
          • 預取(prefetch)的工作原理和預加載類似,都是獲取資源并緩存它們,但用于未來用戶的導航(低優先級)。
          • 預連接(preconnect)允許在 HTTP 請求在實際發送到服務器之前,設置早期連接。


          提前預連接以避免 DNS、TCP 和 TLS 往返延遲

          還有其他一些資源提示,如預渲染或DNS 預取。其中有一些可以在響應頭上指定。在使用資源提示時,請小心行事。很容易一開始就造成太多不必要的請求和下載太多數據,特別是如果用戶在使用蜂窩連接。

          結論

          在不斷增長的應用中,性能是永無止境的過程,該過程通常需要在整個棧中不斷更改。

          這個視頻提醒我,大家希望減少應用程序包的大小——我的同事


          把一切你現在不需要的東西都扔出飛機!——電影《珍珠港》


          以下是一個列表,表中是我們在使用或計劃嘗試的其他未提及的潛在性能改進:

          • 使用 Service Workers 進行緩存、脫機支持及卸載主線程。
          • 內聯關鍵 CSS 或使用功能性 CSS,以便長期減小尺寸大小。
          • 使用如 WOFF2 而不是 WOFF 的字體格式(最高可壓縮一半大小)。
          • 瀏覽器列表保持更新。
          • 使用webpack-bundle-analyzer進行構建塊的可視化分析。
          • 優選較小的包(例如,date-fns)和允許減小尺寸大小的插件(如,lodash-webpack-plugin)。
          • 試試preact、lit-html或svelte。
          • 在CI 中運行 Lighthouse。
          • 漸進激發和用React進行流處理。

          令人興奮的想法無窮無盡,我們都可以拿來嘗試。我希望這些信息和這些案例研究可以啟發大家去思考應用程序中的性能。

          據亞馬遜計算,頁面下載速度每下降 1 秒就可能造成年銷售額減少 13 億美元。沃爾瑪發現,加載時間每減少 1 秒,將使轉換量增加 2%。每 100ms 的改進還會帶來高達 1% 的收入增加。據谷歌計算,搜索結果每放慢 0.4 秒,那么每天的搜索次數有可能減少 8 百萬次。重構 Pinterest 頁面的性能使等待時間減少了 40%,而 SEO 流量增加了 15%,注冊轉化率增加了 15%。BBC 發現,其網站加載時間每增加一秒,就會多流失 10% 的用戶。對新的更快的 FT.com 的測試表明,用戶參與度提高了 30%,這意味著更多的訪問次數和更多的內容消費。Instagram 通過減少顯示評論所需 JSON 的響應大小,將展示次數和用戶個人資料滾動互動量增加了 33%。

          點擊“了解更多”,獲取更多優質閱讀


          本文主要總結了在ICBU的核心溝通場景下服務端在此次性能優化過程中做的工作,供大家參考討論。


          一、背景與效果


          ICBU的核心溝通場景有了10年的“積累”,核心場景的界面響應耗時被拉的越來越長,也讓性能優化工作提上了日程,先說結論,經過這一波前后端齊心協力的優化努力,兩個核心界面90分位的數據,FCP平均由2.6s下降到1.9s,LCP平均由2.8s下降到2s。本文主要著眼于服務端在此次性能優化過程中做的工作,供大家參考討論。

          二、措施一:流式分塊傳輸(核心)


          2.1. HTTP分塊傳輸介紹


          分塊傳輸編碼(Chunked Transfer Encoding)是一種HTTP/1.1協議中的數據傳輸機制,它允許服務器在不知道整個內容大小的情況下,就開始傳輸動態生成的內容。這種機制特別適用于生成大量數據或者由于某種原因數據大小未知的情況。

          在分塊傳輸編碼中,數據被分為一系列的“塊”(chunk)。每一個塊都包括一個長度標識(以十六進制格式表示)和緊隨其后的數據本身,然后是一個CRLF(即"\r\n",代表回車和換行)來結束這個塊。塊的長度標識會告訴接收方這個塊的數據部分有多長,使得接收方可以知道何時結束這一塊并準備好讀取下一塊。

          當所有數據都發送完畢時,服務器會發送一個長度為零的塊,表明數據已經全部發送完畢。零長度塊后面可能會跟隨一些附加的頭部信息(尾部頭部),然后再用一個CRLF來結束整個消息體。

          我們可以借助分塊傳輸協議完成對切分好的vm進行分塊推送,從而達到整體HTML界面流式渲染的效果,在實現時,只需要對HTTP的header進行改造即可:

          public void chunked(HttpServletRequest request, HttpServletResponse response) {
          
              try (PrintWriter writer=response.getWriter()) {
          
              // 設置響應類型和編碼
          
              oriResponse.setContentType(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8");
          
              oriResponse.setHeader("Transfer-Encoding", "chunked");
          
              oriResponse.addHeader("X-Accel-Buffering", "no");
          
              
          
              // 第一段
          
              Context modelMain=getmessengerMainContext(request, response, aliId);
          
              flushVm("/velocity/layout/Main.vm", modelMain, writer);
          
          
          
          
              // 第二段
          
              Context modelSec=getmessengerSecondContext(request, response, aliId, user);
          
              flushVm("/velocity/layout/Second.vm", modelSec, writer);
          
          
          
          
              // 第三段
          
              Context modelThird=getmessengerThirdContext(request, response, user);
          
              flushVm("/velocity/layout/Third.vm", modelThird, writer);
          
          } catch (Exception e) {
          
              // logger
          
          }
          
          }
          
          
          
          
          private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {
          
              StringWriter tmpWri=new StringWriter();
          
              // vm渲染
          
              engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);
          
              // 數據寫出
          
              writer.write(tmpWri.toString());
          
              writer.flush();
          
          }


          2.2. 頁面流式分塊傳輸優化方案


          我們現在的大部分應用都是springmvc架構,瀏覽器發起請求,后端服務器進行數據準備與vm渲染,之后返回html給瀏覽器。

          從請求到達服務端開始計算,一次HTML請求到頁面加載完全要經過網絡請求、網絡傳輸與前端資源渲染三個階段:

          HTML流式輸出,思路是對HTML界面進行拆分,之后由服務器分批進行推送,這樣做有兩個好處:

          • 服務端分批進行數據準備,可以減少首次需要準備的數據量,極大縮短準備時間。
          • 瀏覽器分批接收數據,當接收到第一部分的數據時,可以立刻進行js渲染,提升其利用率。

          這個思路對需要加載資源較多的頁面有很明顯的效果,在我們此次的界面優化中,頁面的FCP與LCP均有300ms-400ms的性能提升,在進行vm界面的數據拆分時,有以下幾個技巧:

          • 注意界面資源加載的依賴關系,前序界面不能依賴后序界面的變量。
          • 將偏靜態與核心的資源前置,后端服務器可以快速完成數據準備并返回第一段html供前端加載。


          2.3. 注意事項


          此次優化的應用與界面本身歷史包袱很重,在進行流式改造的過程中,我們遇到了不少的阻力與挑戰,在解決問題的過程也學到了很多東西,這部分主要對遇到的問題進行整理。

          1. 二方包或自定義的HTTP請求 filter 會改寫 response 的 header,導致分塊傳輸失效。如果應用中有這種情況,我們在進行流式推送時,可以獲取到最原始的response,防止被其他filter影響:
          /**
          
           * 防止filter或者其他代理包裝了response并開啟緩存
          
           * 這里獲取到真實的response
          
           *
          
           * @param response
          
           * @return
          
           */
          
          private static HttpServletResponse getResponse(HttpServletResponse response) {
          
              ServletResponse resp=response;
          
              while (resp instanceof ServletResponseWrapper) {
          
                  ServletResponseWrapper responseWrapper=(ServletResponseWrapper) resp;
          
                  resp=responseWrapper.getResponse();
          
              }
          
              return (HttpServletResponse) resp;
          
          }
          1. 谷歌瀏覽器禁止跨域名寫入cookie,我們的應用界面會以iframe的形式嵌入其他界面,谷歌瀏覽器正在逐步禁止跨域名寫cookie,如下所示:

          為了確保cookie能正常寫入,需要指定cookie的SameSite=None。

          1. VelocityEngine模板引擎的自定義tool。

          我們的項目中使用的模板引擎為VelocityEngine,在流式分塊傳輸時,需要手動渲染vm:

          private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception {
          
              StringWriter tmpWri=new StringWriter();
          
              // vm渲染
          
              engine.mergeTemplate(templateName, "UTF-8", model, tmpWri);
          
              // 數據寫出
          
              writer.write(tmpWri.toString());
          
              writer.flush();
          
          }

          需要注意的是VelocityEngine模板引擎支持自定義tool,在vm文件中是如下的形式,當vm引擎渲染到對應位置時,會調用配置好的方法進行解析:

          <title>$tool.do("xx", "$!{arg}")</title>

          如果用注解的形式進行vm渲染,框架本身會幫我們自動做tools的初始化。但如果我們想手動渲染vm,那么需要將這些tools初始化到context中:

          /**
          
           * 初始化 toolbox.xml 中的工具
          
           */
          
          private Context initContext(HttpServletRequest request, HttpServletResponse response) {
          
              ViewToolContext viewToolContext=null;
          
              try {
          
                  ServletContext servletContext=request.getServletContext();
          
                  viewToolContext=new ViewToolContext(engine, request, response, servletContext);
          
                  VelocityToolsRepository velocityToolsRepository=VelocityToolsRepository.get(servletContext);
          
                  if (velocityToolsRepository !=null) {
          
                      viewToolContext.putAll(velocityToolsRepository.getTools());
          
                  }
          
              } catch (Exception e) {
          
                  LOGGER.error("createVelocityContext error", e);
          
                  return null;
          
              }
          
          }

          對于比較古老的應用,VelocityToolsRepository需要將二方包版本進行升級,而且需要注意,velocity-spring-boot-starter升級后可能存在tool.xml文件失效的問題,建議可以采用注解的形式實現tool,并且注意tool對應java類的路徑。

          @DefaultKey("assetsVersion")
          
          public class AssertsVersionTool extends SafeConfig {
          
              public String get(String key) {
          
                  return AssetsVersionUtil.get(key);
          
              }
          
          }
          1. Nginx 的 location 配置
          server {
          
             location ~ ^/chunked {
          
                  add_header X-Accel-Buffering  no;
          
                  proxy_http_version 1.1;
          
              
          
                  proxy_cache off; # 關閉緩存
          
                  proxy_buffering off; # 關閉代理緩沖
          
                  chunked_transfer_encoding on; # 開啟分塊傳輸編碼
          
                  proxy_pass http://backends;
          
              } 
          
          }
          1. ngnix配置本身可能存在對流式輸出的不兼容,這個問題是很難枚舉的,我們遇到的問題是如下配置,需要將SC_Enabled關閉。
          SC_Enabled on;
          
          SC_AppName gangesweb;
          
          SC_OldDomains //b.alicdn.com;
          
          SC_NewDomains //b.alicdn.com;
          
          SC_OldDomains //bg.alicdn.com;
          
          SC_NewDomains //bg.alicdn.com;
          
          SC_FilterCntType  text/html;
          
          SC_AsyncVariableNames asyncResource;
          
          SC_MaxUrlLen    1024;

          詳見:https://github.com/dinic/styleCombine3

          1. ngnix緩沖區大小,在我們優化的過程中,某個應用并沒有指定緩沖區大小,取的默認值,我們的改造導致http請求的header變大了,導致報錯upstream sent too big header while reading response header from upstream
          proxy_buffers       128 32k;
          
          proxy_buffer_size   64k;
          
          proxy_busy_buffers_size 128k;
          
          client_header_buffer_size 32k;
          
          large_client_header_buffers 4 16k;

          如果頁面在瀏覽器上有問題時,可以通過curl命令在服務器上直接訪問,排查是否為ngnix的問題:

          curl --trace - 'http://127.0.0.1:7001/chunked' \
          
          -H 'cookie: xxx'
          1. ThreadLocal與StreamingResponseBody

          在開始,我們使用StreamingResponseBody來實現的分塊傳輸:

          @GetMapping("/chunked")
          
          public ResponseEntity<StreamingResponseBody> streamChunkedData() {
          
              StreamingResponseBody stream=outputStream -> {
          
              
          
                  // 第一段
          
                  Context modelMain=getmessengerMainContext(request, response, aliId);
          
                  flushVm("/velocity/layout/Main.vm", modelMain, writer);
          
              
          
                  // 第二段
          
                  Context modelSec=getmessengerSecondContext(request, response, aliId, user);
          
                  flushVm("/velocity/layout/Second.vm", modelSec, writer);
          
              
          
                  // 第三段
          
                  Context modelThird=getmessengerThirdContext(request, response, user);
          
                  flushVm("/velocity/layout/Third.vm", modelThird, writer);
          
                      }
          
                  };
          
                  
          
                  return ResponseEntity.ok()
          
                          .contentType(MediaType.TEXT_HTML)
          
                          .body(stream);
          
                          
          
              }
          
          }

          但是我們在運行時發現vm的部分變量會渲染失敗,卡點了不少時間,后面在排查過程中發現應用在處理http請求時會在ThreadLocal中進行用戶數據、request數據與部分上下文的存儲,而后續vm數據準備時,有一部分數據是直接從中讀取或者間接依賴的,而StreamingResponseBody本身是異步的(可以看如下的代碼注釋),這就導致新開辟的線程讀不到原線程ThreadLocal的數據,進而渲染錯誤:

          /**
          
           * A controller method return value type for asynchronous request processing
          
           * where the application can write directly to the response {@code OutputStream}
          
           * without holding up the Servlet container thread.
          
           *
          
           * <p><strong>Note:</strong> when using this option it is highly recommended to
          
           * configure explicitly the TaskExecutor used in Spring MVC for executing
          
           * asynchronous requests. Both the MVC Java config and the MVC namespaces provide
          
           * options to configure asynchronous handling. If not using those, an application
          
           * can set the {@code taskExecutor} property of
          
           * {@link org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter
          
           * RequestMappingHandlerAdapter}.
          
           *
          
           * @author Rossen Stoyanchev
          
           * @since 4.2
          
           */
          
          @FunctionalInterface
          
          public interface StreamingResponseBody {
          
          
          
          
            /**
          
             * A callback for writing to the response body.
          
             * @param outputStream the stream for the response body
          
             * @throws IOException an exception while writing
          
             */
          
            void writeTo(OutputStream outputStream) throws IOException;
          
          
          
          
          }

          三、措施二:非流量中間件優化


          在性能優化過程中,我們發現在流量高峰期,某個服務接口的平均耗時會顯著升高,結合arths分析發現,是由于在流量高峰期,對于配置中心的調用被限流了。原因是配置中心的使用不規范,每次都是調用getConfig方法從配置中心服務端拉取的數據。

          在讀取配置中心的配置時,更標準的使用方法是由配置中心主動推送變更,客戶端監聽配置信息緩存到本地,這樣,每次讀取配置其實讀取的是機器的本地緩存,可以參考如下的方式:

          public static void registerDynamicConfig(final String dataIdKey, final String groupName) {
          
              IOException initError=null;
          
          
          
          
              try {
          
                  String e=Diamond.getConfig(dataIdKey, groupName, DEFAULT_TIME_OUT);
          
                  if(e !=null) {
          
                      getGroup(groupName).put(dataIdKey, e);
          
                  }
          
          
          
          
                  logger.info("Diamond config init: dataId=" + dataIdKey + ", groupName=" + groupName + "; initValue=" + e);
          
              } catch (IOException e) {
          
                  logger.error("Diamond config init error: dataId=" + dataIdKey, e);
          
                  initError=e;
          
              }
          
          
          
          
              Diamond.addListener(dataIdKey, groupName, new ManagerListener() {
          
                  @Override
          
                  public Executor getExecutor() {
          
                      return null;
          
                  }
          
          
          
          
                  @Override
          
                  public void receiveConfigInfo(String s) {
          
                      String oldValue=(String)DynamicConfig.getGroup(groupName).get(dataIdKey);
          
                      DynamicConfig.getGroup(groupName).put(dataIdKey, s);
          
                      DynamicConfig.logger.warn(
          
                          "Receive config update: dataId=" + dataIdKey + ", newValue=" + s + ", oldValue=" + oldValue);
          
                  }
          
              });
          
              if(initError !=null) {
          
                  throw new RuntimeException("Diamond config init error: dataId=" + dataIdKey, initError);
          
              }
          
          }

          四、措施三:數據直出


          1. 靜態圖片直出,頁面上有靜態的loge圖片,原本為cdn地址,在瀏覽器渲染時,需要建聯并會搶占線程,對于這類不會發生發生變化的圖片,可以直接替換為base64的形式,js可以直接加載。
          2. 加載數據直出,這部分需要根據具體業務來分析,部分業務數據是瀏覽器運行js腳本在本地二次請求加載的,由于低端機以及本地瀏覽器的能力限制,如果需要加載的數據很多,就很導致js線程的擠占,拖慢整體的時間,因此,可以考慮在服務器將部分數據預先加載好,隨http請求一起給瀏覽器,減少這部分的卡點。

          數據直出有利有弊,對于頁面的加載性能有正向影響的同時,也會同時導致HTTP的response增大以及服務端RT的升高。數據直出與流式分塊傳輸相結合的效果可能會更好,當服務端分塊響應HTTP請求時,本身的response就被切割成多塊,單次大小得到了控制,流式分塊傳輸下,服務端分批執行數據準備的策略也能很好的緩沖RT增長的問題。

          五、措施四:本地緩存


          以我們遇到的一個問題為例,我們的云盤文件列表需要在后端準備好文件所屬人的昵稱,這是在后端服務器由用戶id調用會員的rpc接口實時查詢的。分析這個場景,我們不難發現,同一時間,IM場景下的文件所屬人往往是其中歸屬在聊天的幾個人名下的,因此,可以利用HashMap作為緩存rpc查詢到的會員昵稱,避免重復的查詢與調用。

          六、措施五:下線歷史債務


          針對有歷史包袱的應用,歷史債務導致的額外耗時往往很大,這些歷史代碼可能包括以下幾類:

          • 未下線的實驗或者分流接口調用;
            • 時間線拉長,這部分的代碼殘骸在所難免,而且積少成多,累計起來往往有幾十上百毫秒的資源浪費,再加上業務開發時,大家往往沒有額外資源去評估這部分的很多代碼是否可以下線,因此可以借助性能優化的契機進行治理。
          • 已經廢棄的vm變量與重復變量治理。
            • 對vm變量的盤點過程中發現有很多之前在使用但現在已經廢棄的變量。當然,這部分變量的需要前后端同學共同梳理,防止下線線上依舊依賴的變量。

          作者:樹塔

          來源-微信公眾號:阿里云開發者

          出處:https://mp.weixin.qq.com/s/06eND-fUGQ7Y6gwJxmvwQQ


          主站蜘蛛池模板: 午夜一区二区在线观看| 在线观看精品一区| 中文字幕一区二区精品区| 亚洲日韩AV一区二区三区中文| 一区二区三区视频免费| 日韩精品免费一区二区三区| 亚洲AV无码一区东京热| 国产福利电影一区二区三区,亚洲国模精品一区 | 韩国福利一区二区三区高清视频| 一区二区在线免费观看| 一区二区高清视频在线观看| 日本在线视频一区二区| 日本欧洲视频一区| 亚洲国产激情一区二区三区| 亚洲美女视频一区二区三区| 亚洲AV永久无码精品一区二区国产| 亚洲高清美女一区二区三区| 亚洲AV无码一区二区三区系列| 久久精品国产亚洲一区二区三区| 日韩精品无码人妻一区二区三区| 精品久久综合一区二区| 无码中文字幕乱码一区 | 国产一区二区在线观看app| 精品一区二区三区中文| 四虎精品亚洲一区二区三区| 麻豆视传媒一区二区三区| 国产成人无码精品一区不卡 | 久久se精品动漫一区二区三区| 国产精品一区二区三区99 | 亚洲高清一区二区三区 | 国产成人午夜精品一区二区三区| 性色av一区二区三区夜夜嗨| 久久se精品一区二区| 日韩免费无码一区二区三区| 97精品国产一区二区三区| 国产欧美色一区二区三区 | 一区二区精品在线观看| 在线视频一区二区日韩国产| 香蕉视频一区二区三区| 久久亚洲国产精品一区二区| 中文字幕乱码亚洲精品一区 |