軟體開發時,有 80% 的程式碼在處理各種錯誤。
——某著名開發者
想讓自己的程式碼健壯,錯誤處理是必不可少的。這篇文章將主要介紹 koa 框架中錯誤處理的實現(其實主要是 co 的實現),使用 koa 框架開發 web 應用時進行錯誤處理的一些方法。
基礎
在 Node.js 中,錯誤處理的方法主要有下面幾種:
- 和其他同步語言類似的 throw / try / catch 方法
- callback(err, data) 回撥形式
- 通過 EventEmitter 觸發一個 error 事件
第一種使用 catch 來捕獲錯誤,十分易用,其他兩種在捕獲錯誤時多多少少都有些彆扭。
但是 koa 通過十分巧妙的”黑魔法“讓我們可以使用 catch 來捕獲非同步程式碼中的錯誤。比如下面的例子:
1 2 3 4 5 6 7 8 9 10 |
const fs = require('fs'); const Promise = require('bluebird'); let filename = '/nonexists'; let statAsync = Promise.promisify(fs.stat); try { yield statAsync(filename); } catch(e) { // error here } |
在 koa 中,推薦統一使用 throw / try / catch 的方式來進行錯誤的觸發和捕獲,這會讓程式碼更加易讀,防止被繞暈。
原理
上面我們說了 koa 中可以使用 try / catch,我們就來分析下它是如何做到的。koa 基於 co,所以,我們其實主要是分析 co 的實現。(注:這一部分比較偏原理,不關心的可以跳過。)
首先,我們來看看什麼是 generator。
1 2 3 4 5 6 7 8 9 10 11 |
function* gen() { var a = yield 'start'; console.log(a); var b = yield 'end'; console.log(b); return 'over'; } var it = gen(); console.log(it.next()); // {value: 'start', done: false} console.log(it.next(22)); // 22 {value: 'end', done: false} console.log(it.next(333)); // 333 {value: 'over', done: true} |
帶有 *
的函式宣告表示是一個 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
函式的引數 333
,b
被賦值為 333
。繼續往下執行到 return
語句,將 return
語句的返回值作為 value
返回,因為函式已經執行完畢,done
欄位標記為 true
。
可以看到 generator 就是一種迭代機制,就像一隻很懶的青蛙,戳一下(呼叫 next
)動一下。
generator 物件還有一個 throw
方法,可以在 generator 函式外面丟擲異常,然後在 generator 函式裡面捕獲異常。有點繞?我們來看一個例項:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function *gen() { try { yield 'a'; yield 'b'; } catch(e) { console.log('inside:', e); // inside: [Error: error from outside] } } var it = gen(); it.next(); console.log(it.throw(new Error('error from outside'))); // { value: undefined, done: true } |
我們執行一次 next
,會執行到 yield 'a'
這裡然後暫停,這一句剛好在 try 的返回內,因此 it.throw
丟擲的錯誤我們可以 catch 到。並且看到 throw
返回的 done
欄位是 true
,說明後面的 yield 'b'
已經不會再執行了。
如果我們不呼叫 next
,或者連續呼叫三次 next
,yield
程式碼不在 try
返回裡面,會導致報錯。co 的錯誤處理其實正是利用了這個 throw
方法。
下面我們來看看 co 的核心程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
function co(gen) { var ctx = this; var args = slice.call(arguments, 1); // 統一返回一個整體的 promise return new Promise(function(resolve, reject) { // 如果是函式,呼叫並取得 generator 物件 if (typeof gen === 'function') gen = gen.apply(ctx, args); // 如果根本不是 generator 物件(沒有 next 方法),直接 resolve 掉並返回 if (!gen || typeof gen.next !== 'function') return resolve(gen); // 入口函式 onFulfilled(); function onFulfilled(res) { var ret; try { // 拿到 yield 的返回值 ret = gen.next(res); } catch (e) { // 如果執行發生錯誤,直接將 promise reject 掉 return reject(e); } // 延續呼叫鏈 next(ret); } function onRejected(err) { var ret; try { // 如果 promise 被 reject 了就直接丟擲錯誤 ret = gen.throw(err); } catch (e) { // 如果執行發生錯誤,直接將 promise reject 掉 return reject(e); } // 延續呼叫鏈 next(ret); } function next(ret) { // generator 函式執行完畢,resolve 掉 promise if (ret.done) return resolve(ret.value); // 將 value 統一轉換為 promise var value = toPromise.call(ctx, ret.value); // 將 promise 新增 onFulfilled、onRejected,這樣當新的promise 狀態變成成功或失敗,就會呼叫對應的回撥。整個 next 鏈路就執行下去了 if (value && isPromise(value)) return value.then(onFulfilled, onRejected); // 沒法轉換為 promise,直接 reject 掉 promise return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, ' + 'but the following object was passed: "' + String(ret.value) + '"')); } }); } |
假設有下面的程式碼,讓我們一起推演下執行流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
co(function* gen() { var a = yield Promise.resolve('a 值'); console.log(a); try { var b = yield Promise.reject(new Error('b 錯誤')); var c = yield Promise.resolve('c 值'); console.log(b, c); } catch(e) { console.log('error', e); } return 'over'; }).then(function (value) { console.log(value); }).catch(function (err) { console.error(err.stack); }); |
約定: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)開始之前定義下面的中介軟體:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
app.use(function* (next) { try { yield* next; } catch(e) { let status = e.status || 500; let message = e.message || '伺服器錯誤'; if (e instanceof JsonError) { // 錯誤是 json 錯誤 this.body = { 'status': status, 'message': message }; if (status == 500) { // 觸發 koa 統一錯誤事件,可以列印出詳細的錯誤堆疊 log this.app.emit('error', e, this); } return; } this.status = status; // 根據 status 渲染不同的頁面 if (status == 403) { this.body = yield this.render('403.html', {'err': e}); } if (status == 404) { this.body = yield this.render('404.html', {'err': e}); } if (status == 500) { this.body = yield this.render('500.html', {'err': e}); // 觸發 koa 統一錯誤事件,可以列印出詳細的錯誤堆疊 log this.app.emit('error', e, this); } } }); |
可以看到,我們直接執行 yield* next
,然後 catch
執行過程中任何一箇中介軟體的錯誤,然後根據錯誤的“特性”,分別進行不同的處理。
有了這個中介軟體,我們的業務邏輯 controller 中的程式碼就可以這樣來觸發錯誤:
1 2 3 4 5 6 7 8 9 10 11 |
const router = new (require('koa-router')); router.get('/some_page', function* () { // 直接丟擲錯誤,被中介軟體捕獲後當成 500 錯誤 throw new PageError('發生了一個致命錯誤'); throw new JsonError('傳送了一個致命錯誤'); // 帶 status 的錯誤,被中介軟體捕獲後特殊處理 this.throw(403, new PageError('沒有許可權訪問')); this.throw(403, new JsonError('沒有許可權訪問')); }); |
對 Error 分類
上面的程式碼裡面出現的 JsonError
、PageError
,實際上是繼承於 Error
的兩個構造器。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const util = require('util'); exports.JsonError = JsonError; exports.PageError = PageError; function JsonError(message) { Error.call(this, message); } util.inherits(JsonError, Error); function PageError(message) { Error.call(this, message); } util.inherits(PageError, Error); |
通過繼承 Error
構造器,我們可以將錯誤進行細分,從而能更精細的對錯誤進行處理。