斷點除錯之壓縮引發的血案

公子發表於2022-02-07

前段時間組裡的小夥伴讓我幫忙排查一個線上問題,我覺得排查流程比較有意思,想著記錄一下看看是否能對其它同學有所幫助,遂有此文。

事情的起因是前幾天線上突然收到一個報警,錯誤內容是 TypeError: C.fn is not a function。相關同學嘗試排查無果後又回滾了最近上線的變更也沒有排查到問題。雖然最終確認了復現路徑,但是在本地卻無法復現。

? 初步排查

線上上復現該錯誤後,點選錯誤堆疊的檔案跳轉,快速定位到線上出錯的程式碼。由於線上都是壓縮過的程式碼,這裡我們可以點選左下角的 {} 進行程式碼美化。

經過美化後我們可以看出來,應該就是 189624 行出了問題。我們直接嘗試在這一行上打斷點,之後會發現程式碼會在這塊瘋狂打轉。這是因為它處於一個 for 迴圈中。仔細觀察不難看出程式碼其實上是 this.head 這個鏈的遞迴執行,每次執行完當前 C 都會被賦值成鏈的下一個值,並執行該值對應的 fn() 方法。也就是問題是這個鏈上的某個值沒有 fn() 方法,最終導致了這個報錯。

大概確認問題後,我們需要看一下最終這個 C 的值是什麼。由於處在迴圈當中,一次一次的點選下一步實在是麻煩。由於我們有明確的目標,所以我們可以嘗試新增條件斷點,讓只有符合我們條件的斷點才停下來,否則都忽略正常執行。

在 189624 行右鍵點選 Add conditional breakpoint... 選項,並輸入 typeof C.fn !== 'function' 作為條件表示式。這樣我們就實現了一個僅在 C.fn 不是一個方法的時候才會觸發的條件斷點。

條件斷點觸發後,我們可以在控制檯中基於斷點時的上下文輸出變數進行除錯。可以從下左圖我們可以清晰的看到,此時的 C.fn 的確是不存在的。

由於剛才我們已知 this.head 應該是一條鏈,依次執行鏈上的方法。所以理論上來說鏈上的每個元素都是一樣的。於是乎我就嘗試輸出了 this.head 鏈上所有的元素想看一下這個鏈到底是什麼樣子的。模擬程式碼裡的迴圈我也在控制檯嘗試寫了下,發現輸出的結果如下左圖展示。在鏈的最後一個元素就是我們有問題的元素。

而之前我們已知的是在本地開發環境是無法復現這個問題的,所以我照貓畫虎在本地同樣的位置也輸出了一下 this.head 鏈,結果見上右圖。發現和線上輸出的,除了最後這個有問題的元素,其它的輸出基本是一樣的。

看來問題的原因就在於線上的程式碼執行在鏈上增加了這麼一個玩意導致的,而本地由於沒有這個多餘的元素所以沒有觸發問題。

? 確認問題

找到原因後我就想著從程式碼層面捋一下是哪裡給增加了這麼個玩意。由於之前的程式碼中可以明顯的看到 i.prototype.finish 的字樣,初步猜測這應該是一個類的定義。於是乎就想看看這個類是在哪裡例項化執行的。

通過剛報錯時的壓縮後的程式碼,我們可以看到報錯的模組是”protobuf.js“這個模組。於是乎我在專案和依賴中查詢是哪個模組依賴了它,最終查到了是我們內部使用的一個 IM 訊息模組有用到。

之後在具體的依賴模組中搜尋 .finish() 相關字樣,查到了最終的呼叫在如下地方。serialize() 方法會呼叫 Request.encode() 方法,它返回一個 $Writer 基類的例項,而 $Writer 就是 protobuf.js 模組中的 Writer 基類。Request.encode() 方法例項化完 Writer 基類後會執行一系列的成員函式,執行完畢後會返回 Writer 例項,並呼叫它的 finish() 方法。

瞭解執行流程之後,我就順著 Request.encode(req).finish() 這一句開始向上對 Request.encode() 方法進行斷點(下左圖)。如下圖先嚐試在末尾斷點輸出 o.heado 是壓縮後指向 Writer 例項的變數),發現此時已經存在異常鏈元素了(下右圖)。

中間的程式碼稍微打了下斷點發現也依舊如此。最終在頭部斷點處發現了端倪。嘗試在開頭增加斷電之後,發現在 120274 行執行完畢之後 o.head 鏈上就已經存在了異常資料了。

