处理错误是软件工程的重要组成部分。
虽然本文主要是关于TypeScript中的错误处理,但我们将介绍的一些原则相当通用,适用于其他语言。
言归正传,这里是我们的5个错误处理诫命:
- #1:确保错误是错误
- #2:不要丢失你的堆栈跟踪
- #3:使用恒定的错误消息
- #4:提供正确的上下文
- #5:不要为预计发生的问题抛出错误
这引起了你的兴趣吗?如果是,请继续阅读!
诫命#1:确保错误是错误
在壮观的JavaScript世界中,你可能不知道,但你可以抛出任何东西,不仅仅是“Error”实例。
function throwNumber() {
throw 123
}
try {
throwNumber()
} catch (err) {
console.log(err) // 123
}
虽然这很有趣,并允许一些聪明的用例(例如,即将推出的React Suspenses就是建立在我们可以抛出的承诺的事实之上),但这不是一个好主意!实际上,有一些问题:
- 没有附加堆栈跟踪,因此抛出的“错误”失去了很多有用性
- 在调用者方面,几乎总是期望实际的“Error”实例。在野外看到
err.message
的天真用法而没有首先检查err
的类型是不寻常的
为了确保你对此问题弹无虚发,请始终在你的代码库中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-stringifiable,就不会丢失任何信息
- 如果需要,它创建一个“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
}
}
与抛出新错误相比,我们失去了一些好处:
- 错误消息可能不太清楚。你可能更喜欢你的函数以一个漂亮的“调用API失败”而不是一个更难懂的“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 } })
错误分组将按预期运行,错误消息始终相同!调试时,你仍然可以访问context
中的相关数据。
第四条规则:提供正确数量的上下文
这是一个小问题,但非常重要:将相关数据附加到错误上下文中。提供太少或太多的上下文将使你更难调试问题。看下面的例子:
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 的数据。另外,请注意,这个函数永远不会抛出异常。
上面的示例有点粗糙和冗长,但这是为了演示的简化到了最大。实际上,有一些很棒的库,它们公开了与Result
高效且更优雅地工作的好的 API,但这超出了本文的范围。
简而言之,为了确保你的用户拥有最佳的体验,问问自己这个问题“这个函数可能失败吗?”。如果是这样,使用Result
模式可能是一个好主意,因为你有保证失败情况将被处理,因此你的用户将看到一个漂亮的消息或一个优雅的回退,而不是崩溃。
这篇文章就到这里了!我希望我们在 Orus 收集的这些技巧对你有所帮助。
如果你有任何问题或反馈,请不要犹豫,回复本文章,我将很乐意回答!
评论(0)