整合 Lighthouse CI 時遇到的大小事 feat. MSW & Drone CI

前陣子因為公司的 Web Infra Team 積極推廣在各個專案中整合 Lighthouse CI 來確保 Core Web Vitals 效能品質的監控,因此我花了一些時間為負責的產品專案整合這個功能,不過我在整合的過程中踩了無數個坑…🥲 雖然過程不太順遂,卻遇到許多有趣的挑戰,我認為非常值得記錄下來,也希望能夠幫助到對這個主題有興趣的人。

(為了避免洩漏公司的內部資訊,本文的畫面截圖有些會經過重製或是使用模擬畫面,還請讀者見諒。)

整合 Lighthouse CI 的目的是什麼?

在 LINE 台灣有一個專門制定前端共用標準規範與共用 library 的 Infra Team,這個組織的任務除了訂立統一的規範與開發共用的 Library 以外,將這些規範推廣到各個專案實踐也是 team member 的重要任務(每個 team member 通常都會隸屬另外的產品團隊,因此會負責推廣到該產品團隊中)。近期在推動的項目有 SonarQube 的程式碼品質檢測、共用 Eslint 規範、Renovate 自動更新專案使用的套件版本…等等,另外也搭建了一個 Grafana Dashboard 來監控各個專案的 Test Coverage、Code Smell…等狀況,期許讓前端專案的可觀測性也達到一定標準。

Renovate 利用 BOT 自動發布更新 dependencies 的 Pull Request
前端專案的 Grafana Dashboard

而 Lighthouse CI 也是近期團隊在推動的一個事項,目的很明確就是要確保各專案的載入效能與 Core Web Vitals 指標分數的狀況(如果不清楚 Lighthouse CI 是什麼的讀者可以參考我之前在鐵人賽的簡短介紹)。

熟悉 Lighthouse CI 或是看過上面鐵人賽文章的讀者應該就會知道 Lighthouse CI 可以在 config 檔指定一些 assertion,例如:

代表當 Performance 分數掉到 93 分以下,就在 status 顯示 warning,若 Accessibility 分數掉到 95 分以下,則會顯示 error。

不過以 Infra Team 制定多個專案間共用規範的角度來說,這個分數是非常難取捨的,畢竟每個專案的效能狀況一定都不相同,尤其是對於大型專案來說,如果在開發初期沒有特別注意效能,通常等規模變大後 Performance 分數都不會太高,且要提升有一定的難度。因此對於 CI 的 assertion 我們並沒有制定統一的規範,而是交由各專案去決定。

雖然沒有在分數上訂立統一的規範,不過 Infra Team 還是制定了關於 Lighthouse CI 共同的目的:

重點不是在頁面的「絕對分數」,而是在於開發時的改動對於分數造成的「相對變動」。

也就是說要關注的重點在於每次 commit 新的程式碼時相對於之前的版本效能是提升還是下降,團隊中有成員開發了 Compare Lighthouse CI 指標分數的工具,並結合 Github Status Check,在每次發布 PR 時都可以看到最新的 commit 與 checkout 這個分支時的 commit 的指標分數對比,以確保不會在不知情的狀況下 merge 到會造成效能大幅降低的程式碼。

在 Pull Request 討論串會有 BOT 自動貼上 compare 後的結果

整合 Lighthouse CI 的過程

其實團隊中已經有成員將整合 Lighthouse CI 的步驟寫成完整的文件,包括在公司內部的 Lighthouse CI Server 建立專案、獲取 Lighthouse CI 與 Github 的token、Docker Image 的配置…等等,原先我以為只要照著文件做,應該一兩天就能夠串接完畢,沒想到最後花了一個多禮拜才完成,而卡住我的主要問題就是 Mock API Server 與自己對專案的了解還不夠足夠。

當然在進行 Lighthouse CI 檢測時,直接串接真實的 API 也是沒什麼問題的,不過這會產生一個潛在問題:

當指標分數下降時,如何得知是不是 API 的回應速度變慢或是相關其他因素造成的?

