使用錯誤類在 NodeJS 中更好地處理錯誤

已發表: 2022-03-10
快速總結↬本文適用於希望改進其應用程序中的錯誤處理的 JavaScript 和 NodeJS 開發人員。 Kelvin Omereshone 解釋了error類模式以及如何使用它來更好、更有效地處理應用程序中的錯誤。

錯誤處理是軟件開發中沒有得到應有的重視的部分之一。 但是,構建健壯的應用程序需要正確處理錯誤。

您可以在不正確處理錯誤的情況下使用 NodeJS,但由於 NodeJS 的異步特性,不正確的處理或錯誤很快就會讓您感到痛苦——尤其是在調試應用程序時。

在我們繼續之前,我想指出我們將討論如何利用錯誤類的錯誤類型。

操作錯誤

這些是在程序運行時發現的錯誤。 操作錯誤不是錯誤,並且可能不時發生,主要是由於一個或幾個外部因素的組合,例如數據庫服務器超時或用戶決定通過在輸入字段中輸入 SQL 查詢來嘗試 SQL 注入。

以下是更多操作錯誤的示例:

  • 連接數據庫服務器失敗;
  • 用戶輸入無效(服務器以400響應碼響應);
  • 請求超時;
  • 未找到資源(服務器以 404 響應代碼響應);
  • 服務器返回500響應。

還值得注意的是簡要討論操作錯誤的對應部分。

程序員錯誤

這些是程序中的錯誤,可以通過更改代碼來解決。 這些類型的錯誤無法處理,因為它們是由於代碼被破壞而發生的。 這些錯誤的示例是:

  • 試圖讀取未定義對象的屬性。
 const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
  • 在沒有回調的情況下調用或調用異步函數。
  • 在需要數字的地方傳遞一個字符串。

本文是關於 NodeJS 中的操作錯誤處理的。 NodeJS 中的錯誤處理與其他語言中的錯誤處理有很大不同。 這是由於 JavaScript 的異步特性和帶有錯誤的 JavaScript 的開放性。 讓我解釋:

在 JavaScript 中, error類的實例並不是唯一可以拋出的東西。 您可以從字面上拋出任何數據類型,這種開放性是其他語言所不允許的。

例如,JavaScript 開發人員可能決定拋出一個數字而不是錯誤對象實例,如下所示:

 // bad throw 'Whoops :)'; // good throw new Error('Whoops :)')

您可能看不到拋出其他數據類型的問題,但這樣做會導致調試更加困難,因為您不會獲得堆棧跟踪和 Error 對象公開的其他調試所需的屬性。

讓我們先看看錯誤處理中的一些不正確的模式,然後再看看 Error 類模式以及它如何成為 NodeJS 中錯誤處理的更好方法。

跳躍後更多! 繼續往下看↓

錯誤處理模式 #1:錯誤使用回調

真實場景您的代碼依賴於需要回調的外部 API 以獲得您期望它返回的結果。

讓我們看下面的代碼片段:

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();

在 NodeJS 8 及更高版本之前,上述代碼是合法的,開發人員只需觸發並忘記命令。 這意味著開發人員不需要為此類函數調用提供回調,因此可以省略錯誤處理。 未創建writeFolder時會發生什麼? 不會調用writeFile ,我們對此一無所知。 這也可能導致競爭條件,因為當第二個命令再次啟動時,第一個命令可能還沒有完成,你不會知道。

讓我們通過解決競爭條件開始解決這個問題。 我們將通過回調第一個命令mkdir來確保該目錄確實存在,然後再使用第二個命令寫入它。 所以我們的代碼如下所示:

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();

雖然我們解決了競態條件,但我們還沒有完成。 我們的代碼仍然存在問題,因為即使我們對第一個命令使用了回調,我們也無法知道文件夾writeFolder是否已創建。 如果未創建文件夾,則第二次調用將再次失敗,但我們仍然再次忽略了該錯誤。 我們通過……解決這個問題

回調錯誤處理

為了使用回調正確處理錯誤,您必須確保始終使用錯誤優先的方法。 這意味著您應該首先檢查函數是否返回錯誤,然後再繼續使用返回的任何數據(如果有)。 讓我們看看這樣做的錯誤方法:

 'use strict'; // Wrong const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (data) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); else callback(err) }); } write(console.log);

上述模式是錯誤的,因為有時您調用的 API 可能不會返回任何值,或者可能會返回虛假值作為有效的返回值。 即使您顯然成功調用了函數或 API,這也會使您最終陷入錯誤狀態。

