利用Decorator和SourceMap優化JavaScript錯誤堆疊

寒月十八發表於2020-09-02

配合原始碼閱讀體驗更佳。

最近收到使用者吐槽 @cloudbase/js-sdk(雲開發Cloudbase的JavaScript SDK)的報錯資訊不夠清晰,比如下面這條報錯:

這屬於業務型報錯,對於熟悉雲開發能力細節的使用者一眼就能看出錯誤的癥結出在安全規則配置上,但是對於剛接觸雲開發的新使用者或者之前沒有遇到類似問題的使用者來說,看到這樣簡短的錯誤資訊肯定會一頭霧水,分不清楚到底是業務報錯還是程式碼寫的不對。所以大部分人的第一反應是按照Error的堆疊資訊進行debug,試圖找到丟擲Error的具體程式碼。然後就會遇到另一個讓人頭疼的問題:Error堆疊太深了,要想找到是哪一行程式碼引起的報錯並不是一件很容易的事。

雖然雲開發是一款toB的產品,相對來說B端開發者的容忍度會「略」高於C端使用者,但是糟糕的開發體驗肯定是會拉低開發者對產品的好感和認可度。所以優化報錯資訊成了一件必須要做的事情。

在詳述優化方案之前,先看一下最終的優化效果:

圖中列印的錯誤跟第一張圖是同一個,代表當前的登入型別受到函式的安全規則限制,導致沒有呼叫函式的許可權。錯誤資訊分為兩部分:

  • 上半部分的黑色字型提示包含了後端 API 返回的錯誤資訊以及針對此類問題的一些解決方案建議;
  • 下半部分的紅色字型是經優化後的錯誤堆疊,第一條直接定位到 SDK 原始碼index.ts),第二條直接定位到呼叫報錯 API 的業務原始碼App.callFn)。

看到index.ts這樣的資訊估計大部分人都明白這裡用到了SourceMap。確實SourceMap是支撐這套優化方案的必備要素,藉助SourceMap可以定位到SDK的原始碼。但只有SourceMap是不夠的,優化的核心點在於:如何把原始錯誤冗長的堆疊中直接定位到關鍵程式碼行?

這就是優化的目標。

有了目標之後的第一步要做的不是立即去扣實現細節,而是設計整體方案,包括兩部分:

  • 第一是確定優化的物件。
    是不是所有的型別的報錯堆疊都需要優化?答案是否定的。優化的物件應該是業務報錯,具體到程式碼就是SDK的public API。其他型別的錯誤(比如SDK自身的語法錯誤)是應該在釋出SDK之前開發團隊自測解決的,不應該被帶給使用者。只針對業務報錯這一前提給優化方案一個基調:所有的錯誤資訊格式是固定的(如果做不到這一點就說明SDK不合格)。
  • 第二是確定接入方式。
    優化的目的是改善體驗,必須做到一點:不侵入SDK的原本邏輯。這個前提堵死了一條最容易也是最笨的路:直接改SDK的API程式碼,對所有的關鍵程式碼塊加一層try-catch。所以接入的方式必然是一種類似外掛的機制,並且成本低、可定製。

除了以上兩點之外,還有一個重要的問題需要提前確定:Error應該在SDK程式碼的什麼位置丟擲?舉個例子,當業務程式碼呼叫SDK提供的callFunction API後,SDK內部再發起網路請求之前有一些前序邏輯,比如判斷入參是否正確、獲取本地登入態資訊等等。如果不做任何處理的話,當發生錯誤時丟擲的Error堆疊是最內層的程式碼行,如下圖:

但是使用者關心的只是callFunction成功還是失敗,不會在意這個API內部是如何工作的,內層的Error堆疊對於使用者來說沒有任何幫助甚至由於加深了堆疊層級反而加重了debug難度。所以期望最佳的效果是由callFunction所在的程式碼行丟擲Error,最笨的實現方案就是為callFunction的邏輯塊整體包一層try-catch統一丟擲Error,但可惜這條路已經被堵死了。