這讓我們串接 Lighthouse CI 的核心目標「確保前端程式碼的改動沒有造成效能的瓶頸」變得更難去判斷,因為指標變動除了可能是程式碼造成的影響以外,網路狀況、API 近期的改動都可能是影響分數的因素之一。

為了要降低其他因素的影響,團隊的結論是各專案在串接 Lighthouse CI 時應該要建立一個給 Lighthouse CI 使用的 Mock API Server,以避免 API 狀況不穩反映到檢測分數上。

也許有人會想到,使用 Mock API Server 檢測出來的分數應該會比真實環境下還要高出許多,這樣失真的狀況會不會失去檢測的意義?依照筆者的觀察,在使用 Mock Server 後分數的確會比 Production 環境上還要高出許多,不過還記得前面提到的目標嗎,我們在意的是程式碼改動造成的「相對變動」,而不是檢測出的「絕對分數」。在儘量提供一致的檢測環境的狀況下,我們可以更有信心的認為分數的提升跟降低跟程式碼的變動是有關聯的,而非其他不可控的因素(當然,不可控的因素是不可能完全避免的)。此時頁面的效能總分到底是多少對我們來說就不是那麼重要了。

MSW (Mock Service Worker)

首先說明一下我們前端專案的 tech stack:NextJS 搭配 Apollo Client 來管理對 GraphQL Server 的 API data fetching,也就是說必須建立一個 GraphQL 的 Mock Server,這對於剛接觸 GraphQL 不到三個月的我來說還真的不太知道該如何下手,想到要 mock 無數個 Schemas、Resolvers 就感到心累。

不過就在這時經過同事提醒才發現專案的 testing 環境是使用 MSW 這個 library 來當作 NodeJS 環境的 Mock Server,也許它也可以當作 Lighthouse CI 的 Mock API Server ?

