对于 JavaScript 中的 Error,想必大家已经很熟悉了,毕竟天天与它打交道。

Node.js 内置的 Error 类型有:

  1. Error:通用的错误类型,例如:new Error('error!!!')
  2. SyntaxError:语法错误,例如:require('vm').runInThisContext('binary ! isNotOk')
  3. ReferenceError:引用错误,如引用一个未定义的变量,例如:doesNotExist
  4. TypeError:类型错误,例如:require('url').parse(() => {})
  5. URIError:全局的 URI 处理函数抛出的错误,例如:encodeURI('\uD800')
  6. AssertError:使用 assert 模块时抛出的错误,例如:assert(false)

每个 Error 对象通常有 name、message、stack、constructor 等属性。当程序抛出异常时,我们需要根据错误栈(error.stack)定位到出错代码。希望本节能够帮助读者理解并玩转错误栈,写出错误栈清晰的代码,方便调试。

3.3.1 Stack Trace

错误栈本质上就是调用栈(或者叫:堆栈追踪)。所以我们先复习一下 JavaScript 中调用栈的概念。

调用栈:每当有一个函数调用,就会将其压入栈顶,在调用结束的时候再将其从栈顶移出。

来看一段代码:

  1. function c () {
  2. console.log('c')
  3. console.trace()
  4. }
  5. function b () {
  6. console.log('b')
  7. c()
  8. }
  9. function a () {
  10. console.log('a')
  11. b()
  12. }
  13. a()

执行后打印出:

  1. a
  2. b
  3. c
  4. Trace
  5. at c (/Users/nswbmw/Desktop/test/app.js:3:11)
  6. at b (/Users/nswbmw/Desktop/test/app.js:8:3)
  7. at a (/Users/nswbmw/Desktop/test/app.js:13:3)
  8. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:16:1)
  9. at ...

可以看出:c 函数中 console.trace() 打印出的堆栈追踪依次为 c、b、a,即 a 调用了 b,b 调用了 c。

稍微修改下上面的例子:

  1. function c () {
  2. console.log('c')
  3. }
  4. function b () {
  5. console.log('b')
  6. c()
  7. console.trace()
  8. }
  9. function a () {
  10. console.log('a')
  11. b()
  12. }
  13. a()

执行后打印出:

  1. a
  2. b
  3. c
  4. Trace
  5. at b (/Users/nswbmw/Desktop/test/app.js:8:11)
  6. at a (/Users/nswbmw/Desktop/test/app.js:13:3)
  7. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:16:1)
  8. at ...

可以看出:c() 在 console.trace() 之前执行完毕,从栈中移除,所以栈中从上往下为 b、a。

上面示例的代码过于简单,在实际情况下错误栈并没有这么直观。以常用的 mongoose 为例,mongoose 的错误栈并不友好。

  1. const mongoose = require('mongoose')
  2. const Schema = mongoose.Schema
  3. mongoose.connect('mongodb://localhost/test')
  4. const UserSchema = new Schema({
  5. id: mongoose.Schema.Types.ObjectId
  6. })
  7. const User = mongoose.model('User', UserSchema)
  8. User
  9. .create({ id: 'xxx' })
  10. .then(console.log)
  11. .catch(console.error)

运行后打印出:

  1. { ValidationError: User validation failed: id: Cast to ObjectID failed for value "xxx" at path "id"
  2. at ValidationError.inspect (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/validation.js:56:24)
  3. at ...
  4. errors:
  5. { id:
  6. { CastError: Cast to ObjectID failed for value "xxx" at path "id"
  7. at new CastError (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/cast.js:27:11)
  8. at model.$set (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/document.js:792:7)
  9. at ...
  10. message: 'Cast to ObjectID failed for value "xxx" at path "id"',
  11. name: 'CastError',
  12. stringValue: '"xxx"',
  13. kind: 'ObjectID',
  14. value: 'xxx',
  15. path: 'id',
  16. reason: [Object] } },
  17. _message: 'User validation failed',
  18. name: 'ValidationError' }

