你真的會在async/await中捕獲異常嗎?

counterxing發表於2019-03-04

原文連結:Catching without Awaiting

當執行一項需要等待一段時間才能返回的任務時,如果使用async/await,就顯得比較麻煩了。如果async方法還沒有得到返回值,我們就捕獲不到其中的異常。

在我的上一篇文章Learn to Throw Again中寫到,當使用async/await時,如何同時捕獲到回撥函式和throw丟擲的錯誤。在這篇文章中,我們將討論如何在“後臺”中執行非同步操作並捕獲異常(這裡使用雙引號,因為在單執行緒平臺上沒有真正的後臺操作)

從回撥函式的模式開始,思考下列程式碼:

function email(user, message, callback) {
  if (!user) {
    // 丟擲異常
    throw new Error(`Invlid user`);
  }
  if (!user.address) {
    // 回撥函式,可能丟擲異常
    return callback();
  }
  // 非同步的
  return mailer.send(user.address, message, callback);
}
複製程式碼

上述程式碼遵循典型的throw-on-bad-input / callback-asynchronous-errors模式(一旦程式接收到錯誤的輸入,非同步丟擲異常),如果我們想要發出一封郵件,我們這樣呼叫:

email(user, message, () => {});
複製程式碼

對於非法的輸入,呼叫這個函式依舊可能丟擲異常。但是,如果電子郵件在傳輸中產生錯誤,這個函式呼叫時會忽略非同步丟擲的錯誤。

我們把它改為Promise的版本:

function email(user, message) {
  if (!user) {
    throw new Error(`Invlid user`);
  }
  if (!user.address) {
    return Promise.resolve();
  }
  return mailer.send(user.address, message); // 函式返回一個Promise
}
複製程式碼

這樣,對於非法的輸入,依舊可以捕獲到異常。而對於mailer.send()操作則會返回一個Promise,我們能夠輕鬆地通過Promise.catch()捕獲到異常:

email(user, message).catch(() => {});
複製程式碼

不管是回撥函式還是Promise,他們都是非同步的,我們的應用程式都不會因為email傳送而被阻塞。

對於async/await的模式,如果在try...catch語句中不使用await關鍵字,那麼try...catch子句不會真正工作。來看下面的async版本:

function email(user, message) {
  if (!user) {
    throw new Error(`Invlid user`);
  }
  if (!user.address) {
    return;
  }
  return mailer.send(user.address, message); // async function
}
複製程式碼

如果我們像這樣去呼叫:

try {
  email(user, message);
} catch (err) {
  Bounce.rethrow(err, `system`);
}
複製程式碼

對於非法的輸入錯誤,仍然會正常地丟擲異常,這沒問題。但是對於任何非同步返回的異常,例如在mailer.send()丟擲的異常,則會被忽略掉。不管這種錯誤我們想不想捕獲到,反正都是捕獲不到的。為了修補這個bug,則要使用await關鍵字。但是問題來了,這將會導致整個“後臺操作”的阻塞。

有一種方案是混用async/awaitPromise

email(user, message).catch(() => {});
複製程式碼

但這樣的問題在於,對於沒有address的使用者,這個方法返回的返回值型別並不是Promise,因而其也不會有catch()方法,因此程式會出現TypeError: Cannot read property ‘catch’ of undefined這樣的錯誤。

你可能會嘗試直接把email()函式宣告為async函式, 並使得它一定會返回一個Promise,但是這並不是一個很好的解決方案,因為async / await其實也只是Promise物件的一層包裝。如果不使用await關鍵字,把一個函式宣告為async函式是完全沒有必要的。因為async函式總是要通過返回一個Promise,通過next-tick拿到結果,這樣會浪費Promise包裝和next-tick事件迴圈機制所造成的效能損耗。

此外,如果要在迴圈中使用async函式,並且這個迴圈中執行了很多工,但是其實很多工並不是真正意義上非同步的,那就沒有必要使用async / await,可以參考hapi.js中的checking if you really need to await下列程式碼判斷是否真的需要使用await,這樣或許能獲得一些效能的提升:

var response = (typeof func === `function` ? func(this) : this._invoke(func));
if (response && typeof response.then === `function`) { // Skip await if no reason to
  response = await response;
}
複製程式碼

判斷是否真的需要await,其實就是判斷其是否存在then方法,並且then方法是一個函式。因為await的作用其實就是取得一個非同步操作的返回結果。

如果你能夠保證email方法總是返回一個Promise,我們可以通過更改我們的email()函式來達到這一點,但這樣就顯得急功近利了!程式碼顯得十分不簡潔,而且使用了很不必要的非同步操作。在一個完整的async/await函式呼叫棧中,不需要我們手動構建Promise。對於這個例子來說還好,更重要的是,我們不可能總通過改變email()方法來實現,因為這只是一個例子,在實際運用中,可能email()方法是通過模組引入的。

其中一種解決方案是通過await關鍵字來呼叫async函式。通常情況下,在一個函式中使用阻塞操作,如果不等待這個函式執行完成,它不會丟擲異常,但是我們可以通過try...catch來包裹:

async function backgroundEmail(user, message) {
  try {
    await email(user, message);
  } catch (err) {
    Bounce.rethrow(err, `system`);
  }
}
複製程式碼

然後不通過await呼叫backgroundEmail

backgroundEmail(user, message);
複製程式碼

這樣我們不但能夠捕獲到應用程式的異常,還能夠捕獲到非同步丟擲的異常。

為了讓異常捕獲更加簡單,我們使用Bounce模組,它提供了一個background()方法。

Bounce.background(() => email(user, message));
複製程式碼

如果我們使用Node.jsAssertionError原型,這樣就能夠使得Bounce丟擲輸入異常的錯誤了。

async/await函式去除了一些同步函式(() => {})的功能,為了達到和普通函式相同的效果,我們不得不寫一些額外的程式碼來實現。但是使用新的工具庫,可以很簡便地突破這一限制。

相關文章