MSW(Mock Service Worker) 是一個透過 Service Workers 攔截發出的 Network Requests,並返回 mock 資料的 API mocking Library。(不了解 Service Workers 概念的讀者可以參考我之前在鐵人賽的文章

而 MSW 也有提供 GraphQL API 的 mocking,看起來是非常可行的,剩下要解決的就是如何將 GraphQL 的 Schema 餵進這個 mock server 了。

專案中使用了 GraphQL Code Generator 搭配 introspection 這個 plugin 協助根據 Schema 自動產生 introspection JSON 檔案,透過將這個 JSON 檔案餵給 graphql 的 buildClientSchema 產生 schema,再搭配 @graphql-tools/mock 的輔助,就可以讓我們的 MSW Mock Server 攔截到前端專案打出的 GraphQL query 了。(程式碼範例大致如下,不過其實並沒有很重要🤣)

準備好 Mock Server 後,要做的就是在跑 Lighthouse CI 的時候啟動它了。需要特別注意的是,因為 NextJS 是一個 Server-Side-Rendering 框架,對於 API 的請求分為 client side 與 server side, 由於 Service Workers 只能運行在瀏覽器上,所以對於 SSR 的 mock data fetching,我們需要另外使用 msw/node 在 server side 啟動另外一個 mock server 來回應請求。

此時將專案跑起來應該就會看到渲染出的都是 mock 的資料了,在瀏覽器的 console 也可以看到 MSW 攔截了哪些請求(這邊只會顯示 client side 的請求,於 server side 被攔截的請求需要在 terminal 才會看得到)

Mock Server 的部分就算初步完成囉!

整合 Lighthouse CI 時踩到的各種問題

在整合 CI 的過程中,我踩到了很多坑,雖然都算是小問題,不過因為需要跑 CI 後才能驗證成果與發現問題,導致 debug 的流程非常的冗長。

CI mode 的環境變數

在基本需求都串接完成後,我回到 Lighthouse CI 的 Dashboard 觀看檢測的成果,發現檢測的頁面分數高的嚇人,雖然知道 mock server 會讓分數結果提高一點,但接近滿分也未免太嚇人了。

從畫面截圖來看頁面也有正確渲染,一時之間還真不知道問題出在哪。模擬 Lighthouse CI 環境將轉案跑在 local 端後在 console 終於發現問題。

原來 JS 的 bundle chunks 根本就沒有正確載入,畫面會正常顯示是因為這個頁面剛好資料都是透過 SSR 抓取的, MSW 在 server side 回傳了 mock 資料並渲染出來,但是頁面在 client side 因為沒有正確載入 chunks,導致頁面沒有經過 hydration,雖然有畫面卻沒辦法執行任何使用者互動,這也難怪檢測分數會這麼高了。

稍微 trace 一下後發現問題出在環境變數沒有帶對,通常在 CI 環境下會給一些不同於 DEV 環境、Produciton 環境的設定,以筆者的案例來說,因為環境變數沒有帶對,所以抓取資源的 request 實際上是打去 CDN server ,等於是打到完全不同的 URL,因此才沒辦法正確抓取,提醒各位在整合的時候記得要對環境變數這塊多做檢查喔!

針對專案的特定商業邏輯回傳 mock data

相信每個前端專案都會有針對商業需求而特別撰寫的邏輯,也許以工程的角度思考這些邏輯並不好維護或不 make any sense,不過還是在時程與其他考量下選擇妥協。

在整合 Lighthouse CI 的過程,我發現某個區塊一直無法在 CI mode 顯示,追蹤了一下發現明明 MSW 是有攔截到該 query 的,後來回去追蹤該區塊的程式碼,才發現有一段邏輯指定如果回傳的陣列資料長度不是 8,就不顯示任何東西,而 mock server 如果是回傳 array type,預設都會回傳長度 2 的 mock data,於是才會發生沒有顯示的狀況。

面對這些特殊的需求,可以在 MSW mock server 做一些特別的設定,例如當攔截到某個特定的 query 時,要回傳指定長度的 mock data。

Automatic Persisted Queries (APQ)

從 Lighthouse CI dashboard 上的畫面截圖我發現某些區塊沒有正確顯示,並且確認不是上方提到的特定商業邏輯所造成的問題,看來很可能跟 mock data fetching 脫離不了關係,研究了一下後發現原因出在 Apollo Server 的 Automatic Persisted Queries (APQ)

簡單來說它是一種避免 client side 因為對 GraphQL server 的 query string 過於肥大而產生延遲、增加網路用量,導致 client 效能降低的機制。它的做法是將 query string 快取在 server side,並且建立一個 unique identifier (通常透過 SHA-256),往後 client 在對 server 發出請求時只需要帶上這個識別碼,server 就能夠識別 client 想要的是什麼 query,client 不必再帶上肥大的 query ,因此大幅減少了 request size(response 則不受影響)。

APQ 一個很棒的應用是在 Apollo Server 前面放置一層 CDN。要知道大部分的 CDN 只能 cache GET request,但大多數的 GraphQL query 常因為 query 太長而不太適合使用 GET request (要在 GraphQL 使用 HTTP GET,原先放在 body 的query 都必須轉換成 query string 的方式,可以參考這篇文章),而 APQ 則正好避免了這個問題,只要在前端的程式中加入createPersistedQueryLink({ useGETForHashedQueries: true }),Apollo Client 就會自動將指定的 request 轉換為 GET request,並且將經過 Hash 的 unique identifier 作為 query string ,變成適合 CDN cache 起來的形式。

先前在 Lighthouse CI 中觀察到的幾個沒有正確顯示的區塊就是因為這些區塊使用了經過 APQ 的 query,request 被導到 CDN 去了,因為跟 mock server 是完全不同的 domain,所以才沒有正確顯示。最後經過指定在 CI mode 時不要啟用 APQ, 這個問題就順利解決囉!

利用 ApolloLink.spint,如果第一個參數的條件回傳為 true,就使用第二個參數,否則使用第三個參數,有點類似三元運算子。

Data Fetching 的 Race Condition

這個應該是讓我卡住最久的問題,我在 Lighthouse CI 發現了一個不預期的行為 — 雖然一開始會渲染 mock data,但最後卻會被 API 的真實資料取代。

這其實代表著一個嚴重的問題:Service Workers 雖然有在運作,但有些 network requests 並沒有成功被攔截到,還是打到 API endpoint 去了。

觀察一下瀏覽器 DevTool 後也驗證了這個猜想

前三個 requests 就是沒有被 Service Workers 成功攔截

研究了一下後發現問題出在 Service Workers 的 initiation(也就是前面程式碼的 worker.start()) 是一個非同步的操作,如果在要發出 request 的時候 Service Workers 還沒有啟動,那麼請求就不會被攔截到,造成上述的 Race Condition,也就是 MSW 官方提到的 deferred mounting problem

要解決這個問題最直覺的方式就是在發出 request 之前先等待 worker 已經成為 enabled 的狀態再接著操作。雖然 msw 的 waitUntilReady 理應可以避免這個問題,或是在相關的 issues 中 MSW 的維護者也表示新版本的 MSW 可以解決這個問題,然而我在嘗試過後都無功而返,看來只好另尋他法。(有時候 bug 就是這麼神秘,不同的技術堆疊、不同的套件組合都有可能產生不預期的錯誤,在這種狀況下要 debug 也相當不容易😰)

因為 NextJS 不像單純的 React 可以控制 ReactDOM render 之前去做一些事

所以我第一個嘗試的是這個 discussion 提供的方式

簡單來說就是在 Service Workers enabled 後才透過操縱 state 觸發 re-render,渲染出頁面的內容。

雖然可以解決 race condition 的問題,不過 Lighthouse CI 檢測出的分數卻變得非常低 (6x → 1x),這也是可以預期的,畢竟整個渲染的流程都被延後了,而且因為是寫在 _app,因此每個頁面都會受到影響。

阿你前面不是說絕對分數不重要!?

Lighthouse 有一個特性,就是當分數非常低時,要繼續往下掉其實不容易,也就是說在分數已經很低的狀況下,就算寫出影響效能的程式碼,也許也只會影響個 1–2 分,而這樣微幅的變動很容易被我們給忽略掉,也不能反應出真正造成的影響。這會導致整合 Lighthouse CI 的價值幾乎完全消失了,因此雖然前面過說頁面的絕對分數不重要,不過前提還是要建立在不與真實環境「差太多」的狀況下喔!

於是我只好再探索其他的解決方案,後來想到 Apollo-Client 搭配 http-link 可以自行定義 customizing fetch,意思是我們可以對 data fetching 所需要的 API client 像是 window.fetch、axios 做一些客製化的行為定義

有了這個功能,我們可以將 worker.start 搬到 custom fetch 裡,等待回傳的 Promise 被 resolve 後才能正式發出 request,並且用一個變數判斷 worker 是否已經 enabled,避免每個 request 都需要重新跑一遍 worker.start 的流程。這個方式有一個缺點是當頁面初次渲染時許多 requests 幾乎是同時發出的,對於這些同時發出的 requests 來說,它們當下的 mswEnabled 變數都還是 false,因此 worker.start() 會被執行多次,不過經過測試後發現這個方式可以解決 race condition,Lighthouse CI 檢測出的分數除了穩定以外,也跟真實環境的分數不會相差太多,因此還是決定採用這個方式,不過未來如果 MSW 套件本身就能解決這個問題的話,應該會花一些時間改掉這個 workaround。

終於解決頁面被覆寫的問題,從 DevTool 中也可以發現所有的 API call 都成功被 Service Workers 攔截

CI Pipeline 調整與優化

我們的專案是使用 Drone 這個 CI Platform,透過在 CI Pipeline 設定檔添加一個 Lighthouse CI 專門的 step,就可以在發佈 Pull Request 與後續 commit 時觸發 Lighthouse CI 的檢測。整合完後檢查一下 pipeline 運行的結果

總共耗時 8 分半鐘左右,老實說這是一個非常不及格的時間,代表每次 push code 後都需要經過非常久才能確認 pipeline 是否通過,更不用提如果是在部署環節,在 build-image 這個 step 之後還會比平常再多出一些額外的步驟,這樣的時間花費真的太長了,無形之中降低了開發的效率與順暢度,得盡量優化才行。

執行會那麼耗時主要原因在於幾乎所有 step 都是順序執行,也就是前一步完成才會開始執行下一步,這也是 Drone pipeline 預設的執行方式,然而以每個 step 的依賴關係來看這似乎有很大的調整空間,我們是可以同時間讓多個沒有依賴關係的步驟平行執行的。

在 Drone CI 中可以透過設定 depends_on 這個 property 以 directed acyclic graph 的形式描繪出 steps 間的依賴關係,將 pipeline 中的 steps 以這個方式調整後流程大致變成以下這樣:

我們將 lint, test, lighthouse-ci, build-image 這四個 steps 都加上

depends_on : ['prepare']

它們就會在 prepare 完成後以平行的方式同時執行。

至於 sonar-scan 則是因為需要 test step 產生的 test-coverage 作為依據,因此設定為 depends_on test。

可以特別注意的是優化後的版本比之前 8 分鐘的版本多了一個 lint step,其實它是從 prepare step 抽出來的步驟。在優化前的 prepare step 其實做了三件事:

  • npm ci (安裝套件)
  • npm run codegen (產生 graphql-code-generator 提供的 Type 檔案)
  • npm run lint

然而只有前兩個步驟產生的 artifacts 是後面的 steps 需要用到的,因此把 lint 抽成獨立的步驟理論上可以讓 prepare step 更快完成並觸發其他依賴於它的 step。

提到 artifacts,Drone 會為 pipeline 自動建立一個 shared volume,供 steps 間共用一些產生的檔案,所以在 prepare step 建立的 node_modules 與 type file 都可以被其他 steps 存取,因此這些 steps 不需要再分別跑一次耗時的 npm ci 或 codegen,節省了不少時間。

在優化過後 pipeline 的時間約可以減少到 5 分鐘左右,雖然我覺得還不算及格的時間,一定也有更多的方式可以進一步優化,不過光是搞清楚步驟間的依賴關係就可以減少約 3 分鐘的時間,效果還是不錯的。

讀者應該可以發現其實跑 Lighthouse CI 還蠻耗時的,而且筆者的專案只檢測了三個頁面,每個頁面都各跑一次而已(可以透過 config 設定每次檢測要跑的次數),因此要不要 by PR 的檢測其實正反兩方的意見都有,我自己覺得 Lighthouse CI 的價值是值得這些時間的,但就要謹慎考量要檢測專案中哪些重要的頁面(檢測全部的頁面有點不切實際),還有找到適合的檢測次數囉。

小結

回過頭去看這次整合 Lighthouse CI 造成的改動,其實 code 改的不多,也不會很困難,但依然花了我一個多星期的時間才解決,不過過程真的學到很多,不管是 Service Workers 的應用與 CI Pipeline 的調整都是我這個 Junior 工程師平時比較少有機會接觸到的。另外過程中還遇到了一些瑣碎的 bug,這也讓自己尋找問題與對應解法的能力有所提升,最重要的是 debug 的過程也讓我對 codebase 有了更深度的探索與了解。

本篇的分享還蠻技術限定的(msw, graphql, apollo, drone…等等),我也不確定如果不是使用相似技術堆疊的讀者看完後會不會覺得有所收穫,總之還是期望能夠幫助到花時間閱讀文章的人啦🙏。

最後要感謝我的同事 Ryan,適時的給予我很多方向,不然很多問題在我對專案缺乏深度理解的狀況下可能得花更多時間才有辦法解決。

最後的最後,如果你對 LINE 台灣的職缺有興趣,歡迎到 LINE CAREERS 逛逛喔!

--

--

一群技術人想要寫出一些好文章所建立的技術專欄。每週二一篇原創文章、一封電子報,歡迎大家訂閱!主網站: https://weekly.starbugs.dev/。

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
莫力全 Kyle Mo

莫力全 Kyle Mo

什麼都想學的雜食性軟體工程師 🇹🇼 (https://github.com/kylemocode) 合作與聯繫 📪 oldmo860617@gmail.com