那麼剩下的唯一辦法就是精簡由內層邏輯丟擲的Error的堆疊,把內層邏輯的堆疊全部剔除,只保留到最外層的callFunction

梳理一下上面的內容可以得出優化方案的關鍵資訊:

選項 說明
優化物件 只針對業務型邏輯報錯,錯誤格式固定
接入方式 不侵入SDK原本邏輯,使用類似外掛的機制
預期目標 精簡Error堆疊,剔除無用條目直接定位到 SDK 的 API程式碼行

精簡Error堆疊的基本思路是在SDK的API程式碼塊內捕獲內層邏輯丟擲的Error,然後重新new一個Error物件丟擲,這種方式可以將內層邏輯的堆疊全部消除。實現方式也很簡單,在API程式碼塊內用try-catch包裝記憶體邏輯即可,但這樣會涉及修改API原本邏輯,而且工作量也不小,所以行不通。

即不侵入API原本邏輯,又能夠影響API的表現,首先想到的便是裝飾器Decorator。

Decorator

Decorator的優勢有兩點:

  1. 不侵入SDK原本邏輯,接入成本很低,只需要幾行程式碼;
  2. TypeScript將Decorator編譯為ES5語法之後有固定的格式,可以方便地在Error堆疊中找出對應的程式碼行,為精簡Error堆疊提供便利。

寫到這裡其實大體的思路就定型了,步驟如下:

  1. 給API新增Decorator;
  2. 在Decorator內將API重新賦值,保持原本邏輯的前提下,為原本邏輯包裝try-catch

大致程式碼如下:

function catchErrorsDecorator(options){
  return function(
    target: any,
    methodName: string,
    descriptor: TypedPropertyDescriptor<Function>
  ){
    const fn = descriptor.value;
		// 重新被裝飾的API原本邏輯
    descriptor.value = function(...args:any[]) {
        try {
          return fn.apply(this, args);
        } catch (err) {
          throw err;
        }
      }
  }
}

然後為API新增裝飾器:

class Cloudbase {
  @catchErrorsDecorator({
    // ...options
  })
  public init(){
    // ...
  }
}

這樣修改後呼叫API的行為方式被修改為執行Decorator的邏輯。但是在Decorator的catch程式碼塊中丟擲的Error物件沒有經過任何處理,仍然是API丟擲的Error物件,也就是說同樣攜帶著API內層邏輯的堆疊資訊。接下來的工作就是想辦法把堆疊資訊精簡。

精簡Error堆疊

首先縷一下當附加Decorator的API被呼叫時的堆疊順序,同樣是以上文提到的callFunction為例,當外層業務邏輯呼叫這個API時整體的鏈路如下圖所示:

這只是原始碼的鏈路,實際上使用TypeScript或ES6語法編寫的原始碼需要經過語法轉換或者引入polyfill才能在瀏覽器中執行,所以實際上的鏈路長度遠遠大於上圖,尤其是async函式(因為目前的語法轉譯通常會把async/await轉化為generator)。這也是造成錯誤堆疊層次太深的主要原因之一。

上文提到的catchErrorsDecorator的工作分兩步:

  • 第一步是Decorator自身的邏輯,也就是複寫API原本邏輯的程式碼塊,這一步是給API新增Decorator之後立即執行的;
  • 第二步是當外層邏輯呼叫callFunction之後,執行descriptor.value內部邏輯。

這兩個步驟並不是連續的,而是分屬於兩條鏈路,第一條發生在SDK初始化時,第二條發生在外層邏輯呼叫API時。

在SDK初始化的鏈路內,Decorator的第一步邏輯的前序環節是初始化被裝飾的API,所以在這裡可以拿到原API的原始碼行,可以藉助Error.stack取到,如下:

/**
 * decorate在stack中一般都特定的規範
 */
const REG_STACK_DECORATE = isFirefox ? 
  /(\.js\/)?__decorate(\$\d+)?<@.*\d$/ : 
  /(\/\w+\.js\.)?__decorate(\$\d+)?\s*\(.*\)$/;
const REG_STACK_LINK = /https?\:\/\/.+\:\d*\/.*\.js\:\d+\:\d+/;