上面的模式也很糟糕,因為它的使用會吞噬你的錯誤(即使它可能已經發生,你的錯誤也不會被調用)。 由於這種錯誤處理模式,您也將不知道代碼中發生了什麼。 所以上面代碼的正確方法是:

 'use strict'; // Right const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (err) return callback(err) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write(console.log);

錯誤的錯誤處理模式 #2:錯誤使用 Promise

真實場景所以你發現了 Promises,你認為它們比回調更好,因為回調地獄,你決定承諾一些你的代碼庫所依賴的外部 API。 或者您正在使用來自外部 API 或瀏覽器 API(如 fetch() 函數)的承諾。

如今,我們在 NodeJS 代碼庫中並沒有真正使用回調,而是使用 Promise。 所以讓我們用一個 Promise 重新實現我們的示例代碼:

 'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!') }).catch((err) => { // catch all potential errors console.error(err) }) }

讓我們把上面的代碼放在顯微鏡下——我們可以看到我們正在將fs.mkdir承諾分支到另一個承諾鏈(對 fs.writeFile 的調用),甚至沒有處理該承諾調用。 您可能認為更好的方法是:

 'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => { // do something }).catch((err) => { console.error(err); }) }).catch((err) => { // catch all potential errors console.error(err) }) }

但上述內容無法擴展。 這是因為如果我們有更多的 Promise 鏈要調用,我們最終會得到類似於回調地獄的東西,Promise 是為了解決這個問題。 這意味著我們的代碼將繼續向右縮進。 我們手頭上會有一個地獄般的承諾。

承諾基於回調的 API

大多數時候,您希望自己承諾基於回調的 API,以便更好地處理該 API 上的錯誤。 然而,這並不容易做到。 讓我們在下面舉一個例子來解釋原因。

 function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }

綜上所述,如果arg不為true ,並且我們在調用doATask函數時沒有錯誤,那麼這個 Promise 就會掛掉,這就是你的應用程序中的內存洩漏。

在 Promise 中吞下同步錯誤

使用 Promise 構造函數有幾個困難,其中一個困難是; 一旦它被解決或被拒絕,它就無法獲得另一個狀態。 這是因為一個 Promise 只能獲得一個狀態——要么是待處理的,要么是被解決/拒絕的。 這意味著我們可以在我們的承諾中有死區。 讓我們在代碼中看到這一點:

 function deadZonePromise(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { resolve('I'm all Done'); throw new Error('I am never reached') // Dead Zone }); }); }

從上面我們看到,一旦 promise 被解決,下一行就是一個死區,永遠不會到達。 這意味著在你的 Promise 中執行的任何後續同步錯誤處理都將被吞沒,永遠不會被拋出。

真實世界的例子

上面的例子有助於解釋糟糕的錯誤處理模式,讓我們來看看你在現實生活中可能會遇到的問題。

真實世界示例 #1 — 將錯誤轉換為字符串

場景您認為從 API 返回的錯誤對您來說不夠好,因此您決定向其中添加自己的消息。

 'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();

讓我們看看上面的代碼有什麼問題。 從上面我們看到開發人員試圖通過將返回的錯誤與字符串“找不到模板”連接起來來改進databaseGet API 引發的錯誤。 這種方法有很多缺點,因為當連接完成時,開發人員會隱式地在返回的錯誤對像上運行toString 。 這樣他就會丟失錯誤返回的任何額外信息(告別堆棧跟踪)。 所以開發人員現在擁有的只是一個在調試時沒有用的字符串。

更好的方法是將錯誤保持原樣,或者將其包裝在您創建的另一個錯誤中,並將從 databaseGet 調用中拋出的錯誤作為屬性附加到它。

真實示例#2:完全忽略錯誤

場景也許當用戶在您的應用程序中註冊時,如果發生錯誤,您只想捕獲錯誤並顯示自定義消息,但您完全忽略了捕獲的錯誤,甚至沒有記錄它以進行調試。

 router.get('/:id', function (req, res, next) { database.getData(req.params.userId) .then(function (data) { if (data.length) { res.status(200).json(data); } else { res.status(404).end(); } }) .catch(() => { log.error('db.rest/get: could not get data: ', req.params.userId); res.status(500).json({error: 'Internal server error'}); }) });

從上面我們可以看到,如果對數據庫的調用失敗,則該錯誤被完全忽略,並且代碼正在向用戶發送 500。 但實際上,數據庫失敗的原因可能是用戶發送的格式錯誤的數據,即狀態碼為 400 的錯誤。

在上述情況下,我們將陷入調試恐懼中,因為作為開發人員的您不知道出了什麼問題。 用戶將無法給出一個體面的報告,因為總是拋出 500 內部服務器錯誤。 你最終會浪費時間去尋找問題​​,這等於浪費你雇主的時間和金錢。

真實示例#3:不接受 API 拋出的錯誤

場景您正在使用的 API 引發了錯誤,但您不接受該錯誤,而是以使其對調試目的無用的方式編組和轉換錯誤。

以下面的代碼示例為例:

 async function doThings(input) { try { validate(input); try { await db.create(input); } catch (error) { error.message = `Inner error: ${error.message}` if (error instanceof Klass) { error.isKlass = true; } throw error } } catch (error) { error.message = `Could not do things: ${error.message}`; await rollback(input); throw error; } }

上面的代碼中發生了很多事情,這會導致調試恐懼。 讓我們來看看:

  • 包裝try/catch塊:從上面可以看出,我們正在包裝try/catch塊,這是一個非常糟糕的主意。 我們通常會嘗試減少try/catch塊的使用,以最小化我們必須處理錯誤的表面(將其視為 DRY 錯誤處理);
  • 我們也在操縱錯誤消息以嘗試改進,這也不是一個好主意;
  • 我們正在檢查錯誤是否是Klass類型的實例,在這種情況下,我們將錯誤isKlass的布爾屬性設置為 truev(但如果檢查通過,則錯誤屬於Klass類型);
  • 我們還過早地回滾數據庫,因為從代碼結構來看,很可能在拋出錯誤時我們甚至可能沒有命中數據庫。

下面是編寫上述代碼的更好方法:

 async function doThings(input) { validate(input); try { await db.create(input); } catch (error) { try { await rollback(); } catch (error) { logger.log('Rollback failed', error, 'input:', input); } throw error; } }

讓我們分析一下我們在上面的代碼片段中正在做的事情:

  • 我們正在使用一個try/catch塊,並且只有在 catch 塊中我們才使用另一個try/catch塊,它作為保護,以防該回滾函數發生某些事情並且我們正在記錄它;
  • 最後,我們拋出了我們最初收到的錯誤,這意味著我們不會丟失該錯誤中包含的消息。

測試

我們主要想測試我們的代碼(手動或自動)。 但大多數時候我們只測試積極的東西。 對於穩健的測試,您還必須測試錯誤和邊緣情況。 這種疏忽導致錯誤進入生產環境,這將花費更多額外的調試時間。

提示始終確保不僅要測試積極的事情(從端點獲取狀態代碼 200),還要測試所有錯誤情況和所有邊緣情況。

真實示例#4:未處理的拒絕

如果您以前使用過 promise,那麼您可能會遇到unhandled rejections

這是關於未處理拒絕的快速入門。 未處理的拒絕是未處理的承諾拒絕。 這意味著承諾被拒絕,但您的代碼將繼續運行。

讓我們看一個導致未處理拒絕的常見現實示例。

 'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await a; await b; } catch (error) { // ignore all errors! } })();

上面的代碼乍一看似乎不太容易出錯。 但仔細觀察,我們開始看到一個缺陷。 讓我解釋一下:當a被拒絕時會發生什麼? 這意味著await b永遠不會到達,這意味著它是一個未處理的拒絕。 一個可能的解決方案是在兩個 Promise 上都使用Promise.all 。 所以代碼會這樣寫:

 'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await Promise.all([a, b]); } catch (error) { // ignore all errors! } })();

這是另一個會導致未處理的承諾拒絕錯誤的真實場景:

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();

如果你運行上面的代碼片段,你會得到一個未處理的 Promise 拒絕,原因如下:雖然這並不明顯,但我們在使用try/catch處理它之前返回了一個 Promise (foobar)。 我們應該做的是等待我們使用try/catch處理的承諾,因此代碼將顯示為:

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();

總結消極的事情

現在您已經看到了錯誤的錯誤處理模式和可能的修復,現在讓我們深入了解 Error 類模式以及它如何解決 NodeJS 中的錯誤錯誤處理問題。

錯誤類

在這種模式下,我們將使用ApplicationError類啟動我們的應用程序,這樣我們就知道我們明確拋出的應用程序中的所有錯誤都將從它繼承。 所以我們將從以下錯誤類開始:

  • ApplicationError
    這是所有其他錯誤類的祖先,即所有其他錯誤類都繼承自它。
  • DatabaseError
    任何與數據庫操作相關的錯誤都將從此類繼承。
  • UserFacingError
    由於用戶與應用程序交互而產生的任何錯誤都將從此類繼承。

下面是我們的error類文件的樣子:

 'use strict'; // Here is the base error classes to extend from class ApplicationError extends Error { get name() { return this.constructor.name; } } class DatabaseError extends ApplicationError { } class UserFacingError extends ApplicationError { } module.exports = { ApplicationError, DatabaseError, UserFacingError }

這種方法使我們能夠區分應用程序拋出的錯誤。 因此,現在如果我們要處理錯誤的請求錯誤(無效的用戶輸入)或未找到的錯誤(找不到資源),我們可以從基類繼承UserFacingError (如下面的代碼所示)。

 const { UserFacingError } = require('./baseErrors') class BadRequestError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 400; } } class NotFoundError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 404 } } module.exports = { BadRequestError, NotFoundError }

error類方法的好處之一是,如果我們拋出其中一個錯誤,例如NotFoundError ,每個閱讀此代碼庫的開發人員都能夠理解此時發生的事情(如果他們閱讀了代碼)。

您還可以在該錯誤的實例化期間傳遞特定於每個錯誤類的多個屬性。

另一個關鍵好處是您可以擁有始終屬於錯誤類的屬性,例如,如果您收到 UserFacing 錯誤,您會知道 statusCode 始終是此錯誤類的一部分,現在您可以直接在稍後代碼。

使用錯誤類的技巧

  • 為每個錯誤類創建自己的模塊(可能是私有模塊),這樣您就可以簡單地將其導入應用程序並在任何地方使用它。
  • 只拋出你關心的錯誤(錯誤是你的錯誤類的實例)。 這樣你就知道你的錯誤類是你唯一的真實來源,它包含調試應用程序所需的所有信息。
  • 擁有一個抽象的錯誤模塊非常有用,因為現在我們知道所有關於我們的應用程序可能拋出的錯誤的必要信息都在一個地方。
  • 處理層中的錯誤。 如果您處處處理錯誤,那麼您的錯誤處理方法就會不一致,難以跟踪。 我所說的層是指數據庫、express/fastify/HTTP 層等。

讓我們看看錯誤類在代碼中的樣子。 這是快遞的一個例子:

 const { DatabaseError } = require('./error') const { NotFoundError } = require('./userFacingErrors') const { UserFacingError } = require('./error') // Express app.get('/:id', async function (req, res, next) { let data try { data = await database.getData(req.params.userId) } catch (err) { return next(err); } if (!data.length) { return next(new NotFoundError('Dataset not found')); } res.status(200).json(data) }) app.use(function (err, req, res, next) { if (err instanceof UserFacingError) { res.sendStatus(err.statusCode); // or res.status(err.statusCode).send(err.errorCode) } else { res.sendStatus(500) } // do your logic logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user) });