从 mongoose 给出的 error.stack 中看不到任何有用的信息,error.message 告诉我们 “xxx” 不匹配 User 这个 Model 的 id(ObjectID)的类型,其他的字段基本上也是这个结论的补充,却没有给出我们最关心的问题:我写的代码中,到底哪一行出了问题?

如何解决这个问题呢?我们先看看 Error.captureStackTrace 的用法。

3.3.2 Error.captureStackTrace

Error.captureStackTrace 是 Node.js 提供的一个 API,可以传入两个参数:

  1. Error.captureStackTrace(targetObject[, constructorOpt])

Error.captureStackTrace 会在 targetObject 中添加一个 stack 属性,对该属性进行访问时,将以字符串的形式返回 Error.captureStackTrace() 语句被调用时的代码位置信息(即:调用栈历史)。

举个简单的例子:

  1. const myObject = {}
  2. Error.captureStackTrace(myObject)
  3. console.log(myObject.stack)
  4. // 输出
  5. Error
  6. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:2:7)
  7. at ...

除了 targetObject,captureStackTrace 还接收一个类型为 function 的可选参数 constructorOpt,当传递该参数时,调用栈中所有 constructorOpt 函数之上的信息(包括 constructorOpt 函数自身),都会在访问 targetObject.stack 时被忽略。当需要对终端用户隐藏内部的实现细节时,constructorOpt 参数会很有用。传入第 2 个参数通常用于自定义错误,例如:

  1. function MyError() {
  2. Error.captureStackTrace(this, MyError)
  3. this.name = this.constructor.name
  4. this.message = 'you got MyError'
  5. }
  6. const myError = new MyError()
  7. console.log(myError)
  8. console.log(myError.stack)
  9. // 输出
  10. MyError { name: 'MyError', message: 'you got MyError' }
  11. Error
  12. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:7:17)
  13. at ...

如果去掉 captureStackTrace 的第 2 个参数:

  1. function MyError() {
  2. Error.captureStackTrace(this)
  3. this.name = this.constructor.name
  4. this.message = 'you got MyError'
  5. }
  6. const myError = new MyError()
  7. console.log(myError)
  8. console.log(myError.stack)
  9. // 输出
  10. MyError { name: 'MyError', message: 'you got MyError' }
  11. Error
  12. at new MyError (/Users/nswbmw/Desktop/test/app.js:2:9)
  13. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:7:17)
  14. at ...

可以看出:出现了 MyError 相关的调用栈,但我们并不关心 MyError 及其内部是如何实现的。

captureStackTrace 的第 2 个参数可以传入其他函数,不一定是当前函数,例如:

  1. const myObj = {}
  2. function c () {
  3. Error.captureStackTrace(myObj, b)
  4. }
  5. function b () {
  6. c()
  7. }
  8. function a () {
  9. b()
  10. }
  11. a()
  12. console.log(myObj.stack)
  13. // 输出
  14. Error
  15. at a (/Users/nswbmw/Desktop/test/app.js:12:3)
  16. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:15:1)
  17. at ...

可以看出:captureStackTrace 的第 2 个参数传入了函数 b,调用栈中隐藏了 b 函数及其以上所有的堆栈帧。

讲到这里,相信读者都明白了 captureStackTrace 的用法。但这具体有什么用呢?其实上面提到了:隐藏内部的实现细节,优化错误栈

下面以笔者写的一个模块 Mongolass 为例,讲解如何应用 captureStackTrace。

Mongolass 是一个轻量且优雅的连接 MongoDB 的模块。

3.3.3 captureStackTrace 在 Mongolass 中的应用

这里先大体讲讲 Mongolass 的用法。Mongolass 与 Mongoose 类似,有 Model 的概念,Model 上挂载的方法对应对 MongoDB 的 collections 的操作,例如:User.insert。User 是一个 Model 实例,User.insert 方法返回的是一个 Query 实例。Query 的代码如下:

  1. class Query {
  2. constructor(op, args) {
  3. Error.captureStackTrace(this, this.constructor);
  4. ...
  5. }
  6. }

这里用 Error.captureStackTrace 隐藏了 Query 内部的错误栈细节,但这样带来一个问题:丢失了原来的 error.stack,在 Mongolass 中可以自定义插件,而插件函数的执行是在 Query 内部,假如在插件中抛错,则会丢失相关错误栈信息。

