我的博客:blog.skrskrskrskr.comhtml
軟件程序中,咱們能夠將錯誤大體分爲外部錯誤和內部錯誤兩大類。node
外部錯誤是正確編寫的程序在運行時產生的錯誤。它並非程序自己的 bug,更可能是一些外部緣由致使的問題,好比請求超時、服務器返回 500、內存不足等。git
而內部錯誤是程序裏的 bug。好比傳參類型錯誤、讀取 undefined 的一個屬性等。這類問題跟你選擇的開發語言、開發者的編程經驗、系統複雜度等因素息息相關,雖然沒法避免,但能夠經過修改代碼來修復它。程序員
對應到 Node.js 程序上,通常遇到如下四類錯誤:github
注:本文中不區分錯誤和異常,都將其統稱爲錯誤。redis
當錯誤發生後,咱們須要第一時間去處理它。針對不一樣類型的錯誤,有不一樣的措施。處理錯誤的整體原則:npm
程序運行過程當中,可能會遇到各類外部因素致使的問題,這些問題須要具體問題具體分析。咱們沒辦法保證外部服務提供方的穩定性,可是遇到此類問題時,能夠作一些事情,來保證咱們的程序不至於直接崩潰。編程
舉個例子,秒殺場景的業務常常會承受很是大的 QPS,在一波瞬間大流量的衝擊,後端服務扛不住的話會報 5XX 錯誤。在後端服務掛掉後,咱們可能會去讀 redis 等緩存中的數據,用舊數據來兜底。而當 Node.js 應用也掛掉了,還能夠在 Nginx 層進行 CDN 降級,給用戶輸出一個兜底的靜態頁。bootstrap
還有些來自後端服務的錯誤,只須要進行簡單的重試就能解決。若是要重試的話,要肯定重試的次數,以及重試的間隔。後端
有人建議在發生錯誤後直接崩潰掉,防止錯誤擴散。我的認爲實際上是不合理的,會下降服務的可用性。咱們能夠在出現一些嚴重的錯誤後,先記錄下錯誤,而後重啓進程。在 Node.js 中,未捕獲的 JavaScript 異常一直冒泡回到事件循環時,會觸發 process.uncaughtException 事件。咱們能夠在事件回調中作錯誤上報,而後重啓 Node.js 進程。這時,還須要藉助 Cluster 來啓動多個 Node 進程,保證單進程崩潰重啓不會影響總體服務的可用性。實際的生產環境中,使用 PM2 來管理 Node.js 進程是一個更好的選項。
咱們永遠也沒法阻止外部錯誤,它跟你的業務場景、用戶終端等各類不可控的因素相關。可是咱們若是作好監控、告警、日誌、緩存等工做,能夠方便程序員迅速定位/解決問題,從而將損失降至最低。
對於 JavaScript 錯誤,咱們可使用 throw 拋出,並用 try catch 來捕獲住。
try {
throw new Error('some error')
} catch(e) {
console.error(e)
}
複製代碼
並且對於 throw 拋出的異常必需要 try catch 包裹,不然 Node.js 進程會直接退出。這種寫法能夠獲取到完整的錯誤調用堆棧。好比:
fs.js:115
throw err;
^
Error: ENOENT: no such file or directory, scandir '/Users/frank/code/work/wxapp/src/componentsa'
at Object.readdirSync (fs.js:783:3)
at getDirFilePaths (/Users/frank/code/m/demo/readdir.js:8:22)
at Object.<anonymous> (/Users/frank/code/m/demo/readdir.js:27:15)
at Module._compile (internal/modules/cjs/loader.js:688:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:699:10)
at Module.load (internal/modules/cjs/loader.js:598:32)
at tryModuleLoad (internal/modules/cjs/loader.js:537:12)
at Function.Module._load (internal/modules/cjs/loader.js:529:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:741:12)
at startup (internal/bootstrap/node.js:285:19)
複製代碼
衆所周知,JS 函數調用會造成一系列的棧幀,爲了儘量的恢復錯誤發生現場,最好在錯誤上報時帶上堆棧信息。Node.js 中,Error.captureStackTrace()
方法是 v8 引擎暴露出來的,處理錯誤堆棧信息的 API。
Error.captureStackTrace(targetObject[, constructorOpt]) 在 targetObject 中添加一個 .stack 屬性。對該屬性進行訪問時,將以字符串的形式返回 Error.captureStackTrace() 語句被調用時的代碼位置信息(即:調用棧歷史)。
值得注意的是,它的第二個參數能夠用來控制棧幀的終點。在一些底層庫中,這個參數能夠用來向開發者隱藏內部實現細節。
實際的生產環境中,咱們可使用 nested-error-stacks
這類 npm 包來採集堆棧信息,原理其實也是基於 Error.captureStackTrace()。
這裏有個問題是:try catch 代碼塊是同步的,對於異步 API 發生的錯誤,它不能捕獲到。
好比下列代碼:
try {
setTimeout(() => {
throw new Error('some error')
}, 1000)
} catch(e) {
console.log('some error...')
}
複製代碼
錯誤並不能被捕獲住。這個跟 Node.js 的事件循環機制有關,由於異步任務是經過事件隊列來實現的,每次從事件隊列中取出一個函數來執行時,實際上這個函數是在調用棧的最頂層執行的,若是它拋出了一個異常,也是沒法沿着調用棧回溯到這個異步任務的建立者的。
下面介紹下在異步流程中,咱們應該怎麼處理錯誤。
Node.js 中常見異步場景包括三類:
大部分異步 API 都遵循錯誤回調優先的約定,將 Error 做爲 callback 的第一個參數來傳遞,這種風格比較相似函數式編程中的 Continuation-passing style。
fs.readFile(path, 'r', (err, data) => {
if (err) {
throw err
} else {
try {
// handle data
} catch(e) {
}
}
})
複製代碼
這種寫法很容易形成回調地獄。另外一方面,對於回調函數中的同步邏輯,咱們還須要用 try catch 去單獨處理,這致使錯誤邏輯的處理被分散了兩處。Promise 被正式 ES6 標準化後,咱們能夠用 Promise 的鏈式調用來處理錯誤。
new Promise((resolve, reject) => {
reject(new Error('some error'));
})
.then(() => {
...
})
.then(() => {
...
})
.catch(err => {
});
複製代碼
這樣,Promise 鏈上的錯誤都會在 catch 方法上捕獲住。對於沒有 catch 的 Promise 異常,會一直冒泡到頂層,在 process.unhandledRejection
事件上被捕獲住。
還有一類是 EventEmitter 對象上的錯誤。它們會被分發到 error 事件上進行處理,好比 Stream 等。咱們須要去爲每個流去監聽 error 事件,不然會冒泡到process.uncaughtException 事件上去。
異步場景中,還有個問題就是,會丟失異步回調前的錯誤堆棧。緣由仍是上文提到的 Node.js 事件循環機制。
const foo = function () {
throw new Error('some error')
}
const bar = function () {
setTimeout(foo)
}
bar()
複製代碼
輸出結果:
Error: some error
at Timeout.foo [as _onTimeout] (/Users/frank/code/m/demo/readdir.js:47:9)
at ontimeout (timers.js:436:11)
at tryOnTimeout (timers.js:300:5)
at listOnTimeout (timers.js:263:5)
at Timer.processTimers (timers.js:223:10)
複製代碼
能夠看到丟失了 bar 的調用棧。然而在 Node.js 中,異步調用場景還挺多的,有什麼辦法能夠將多個異步調用給串起來,獲取到完整的調用鏈信息呢?答案是有的。Node.js v8+ 上提供了 async_hooks 模塊,用來完善異步場景的監控。
async_hooks 提供了一些 API 用於跟蹤 Node.js 中的異步資源的生命週期。有幾個概念:
console.log('global.asyncId:', async_hooks.executionAsyncId()); // global.asyncId: 1
console.log('global.triggerAsyncId:', async_hooks.triggerAsyncId()); // global.triggerAsyncId: 0
fs.open('./app.js', 'r', (err, fd) => {
console.log('fs.open.asyncId:', async_hooks.executionAsyncId()); // fs.open.asyncId: 7
console.log('fs.open.triggerAsyncId:', async_hooks.triggerAsyncId()); // fs.open.triggerAsyncId: 1
});
複製代碼
回調函數中的 triggerAsyncId 爲 1,它等於 global scope 上的 asyncId。這樣就能夠拿到多個異步調用的調用鏈。
國內的趙坤大神寫過一個 koa 日誌中間件 koa-await-breakpoint,用於實如今每一個 await 執行的語句先後進行自動打點工做。
// On top of the main file
const koaAwaitBreakpoint = require('koa-await-breakpoint')({
name: 'api',
files: ['./routes/*.js']
})
const Koa = require('koa')
const app = new Koa()
// Generally, above other middlewares
app.use(koaAwaitBreakpoint)
...
app.listen(3000)
複製代碼
每一個請求到來時,生成一個 requestId 掛載到 ctx 上,經過 requestId 將日誌串起來。核心原理是 hack 了模塊的 require 方法(重載 Module.prototype._compile),用 esprima 將模塊代碼轉成 AST,找到其中的 awaitExpression 節點,對其用日誌函數包裹後從新插入到 AST,最後用 escodegen 將 AST 生成代碼。其中還用到了 async_hooks,在日誌函數中,基於 async_hooks 的 init 鉤子中將異步調用關係存儲到一個 Map 中,最終實現函數調用鏈的自動日誌打點。
不過,使用 async_hooks 在目前有較嚴重的性能損耗。建議生產環境慎用。下圖展現了 Node.js 9.4.0 上,async_hooks 在 Hapi 和 Koa 這兩款框架上的性能比較。
錯誤可分爲外部錯誤和內部錯誤兩類。對外部錯誤的處理主要考驗系統架構的設計,只有系統設計的足夠健壯,纔可以抵禦各類外部挑戰,並損失降到最低。對於內部錯誤,本文分別討論了同步和異步兩種場景,介紹了 Error.captureStackTrace()
、async_hooks
等 API 在收集錯誤堆棧、異步調用鏈上的用途,並結合 koa-await-breakpoint 源碼,解釋了 Node.js 自動化打點的核心原理。
參考連接: