使用错误类在 NodeJS 中更好地处理错误
已发表: 2022-03-10error
类模式以及如何使用它来更好、更有效地处理应用程序中的错误。错误处理是软件开发中没有得到应有的重视的部分之一。 但是,构建健壮的应用程序需要正确处理错误。
您可以在不正确处理错误的情况下使用 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 钩子。
有用的前端和用户体验位,每周交付一次。
借助工具帮助您更好地完成工作。 通过电子邮件订阅并获取 Vitaly 的智能界面设计清单 PDF 。
在前端和用户体验上。 受到 190.000 人的信赖。