如何弥补呢?Mongolass 的做法是,当 Query 内部抛出错误(error)时,截取有用的 error.stack,然后拼接到 Query 实例通过 Error.captureStackTrace 生成的 stack 上。

来看一段 Mongolass 的代码:

  1. const Mongolass = require('mongolass')
  2. const Schema = Mongolass.Schema
  3. const mongolass = new Mongolass('mongodb://localhost:27017/test')
  4. const UserSchema = new Schema('UserSchema', {
  5. name: { type: 'string' },
  6. age: { type: 'number' }
  7. })
  8. const User = mongolass.model('User', UserSchema)
  9. User
  10. .insertOne({ name: 'nswbmw', age: 'wrong age' })
  11. .exec()
  12. .then(console.log)
  13. .catch(console.error)

运行后打印的错误信息如下:

  1. { TypeError: ($.age: "wrong age") (type: number)
  2. at Model.insertOne (/Users/nswbmw/Desktop/test/node_modules/mongolass/lib/query.js:104:16)
  3. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:12:4)
  4. at ...
  5. validator: 'type',
  6. actual: 'wrong age',
  7. expected: { type: 'number' },
  8. path: '$.age',
  9. schema: 'UserSchema',
  10. model: 'User',
  11. op: 'insertOne',
  12. args: [ { name: 'nswbmw', age: 'wrong age' } ],
  13. pluginName: 'MongolassSchema',
  14. pluginOp: 'beforeInsertOne',
  15. pluginArgs: [] }

可以看出:app.js 第 12 行的 insertOne 报错,报错原因是 age 字段是字符串 “wrong age”,而我们期望的是 number 类型的值。

3.3.4 Error.prepareStackTrace

V8 暴露了另外一个接口——Error.prepareStackTrace。简单来讲,它的作用就是:定制 stack。用法如下:

  1. Error.prepareStackTrace(error, structuredStackTrace)

第 1 个参数是个 Error 对象,第 2 个参数是一个数组,每一项都是一个 CallSite 对象,包含错误的函数名、行数等信息。对比以下两种代码:

正常的 throw error:

  1. function c () {
  2. throw new Error('error!!!')
  3. }
  4. function b () {
  5. c()
  6. }
  7. function a () {
  8. b()
  9. }
  10. try {
  11. a()
  12. } catch (e) {
  13. console.log(e.stack)
  14. }
  15. // 输出
  16. Error: error!!!
  17. at c (/Users/nswbmw/Desktop/test/app.js:2:9)
  18. at b (/Users/nswbmw/Desktop/test/app.js:6:3)
  19. at a (/Users/nswbmw/Desktop/test/app.js:10:3)
  20. at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:14:3)
  21. at ...

使用 Error.prepareStackTrace 格式化 stack:

  1. Error.prepareStackTrace = function (error, callSites) {
  2. return error.toString() + '\n' + callSites.map(callSite => {
  3. return ' -> ' + callSite.getFunctionName() + ' ('
  4. + callSite.getFileName() + ':'
  5. + callSite.getLineNumber() + ':'
  6. + callSite.getColumnNumber() + ')'
  7. }).join('\n')
  8. }
  9. function c () {
  10. throw new Error('error!!!')
  11. }
  12. function b () {
  13. c()
  14. }
  15. function a () {
  16. b()
  17. }
  18. try {
  19. a()
  20. } catch (e) {
  21. console.log(e.stack)
  22. }
  23. // 输出
  24. Error: error!!!
  25. -> c (/Users/nswbmw/Desktop/test/app.js:11:9)
  26. -> b (/Users/nswbmw/Desktop/test/app.js:15:3)
  27. -> a (/Users/nswbmw/Desktop/test/app.js:19:3)
  28. -> null (/Users/nswbmw/Desktop/test/app.js:23:3)
  29. -> ...

可以看出:我们自定义了一个 Error.prepareStackTrace 格式化了 stack 并打印出来。

