如何優雅的在 koa 中處理錯誤

發表於2016-03-18

如何優雅的在 koa 中處理錯誤

軟體開發時,有 80% 的程式碼在處理各種錯誤。

——某著名開發者

想讓自己的程式碼健壯,錯誤處理是必不可少的。這篇文章將主要介紹 koa 框架中錯誤處理的實現(其實主要是 co 的實現),使用 koa 框架開發 web 應用時進行錯誤處理的一些方法。

基礎

在 Node.js 中,錯誤處理的方法主要有下面幾種:

  • 和其他同步語言類似的 throw / try / catch 方法
  • callback(err, data) 回撥形式
  • 通過 EventEmitter 觸發一個 error 事件

第一種使用 catch 來捕獲錯誤,十分易用,其他兩種在捕獲錯誤時多多少少都有些彆扭。

但是 koa 通過十分巧妙的”黑魔法“讓我們可以使用 catch 來捕獲非同步程式碼中的錯誤。比如下面的例子:

在 koa 中,推薦統一使用 throw / try / catch 的方式來進行錯誤的觸發和捕獲,這會讓程式碼更加易讀,防止被繞暈。

原理

上面我們說了 koa 中可以使用 try / catch,我們就來分析下它是如何做到的。koa 基於 co,所以,我們其實主要是分析 co 的實現。(注:這一部分比較偏原理,不關心的可以跳過。)

首先,我們來看看什麼是 generator。

帶有 * 的函式宣告表示是一個 generator 函式,當執行 gen() 時,函式體內的程式碼並沒有執行,而是返回了一個 generator 物件。

generator 函式通常和 yield 結合使用,函式執行到每個 yield 時都會暫停並返回 yield 的右值。下次呼叫 next 時,函式會從 yield 的下一個語句繼續執行。等到整個函式執行完,next 方法返回的 done 欄位會變成 true,並且將函式返回值作為 value 欄位。

第一次執行 next() 時,走到 yield 'start' 後暫停並返回 yield 的右值 'start'。注意,此時var a = 這個賦值語句其實還沒有執行。

第二次執行 next(22) 時,從 yield 'start' 下一個語句執行。於是執行 var a = 這個賦值語句,而表示式 yield 'start' 的值就等於傳遞給 next 函式的引數值 22,所以,a 被賦值為 22。然後繼續往下執行到 yield 'end' 後暫停並返回 yield 的右值 'end'

第三次執行 next(333) 時,從 yield 'end' 下一個語句執行。此時執行 var b = 這個賦值語句,表示式 yield 'end' 的值等於傳遞給 next 函式的引數 333b 被賦值為 333。繼續往下執行到 return 語句,將 return 語句的返回值作為 value 返回,因為函式已經執行完畢,done 欄位標記為 true

可以看到 generator 就是一種迭代機制,就像一隻很懶的青蛙,戳一下(呼叫 next)動一下。

generator 物件還有一個 throw 方法,可以在 generator 函式外面丟擲異常,然後在 generator 函式裡面捕獲異常。有點繞?我們來看一個例項:

我們執行一次 next,會執行到 yield 'a' 這裡然後暫停,這一句剛好在 try 的返回內,因此 it.throw 丟擲的錯誤我們可以 catch 到。並且看到 throw 返回的 done 欄位是 true,說明後面的 yield 'b' 已經不會再執行了。

如果我們不呼叫 next,或者連續呼叫三次 nextyield 程式碼不在 try 返回裡面,會導致報錯。co 的錯誤處理其實正是利用了這個 throw 方法。

下面我們來看看 co 的核心程式碼:

假設有下面的程式碼,讓我們一起推演下執行流程:

約定:Promise.resolve('a 值') 生成的是 promiseA;Promise.reject(new Error('b 錯誤')) 生成的是 promiseB。

首先傳入 co 的 gen 函式會被執行,獲取到 generator 物件。對應程式碼:if (typeof gen === 'function') gen = gen.apply(ctx, args);

然後呼叫 onFulfilled 函式。開啟整個執行過程。

第一次執行 ret = gen.next(res),走到 yield Promise.resolve('a 值') 後暫停並返回 yield 的右值,此時 ret 等於 {value: PromiseA, done: false}

然後執行 next(ret),將 ret.value 轉換為 Promise,執行 value.then(onFulfilled, onRejected),也就是 PromiseA.then(onFulfilled, onRejected)。當我們的 PromiseA 被 resolve 後,又再次執行 onFulfilled,並傳入 resvole 的值,也就是:onFulfilled('a 值')

