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

軟件開發時,有 80% 的代碼在處理各類錯誤。
javascript

——某著名開發者html

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

基礎

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

  • 和其餘同步語言相似的 throw / try / catch 方法git

  • callback(err, data) 回調形式es6

  • 經過 EventEmitter 觸發一個 error 事件github

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

可是 koa 經過十分巧妙的」黑魔法「讓咱們可使用 catch 來捕獲異步代碼中的錯誤。好比下面的例子:數據庫

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 的方式來進行錯誤的觸發和捕獲,這會讓代碼更加易讀,防止被繞暈。json

原理

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

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

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 函數的參數 333b 被賦值爲 333。繼續往下執行到 return 語句,將 return 語句的返回值做爲 value 返回,由於函數已經執行完畢,done 字段標記爲 true

能夠看到 generator 就是一種迭代機制,就像一隻很懶的青蛙,戳一下(調用 next)動一下。

generator 對象還有一個 throw 方法,能夠在 generator 函數外面拋出異常,而後在 generator 函數裏面捕獲異常。有點繞?咱們來看一個實例:

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,或者連續調用三次 nextyield 代碼不在 try 返回裏面,會致使報錯。co 的錯誤處理其實正是利用了這個 throw 方法。

下面咱們來看看 co 的核心代碼:

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) + '"'));
    }
  });
}

假設有下面的代碼,讓咱們一塊兒推演下執行流程:

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)開始以前定義下面的中間件:

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 中的代碼就能夠這樣來觸發錯誤:

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 分類

上面的代碼裏面出現的 JsonErrorPageError,其實是繼承於 Error 的兩個構造器。代碼以下:

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 構造器,咱們能夠將錯誤進行細分,從而能更精細的對錯誤進行處理。

參考資料

相關文章
相關標籤/搜索