聊一聊 Node.js 錯誤處理

我的博客:blog.skrskrskrskr.comhtml

錯誤分類

軟件程序中,咱們能夠將錯誤大體分爲外部錯誤和內部錯誤兩大類node

外部錯誤是正確編寫的程序在運行時產生的錯誤。它並非程序自己的 bug,更可能是一些外部緣由致使的問題,好比請求超時、服務器返回 500、內存不足等。git

而內部錯誤是程序裏的 bug。好比傳參類型錯誤、讀取 undefined 的一個屬性等。這類問題跟你選擇的開發語言、開發者的編程經驗、系統複雜度等因素息息相關,雖然沒法避免,但能夠經過修改代碼來修復它。程序員

對應到 Node.js 程序上,通常遇到如下四類錯誤:github

  1. 標準的 JavaScript 錯誤。例如 SyntaxError、RangeError、ReferenceError、TypeError等。
  2. 由底層操做系觸發的系統錯誤,例如試圖打開不存在的文件。
  3. 用戶自定義錯誤。
  4. 斷言錯誤。這類錯誤一般來自 assert 模塊。

注:本文中不區分錯誤和異常,都將其統稱爲錯誤。redis

錯誤處理

當錯誤發生後,咱們須要第一時間去處理它。針對不一樣類型的錯誤,有不一樣的措施。處理錯誤的整體原則:npm

  1. 及時止損,防止系統級崩潰。
  2. 詳細記錄現場,方便分析緣由。

外部錯誤

程序運行過程當中,可能會遇到各類外部因素致使的問題,這些問題須要具體問題具體分析。咱們沒辦法保證外部服務提供方的穩定性,可是遇到此類問題時,能夠作一些事情,來保證咱們的程序不至於直接崩潰。編程

舉個例子,秒殺場景的業務常常會承受很是大的 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 中常見異步場景包括三類:

  • Node.js style callback
  • Promise
  • EventEmitter

大部分異步 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

async_hooks 提供了一些 API 用於跟蹤 Node.js 中的異步資源的生命週期。有幾個概念:

  • 每一個異步函數的做用域,咱們稱之爲 async scope。
  • 每個 async scope 中都有一個 asyncId, 用來標記當前做用域。相同 async scope 的 asyncId 也相同。每一個異步資源在建立時 asyncId 全量遞增的。
  • 每個 async scope 中都有一個 triggerAsyncId 表示當前函數是由哪一個 async scope 觸發生成的。
  • 經過 asyncId 和 triggerAsyncId,咱們能夠獲取到異步資源的調用鏈。
  • async_hooks.createHooks 函數能夠用來給每一個異步資源添加 init/before/after/destory 等生命週期鉤子函數。
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 自動化打點的核心原理。


參考連接:

  1. 鬍子大哈. 深刻理解 JavaScript Errors 和 Stack Traces. zhuanlan.zhihu.com/p/25338849
  2. Chuan's blog. 關於 Error.captureStackTrace. blog.shaochuancs.com/about-error…
  3. joyent. Error Handling in Node.js. www.joyent.com/node-js/pro…
  4. 王子亭. Node.js 錯誤處理實踐. jysperm.me/2016/10/nod…
  5. 張佃鵬. 學習使用 Node.js 中 async-hooks 模塊. zhuanlan.zhihu.com/p/53036228
  6. bmeurer. github.com/bmeurer/asy…
  7. nodejs.cn/api/errors.…
相關文章
相關標籤/搜索