於是第二次執行 ret = gen.next('a 值')(此時的 res 就等於 a 值),進入到 gen 函式,執行接下來的 var a = 賦值語句,yield Promise.resolve('a 值') 的返回值等於給 next 傳遞的引數 'a 值',於是變數 a 被賦值為 'a 值'。繼續執行到 yield Promise.reject(new Error('b 錯誤')) 後暫停並返回 yield 的右值,此時 ret 等於 {value: PromiseB, done: false}

繼續執行 next(ret),延續呼叫鏈。執行 value.then(onFulfilled, onRejected),也就是 PromiseB.then(onFulfilled, onRejected)。這次 PromiseB 被 reject 掉了,於是執行 onRejected,並傳人 reject 的錯誤原因,也就是:onRejected(new Error('b 錯誤'))

於是執行到 ret = gen.throw(new Error('b 錯誤')),而此時 yield Promise.reject(new Error('b 錯誤')) 剛好在 try 的範圍內,錯誤被 catch 住了!接著就執行 catch 裡面的列印語句 console.log('error', e);,一路執行到函式結束(因為再也沒有 yield 了),將返回值賦給 value。最後 ret 等於 {value: 'over', done: true}

繼續執行 next(ret),延續呼叫鏈。執行到 if (ret.done) return resolve(ret.value);,於是整體的 promise 被 resolve 掉,執行 then 裡面的列印語句,列印出 ret.value 的值 'over'。整個流程結束。

如果我們不 try / catch 會怎樣?因為 onRejected 裡面有是這樣處理的:try { ret = gen.throw(err); } catch (e) { return reject(e); }。我們上面說如果 yield 沒有在 try裡會導致 gen.throw 報錯,於是整體 promise 被 reject,執行其 catch 方法,列印出 Error('b 錯誤') 的堆疊。

這就是“黑魔法”的神祕面紗!對 TJ 大神真是一個大寫的“服”字。

什麼錯誤該處理和怎麼處理

接下來的問題是什麼樣的錯誤我們需要處理?怎麼處理?我們可以將錯誤分個類:

  • 操作錯誤:不是程式 bug 導致的執行時錯誤。比如:連線資料庫伺服器失敗、請求介面超時、系統記憶體用光等等。
  • 程式錯誤:程式 bug 導致的錯誤,只要修改程式碼就可以避免。比如:嘗試讀取未定義物件的屬性、語法錯誤等等。

很顯然,我們真正需要處理的是操作錯誤,程式錯誤應該馬上進行修復。

那怎麼處理操作錯誤呢?總結起來大概有下面這些方法:

  • 直接處理。這個簡直是廢話。舉個例子:嘗試向一個檔案中寫東西,但是這個檔案不存在,那這個時候會報錯吧?處理這個錯誤的方法就是先建立好要寫入的檔案。如果我們知道怎麼處理錯誤,那直接處理就是。
  • 重試。有時候某些錯誤可能是偶發的(比如:連線的服務不穩定等),我們可以嘗試對當前操作進行重試。但是一定要設定重試的超時時間、次數,避免長時間的等待卡死應用。
  • 直接將錯誤拋給呼叫方。如果我們不知道具體怎麼處理錯誤,那最簡單的就是將錯誤往上拋。比如:檢查到使用者沒有許可權訪問某個資源,那我們直接 throw 一個 Error(並帶上 status 是 403)比較好,上層程式碼可以 catch 這個錯誤,然後要麼展示一個統一的無許可權頁面給使用者,要麼返回一個統一的錯誤 json 給呼叫方。
  • 寫日誌然後將錯誤丟擲。這種情況一般是發生了比較致命的錯誤,沒法處理,也不能重試,那我們需要記下錯誤日誌(方便以後定位問題),然後將錯誤往上拋(交給上層程式碼去進行統一錯誤展示)。

使用中介軟體統一處理錯誤

有了上面的說明,那現在我們就來看看在 koa 裡面怎麼優雅的實現統一錯誤處理。

答案就是使用強大的中介軟體!

我們可以在業務邏輯中介軟體(一般就是 MVC 中的 Controller)開始之前定義下面的中介軟體:

可以看到,我們直接執行 yield* next,然後 catch 執行過程中任何一箇中介軟體的錯誤,然後根據錯誤的“特性”,分別進行不同的處理。

有了這個中介軟體,我們的業務邏輯 controller 中的程式碼就可以這樣來觸發錯誤:

對 Error 分類

上面的程式碼裡面出現的 JsonErrorPageError,實際上是繼承於 Error 的兩個構造器。程式碼如下:

通過繼承 Error 構造器,我們可以將錯誤進行細分,從而能更精細的對錯誤進行處理。

參考資料

相關文章