那我們嘗試翻看下程式碼看一下 o.create() 方法具體幹了什麼。從下圖左我們可以看到 Writer.create() 本質其實就是 Writer 基類的例項化工廠方法。而下圖中可以看到 Writer 的構造方法對一些成員屬性賦了初值。其中關鍵的 this.head 的初值是一個 Op 基類的例項。下圖右可以看到 Op 基類的構造方法中也是賦了一些初值。同時我們可以看到 function noop() {} 實際上就是一個空方法。也就是說 this.head 預設指向了一個空方法例項化的 Op 物件。

乍一看整個流程其實非常簡單,本質上建構函式內都是一些簡單的賦值操作,不會有什麼問題。於是乎還是按照鏈路依次向上排查問題。因為上一趴我們排查到執行完 Writer.create() 工廠方法後就有問題了,所以這裡我們需要對 Writer 的建構函式進行斷點排查。

嘗試如下圖在構造方法末尾斷點後,輸出 this.head 鏈,發現此時已經有異常資料了。而這個時候不過只是做了初值的操作而已,這怎麼就能出問題了呢?由於斷點情況下我能在當前上下文中進行除錯,所以此時我嘗試自己執行一下 Op 基類的例項化操作(見下圖)。這時候發現確實它的 next 屬性不對,是我們要找的問題元素!

此時此刻,我感覺我們已經越來越接近真相了!

如下圖左我們在 f 變數上 hover 一會兒,會出現它的定義處連結,點選後會直接跳轉到它的定義處下圖右(其實就離的不太遠)。

大家可能也都注意到了,我們剛才看的程式碼中 this.next 明明是定義成 undefined 怎麼這裡給定義成 g 了?而這個 g 又對上了 189456 行 g = s.base64,所以我們才看到 this.head.next 的值這麼奇怪。而我們嘗試看一下引用的 protobuf.js 程式碼,發現程式碼裡 this.next 雖然是等於 g 但是它並沒有關聯到 u.base64 上。

由於我之前有解決過一些壓縮再壓縮後程式碼異常的 Case,所以至此我基本上可以斷定,由於 protobuf.js 在我們的依賴中是引入的壓縮後的程式碼,而壓縮後的程式碼再走壓縮導致了變數指向出現錯亂從而導致的問題。這也側面印證了為什麼只有線上可以,本地無法復現的原因。因為本地是沒有走壓縮的。

? 如何解決

找到問題後有兩種解決方法。一是正向的去查詢壓縮工具造成這個問題的原因;二是反向的去規避該問題,我們不引入壓縮後的程式碼而是正常引入未壓縮的程式碼,最終統一由專案進行壓縮處理。

這兩種方法都能解決問題。而第一種需要的時間會比較久,所以我們先採用了第二種方法臨時解決一下。由於該依賴包不是我們維護的,我們只能使用 patch-package 給模組打補丁的方式進行修復。它的功能是在安裝完依賴後會根據我們的 diff 檔案對依賴進行修改。

這裡我們的修改比較簡單,找到我們依賴模組引入 protobuf.min.js 的地方,將其修改成 protobuf.js 即可。

? 後記

undefined 在壓縮後就變成了 g 這個初步猜想應該是本地想要定義一個沒有定義的變數,這樣就是 undefined 了。我嘗試克隆了下 protobuf.js 倉庫進行了嘗試,發現應該是 UglifyJS 中配置了 marguel.eval 導致有這個特性。

以上就是壓縮造成的血案完整的排查經過,整個的過程總結一下有以下幾個經驗可以供大家參考:

  1. 除了單步斷點,我們還有條件斷點、日誌斷點等多種斷點方式幫助我們排查問題,合理使用會加速我們排查問題的速度。
  2. 斷點後當前 JS 環境會停留在當時的上下文中,我們可以在控制檯執行、輸出我們想要的當時環境的資料幫助排查。
  3. 控制檯中我們也可以 hover 檢視定義位置,進行定義間快速跳轉。
  4. 壓縮後的程式碼不可怕,我們可以通過原始碼對比,無法壓縮的關鍵字進行定位查詢。
  5. 只要是可以復現的問題,那都不是問題!

最後祝大家開工大吉,新的一年沒有 Bug!

相關文章