綜上所述,我們利用 Express 公開了一個全局錯誤處理程序,它允許您在一個地方處理所有錯誤。 您可以在我們處理錯誤的地方看到對next()的調用。 此調用會將錯誤傳遞給app.use部分中定義的處理程序。 因為 express 不支持 async/await,所以我們使用try/catch塊。

因此,從上面的代碼中,要處理我們的錯誤,我們只需要檢查拋出的錯誤是否是UserFacingError實例,並且我們會自動知道錯誤對像中會有一個 statusCode 並將其發送給用戶(您可能想要還有一個特定的錯誤代碼,您可以將其傳遞給客戶端),僅此而已。

您還會注意到,在此模式( error類模式)中,您沒有顯式拋出的所有其他錯誤都是500錯誤,因為這是意料之外的事情,這意味著您沒有在應用程序中顯式拋出該錯誤。 通過這種方式,我們能夠區分應用程序中發生的錯誤類型。

結論

在您的應用程序中正確處理錯誤可以讓您在晚上睡得更好並節省調試時間。 以下是本文的一些要點:

  • 使用專門為您的應用程序設置的錯誤類;
  • 實現抽象錯誤處理程序;
  • 始終使用異步/等待;
  • 使錯誤富有表現力;
  • 必要時用戶承諾;
  • 返回正確的錯誤狀態和代碼;
  • 使用 Promise 鉤子。

當然,在 Smashing Workshops 中,Smashing Cat 探索新的見解。

有用的前端和用戶體驗位,每週交付一次。

借助工具幫助您更好地完成工作。 通過電子郵件訂閱並獲取 Vitaly 的智能界面設計清單 PDF

在前端和用戶體驗上。 受到 190.000 人的信賴。