处理错误是软件工程的重要部分。
定义并具备如何处理错误的强有力指导方针,在开发功能时会让你的生活变得更加容易,而且,当事情出错时更加重要!
在 Orus(我们试图重新定义专业保险)中,随着时间的推移,我们量身定制了一种对我们很有效的错误策略,我们认为这可能是有用的分享。
虽然本文主要是关于 TypeScript 中的错误处理,但我们将介绍的一些原则相当通用,也适用于其他语言。
话不多说,这是我们的五个错误处理戒律:
- #1:确保错误是……错误
- #2:不要丢失堆栈跟踪
- #3:使用常量错误消息
- #4:提供正确的上下文
- #5:不要为预计会发生的问题抛出错误
这激起了你的兴趣吗?如果是,请继续阅读!
戒律1:确保错误是……错误
在神奇的 JavaScript 世界中,你可能没有意识到,但你可以抛出任何东西,不仅仅是 Error
实例。
function throwNumber() {
throw 123
}
try {
throwNumber()
} catch (err) {
console.log(err) // 123
}
尽管这很有趣并允许一些巧妙的用例(例如,即将推出的 React Suspenses 就建立在我们可以抛出承诺的事实上),但这不是一个好主意!实际上,有一些问题:
- 没有附加堆栈跟踪,因此抛出的“错误”失去了很多有用性
- 在调用方面,几乎总是期望实际的
Error
实例。在野外,经常看到不加检查err
类型的err.message
的使用方式,这并不罕见
为了确保你在这个问题上是防弹的,首先,总是在你的代码库中 throw
Error
。记住这个规则很棒,但是用一个 ESLint 规则(例如 https://typescript-eslint.io/rules/no-throw-literal/)强制执行它更好!
更重要的是:在使用捕获的错误之前,确保它实际上是一个 Error
。为此,请确保在 tsconfig.json
中启用了 useUnknownInCatchVariables
标志(请注意,如果处于 strict
模式下,则默认启用)。这将使 catch
块中的错误变量 unknown
而不是 any
。
try {
runFragileOperation()
} catch (err) { // err is `unknown`
console.log(err.message) // this will fail because we're not checking the `err` type
}
这是一个很大的进步:我们可以在确保其类型之前不滥用错误。默认情况下,它不实用:
try {
runFragileOperation()
} catch (err) {
if (err instanceof Error) console.log(err.message)
// what do you do if err is not an `Error`?
}
在 catch
块中不断使用 if
块检查错误类型很麻烦。此外,如果不是 Error
,你该怎么办?
我们在 Orus 团队中提出的解决方案是拥有一个 ensureError
函数,它确保一个错误是一个 Error
。如果不是,则将抛出的值包装在 Error
中。实现如下:
function ensureError(value: unknown): Error {
if (value instanceof Error) return value
let stringified = '[Unable to stringify the thrown value]'
try {
stringified = JSON.stringify(value)
} catch {}
const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`)
return error
}
使用这个函数,操作捕获的错误更加简单:
try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)
console.log(error.message)
}
这样做有几个好处:
- 它处理可能被抛出的任何东西,只要值是 JSON 可串行化的,就不会丢失任何信息
- 它如果需要创建一个
Error
实例,尽早添加堆栈跟踪。因此,错误可以轻松传播,并具有最相关的堆栈跟踪 - 使用看起来更加清洁:用一行简短的代码,你确信正在操作一个
Error
。因为这必须在每个catch
块中使用,所以这相当重要
这个简单的函数显著简化了我们的错误处理代码。
戒律2:不要丢失堆栈跟踪
你能在这个片段中发现问题吗?
try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)
throw new Error(`Running fragile operation failed: ${error.message}`)
}
好吧,标题很早就透露了:我们失去了初始错误的堆栈跟踪。
这似乎不是很重要,但尽可能多地拥有信息可以在调试问题时极大地帮助。
在旧的 JavaScript 中保留堆栈跟踪(“链接”错误)相当复杂,但你很幸运:从 Node.js 16.9.0 开始,并且自 2021 年中期以来在大多数浏览器中可用,cause
属性允许你以向后兼容的方式将“原始”错误附加到 Error
上:
const error1 = new Error("Network error")
const error2 = new Error("The update failed", { cause: error1 })
console.log(error2)
/* Prints:
Error: The update failed
at REPL2:1:16
at Script.runInThisContext (node:vm:129:12)
... 7 lines matching cause stack trace ...
at [_line] [as _line] (node:internal/readline/interface:892:18) {
[cause]: Error: Network error
at REPL1:1:16
at Script.runInThisContext (node:vm:129:12)
at REPLServer.defaultEval (node:repl:572:29)
at bound (node:domain:433:15)
at REPLServer.runBound [as eval] (node:domain:444:12)
at REPLServer.onLine (node:repl:902:10)
at REPLServer.emit (node:events:525:35)
at REPLServer.emit (node:domain:489:12)
at [_onLine] [as _onLine] (node:internal/readline/interface:422:12)
at [_line] [as _line] (node:internal/readline/interface:892:18)
*/
正如所示,原始错误完全保留,包括其堆栈跟踪。请注意,它不仅限于一个错误,错误原因可能有一个原因,该原因可能有一个原因,该原因可能有一个原因……你懂的!
如果你想知道为什么我们抛出了一个 new Error
而不是只重新抛出原始错误,请看下面的例子:
try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)
if (!config.fallbackEnabled) throw error
try {
runFallback()
} catch {
// for the sake of this example, we discard the `runFallback`
// stack trace on purpose, but in course on production we shouldn't!
throw error
}
}
与抛出新错误相比,我们失去了一些好处:
- 错误消息可能不太清晰。你可能更喜欢你的函数以一个漂亮的
Calling API failed
失败,而不是一个更模糊的read ECONNRESET
- 我们不知道哪个
throw
会抛出。是来自回退还是另一个的?结果的堆栈跟踪看起来完全相同,如果我们抛出了新的错误,情况将不同
戒律3:使用常量错误消息
大多数监控和错误跟踪平台分析错误消息以确定错误是否频繁。看下面的例子:
try {
await logRequest(requestId, { elapsedTime })
} catch (err) {
const error = ensureError(err)
throw new Error(`Could not log request with ID "${requestId}"`, { cause: error })
}
想象一下,logRequest
将请求记录在数据库中,但数据库失败了。如果由于此原因有 1,000 个请求失败,将有 1,000 个不同的错误消息:
Could not log request with ID "9r7S8_ZoobNwRKafaVeP7"
Could not log request with ID "v2zGvKj-JVdFg_vjJyUP1"
Could not log request with ID "CaU_eS8olPcbbxfIPiUWN"
[...]
这将是有问题的,因为:
- 如果你是一个小公司,你的监控平台可能会在有任何单个意外错误时警报你。你将最终拥有 1,000 个警报
- 如果你是一家大公司,并且你的监控平台在达到一定发生阈值时警报你,每个错误消息都是唯一的,你将永远不会收到通知解决方法是确保你的错误信息是固定的。变量数据应该作为错误上下文(或元数据或任何你喜欢的名称)的一部分设置。出于类型安全的考虑,你需要创建自己的
Error
子类:
type Jsonable = string | number | boolean | null | undefined | readonly Jsonable[] | { readonly [key: string]: Jsonable } | { toJSON(): Jsonable }
export class BaseError extends Error {
public readonly context?: Jsonable
constructor(message: string, options: { error?: Error, context?: Jsonable } = {}) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}
现在,你可以将上下文附加到错误上,同时确保你的错误可序列化为JSON,这要归功于Jsonable
类型。上面的错误现在将如下所示:
throw new BaseError('Could not log request', { cause: error, context: { requestId } })
错误分组将按预期进行,错误消息始终相同!对于调试,你仍然可以访问上下文中的相关数据。
第四诫:提供适当的上下文
这是一个小问题,但仍然非常重要:将相关数据附加到你的错误上下文中。提供太少或太多的上下文都会使你更难调试问题。看下面的例子:
try {
await billCustomer(customer.id, quote.amount)
} catch (err) {
const error = ensureError(err)
throw new BaseError('Could not bill customer', {
cause: error,
context: { customer, quote }
})
}
这里可能提供了太多的上下文:有可能,要调试这个问题,你只需要customer.id
和quote.amount
。在这种情况下,调试问题需要你浏览整个上下文,而这可能是庞大的(例如,报价可能有很多信息)。此外,如果customer
中有敏感数据怎么办?将客户的个人信息放在你的错误监控平台中可能不是你想要的。
在这种情况下,提供customerId
和quoteAmount
就足够了。
第五诫:不要为预期会发生的问题抛出错误
由于TypeScript的性质,抛出错误应该被视为最后的手段,即难以或不可能从中恢复。
实际上,与其他语言不同,无法保证在TypeScript中抛出的错误将被处理。这是因为抛出的错误类型不是函数签名的一部分。
那么我们怎样才能保证错误被处理呢?嗯,错误应该是函数签名的一部分。你可以通过借鉴来自Rust的Result
模式来实现这一点,这是一个简单但相当强大的模式:
type Result<T, E extends BaseError = BaseError> = { success: true, result: T } | { success: false, error: E }
有了这个类型,你现在可以表达函数可能失败的事实。例如,一个访问API服务器的函数将如下所示:
type ApiResponse = { data: string }
export async function fetchDataFromApi(): Result<ApiResponse> {
try {
const result = await fetch('https://api.local')
// for the sake of this snippet we don't do it
// but we should validate `result` matches the expected format
return { success: true, result}
} catch (err) {
const error = ensureError(err)
return { success: false, error }
}
}
现在,在确保成功之前无法访问API中的数据。同时,注意到这个函数永远不会抛出异常。
上面的例子有点粗糙和冗长,但这是为了演示的最大化简化。实际上,有很好的库提供了优秀的API,以有效而优雅地使用Result
,但这超出了本文的范围。
简而言之,为了确保你的用户有最好的体验,请问自己一个问题:“这个函数可能失败吗?”如果是,使用Result
模式可能是一个好主意,因为你有保证失败情况将被处理,这样你的用户将看到一个漂亮的消息或一个优雅的回退,而不是一个崩溃。
评论(0)