function catchErrorsDecorator(options){
  return function(
    target: any,
    methodName: string,
    descriptor: TypedPropertyDescriptor<Function>
  ){
    let sourceLink = '';
    const outterErrStacks = (new Error()).stack.split('\n');
    const indexOfDecorator = outterErrStacks.findIndex(str=>REG_STACK_DECORATE.test(str));
    if(indexOfDecorator!==-1){
      const match = REG_STACK_LINK.exec(outterErrStacks[indexOfDecorator+1]||'');
      sourceLink = match?match[0]:'';
    }
    const fn = descriptor.value;
		// 重新被裝飾的API原本邏輯
    descriptor.value = function(...args:any[]) {
      	const innerErr = getRewritedError({
          err: new Error(),
          className,
          methodName: fnName,
          sourceLink
        })
        try {
          return fn.apply(this, args);
        } catch (err) {
          
          throw err;
        }
      }
  }
}

之所以把獲取原API程式碼行的邏輯放在Decorator的第一步,是由於此時距離原API的堆疊層數比較淺,而如果放到第二步(即descriptor.value內部)獲取,則有可能由於堆疊太深取不到。

這裡需要說明的一點,獲取原API程式碼行是通過匹配Error.stack資訊。呼叫throw Error或console.error後在瀏覽器的控制檯列印的堆疊是完整的,但是瀏覽器在返回Error.stack資訊時並不是將全部的堆疊返回,而是隻返回最前列的幾條,一般是5-10條。這也是為何將獲取原API程式碼行的邏輯放在descriptor.value外執行的主要原因。

另外在上述程式碼中新增了如下一段邏輯:

const innerErr = getRewritedError({
  err: new Error(),
  className,
  methodName: fnName,
  sourceLink
})

其中工具函式getRewritedError的作用是在Error.stack中找到執行descriptor.value前一條資訊,這條資訊便是外層邏輯呼叫callFunction時執行被複寫的callFunction API的程式碼行,而這條資訊之前(Error堆疊是倒序排列)的所有堆疊都是callFunction的內層邏輯,是要被剔除的無用資訊。

getRewritedError函式的程式碼比較長就不寫了,感興趣的可以去看原始碼

接下來的工作就簡單了,從Error.stack中過濾無用的資訊,然後把descriptor.value條目的連結替換為先前拿到的原API程式碼行,最後new一個Error物件將其stack替換為處理之後的在丟擲即可。

邊角料工作

截止到這裡,優化工作的核心內容就已經完成了,剩下的就是完善一下邏輯支援更豐富的場景,比如:

  • 支援同步和非同步兩種模式;
  • console.group列印錯誤資訊和解決方案建議;
  • 相容多種構建工具(Webpack和Rollup,不同的構建工具混淆後的Decorator堆疊有略微差異);
  • 相容多種瀏覽器(不同瀏覽器核心的堆疊格式有差異)

等等。這些小事就不寫了,感興趣的可以去閱讀原始碼

最終的接入方式就是import這個Decorator,然後為API新增裝飾器,如下:

class Cloudbase {
  @catchErrorsDecorator({
    //同步模式
    mode: 'sync', 
    // title和message是錯誤提示資訊,可定製
    title: 'Cloudbase 初始化失敗', 
    messages: [
      '請確認以下各項:',
      '  1 - 呼叫 cloudbase.init() 的語法或引數是否正確',
      '  2 - 如果是非瀏覽器環境,是否配置了安全應用來源(https://docs.cloudbase.net/api-reference/webv2/adapter.html#jie-ru-liu-cheng)',
      `如果問題依然存在,建議到官方問答社群提問或尋找幫助:${COMMUNITY_SITE_URL}`
    ]
  })
  public init(options){
    // ...
  }
}

最後值得一提的是,這種優化方案只支援在開發環境下使用,一是因為邏輯比較繁瑣,帶入到生產環境中會產生不必要的資源消耗;二是由於生產環境的js通常是所有模組打包到一起並且經過混淆,造成堆疊資訊難以定位。

相關文章