CallSite 对象还有许多 API,例如:getThis、getTypeName、getFunction、getFunctionName、getMethodName、getFileName、getLineNumber、getColumnNumber、getEvalOrigin、isToplevel、isEval、isNative 和 isConstructor,这里不一一介绍了,有兴趣的读者可查看参考链接。

在使用 Error.prepareStackTrace 时需要注意两点:

  1. 这个方法是 V8 暴露出来的,所以只能在基于 V8 的 Node.js 或者 Chrome 里才能使用。
  2. 这个方法会修改全局 Error 的行为。

3.3.5 Error.prepareStackTrace 的其他用法

Error.prepareStackTrace 除了格式化错误栈外还有什么作用呢?sindresorhus 大神还写了一个 callsites 的模块,可以用来获取函数调用相关的信息,例如获取执行该函数所在的文件名:

  1. const callsites = require('callsites')
  2. function getFileName() {
  3. console.log(callsites()[0].getFileName())
  4. //=> '/Users/nswbmw/Desktop/test/app.js'
  5. }
  6. getFileName()

我们来看一下源代码:

  1. module.exports = () => {
  2. const _ = Error.prepareStackTrace
  3. Error.prepareStackTrace = (_, stack) => stack
  4. const stack = new Error().stack.slice(1)
  5. Error.prepareStackTrace = _
  6. return stack
  7. }

注意以下几点:

  1. 因为修改 Error.prepareStackTrace 会全局生效,所以将原来的 Error.prepareStackTrace 存到一个变量中,函数执行完后再重置回去,避免影响全局的 Error。
  2. Error.prepareStackTrace 函数直接返回 CallSite 对象数组,而不是格式化后的 stack 字符串。
  3. new 一个 Error,stack 是返回的 CallSite 对象数组,因为第 1 项是 callsites,它总是这个模块的 CallSite,所以通过 slice(1) 去掉。

假如我们想获取当前函数的父函数名,则可以这样用:

  1. const callsites = require('callsites')
  2. function b () {
  3. console.log(callsites()[1].getFunctionName())
  4. // => 'a'
  5. }
  6. function a () {
  7. b()
  8. }
  9. a()

3.3.6 Error.stackTraceLimit

Node.js 还暴露了一个 Error.stackTraceLimit 的设置,可以通过设置这个值来改变输出的 stack 的行数,默认值是 10。

3.3.7 Long Stack Trace

stack trace 也有短板,问题出在异步操作上。若在异步回调中抛错,就会丢失绑定回调前的调用栈信息,来看个例子:

  1. const foo = function () {
  2. throw new Error('error!!!')
  3. }
  4. const bar = function () {
  5. setTimeout(foo)
  6. }
  7. bar()
  8. // 输出
  9. /Users/nswbmw/Desktop/test/app.js:2
  10. throw new Error('error!!!')
  11. ^
  12. Error: error!!!
  13. at Timeout.foo [as _onTimeout] (/Users/nswbmw/Desktop/test/app.js:2:9)
  14. at ontimeout (timers.js:469:11)
  15. at tryOnTimeout (timers.js:304:5)
  16. at Timer.listOnTimeout (timers.js:264:5)

可以看出:丢失了 bar 的调用栈。

在实际开发过程中,异步回调的例子数不胜数,如果不能知道异步回调之前的触发位置,则会给 debug 带来很大的难度。这时,出现了一个叫 long Stack Trace 的概念。

long Stack Trace 并不是 JavaScript 原生就支持的功能,所以要拥有这样的功能,就需要我们做一些 hack,幸好在 V8 环境下,所有 hack 所需的 API,V8 都已经提供了。

对于异步回调,目前能做的就是在所有会产生异步操作的 API 上做一些手脚,这些 API 包括:

  • setTimeout, setInterval, setImmediate。
  • nextTick, nextDomainTick。
  • EventEmitter.addEventListener。
  • EventEmitter.on。
  • Ajax XHR。

Long Stack Trace 相关的库可以参考:

  1. AndreasMadsen/trace
  2. mattinsler/longjohn
  3. tlrobinson/long-stack-traces

node@8+ 提供了强大的 async_hooks 模块,在本书的后面章节会介绍如何使用。

3.3.8 参考链接

上一节:3.2 Async + Await

下一节:3.4 Node@8