首页
Preview

TypeScript中干净的错误处理的5个准则

处理错误是软件工程的重要组成部分。

虽然本文主要是关于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.idquote.amount。在这种情况下调试问题,你需要挖掘整个上下文,这可能非常庞大(例如,报价可能包含大量信息)。而且,如果customer中有敏感数据怎么办?在错误监控平台中拥有客户的个人信息可能不是你想要的。

在这种情况下,提供customerIdquoteAmount就足够了。

第五条规则:不要为预计会发生的问题抛出错误

由于 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 收集的这些技巧对你有所帮助。

如果你有任何问题或反馈,请不要犹豫,回复本文章,我将很乐意回答!

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
一个人玩
先找到想要的,然后出发

评论(0)

添加评论