对于 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 中调用栈的概念。

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

来看一段代码:

function c () {
  console.log('c')
  console.trace()
}

function b () {
  console.log('b')
  c()
}

function a () {
  console.log('a')
  b()
}

a()

执行后打印出:

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

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

稍微修改下上面的例子:

function c () {
  console.log('c')
}

function b () {
  console.log('b')
  c()
  console.trace()
}

function a () {
  console.log('a')
  b()
}

a()

执行后打印出:

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

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

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

const mongoose = require('mongoose')
const Schema = mongoose.Schema
mongoose.connect('mongodb://localhost/test')

const UserSchema = new Schema({
  id: mongoose.Schema.Types.ObjectId
})
const User = mongoose.model('User', UserSchema)
User
  .create({ id: 'xxx' })
  .then(console.log)
  .catch(console.error)

运行后打印出:

{ ValidationError: User validation failed: id: Cast to ObjectID failed for value "xxx" at path "id"
    at ValidationError.inspect (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/validation.js:56:24)
    at ...
  errors:
   { id:
      { CastError: Cast to ObjectID failed for value "xxx" at path "id"
    at new CastError (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/cast.js:27:11)
    at model.$set (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/document.js:792:7)
    at ...
        message: 'Cast to ObjectID failed for value "xxx" at path "id"',
        name: 'CastError',
        stringValue: '"xxx"',
        kind: 'ObjectID',
        value: 'xxx',
        path: 'id',
        reason: [Object] } },
  _message: 'User validation failed',
  name: 'ValidationError' }

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

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

3.3.2 Error.captureStackTrace

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

Error.captureStackTrace(targetObject[, constructorOpt])

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

举个简单的例子:

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

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

function MyError() {
  Error.captureStackTrace(this, MyError)
  this.name = this.constructor.name
  this.message = 'you got MyError'
}

const myError = new MyError()
console.log(myError)
console.log(myError.stack)
// 输出
MyError { name: 'MyError', message: 'you got MyError' }
Error
    at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:7:17)
    at ...

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

function MyError() {
  Error.captureStackTrace(this)
  this.name = this.constructor.name
  this.message = 'you got MyError'
}

const myError = new MyError()
console.log(myError)
console.log(myError.stack)
// 输出
MyError { name: 'MyError', message: 'you got MyError' }
Error
    at new MyError (/Users/nswbmw/Desktop/test/app.js:2:9)
    at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:7:17)
    at ...

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

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

const myObj = {}

function c () {
  Error.captureStackTrace(myObj, b)
}

function b () {
  c()
}

function a () {
  b()
}

a()
console.log(myObj.stack)
// 输出
Error
    at a (/Users/nswbmw/Desktop/test/app.js:12:3)
    at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:15:1)
    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 的代码如下:

class Query {
  constructor(op, args) {
    Error.captureStackTrace(this, this.constructor);
    ...
  }
}

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

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

来看一段 Mongolass 的代码:

const Mongolass = require('mongolass')
const Schema = Mongolass.Schema
const mongolass = new Mongolass('mongodb://localhost:27017/test')

const UserSchema = new Schema('UserSchema', {
  name: { type: 'string' },
  age: { type: 'number' }
})
const User = mongolass.model('User', UserSchema)

User
  .insertOne({ name: 'nswbmw', age: 'wrong age' })
  .exec()
  .then(console.log)
  .catch(console.error)

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

{ TypeError: ($.age: "wrong age") ✖ (type: number)
    at Model.insertOne (/Users/nswbmw/Desktop/test/node_modules/mongolass/lib/query.js:104:16)
    at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:12:4)
    at ...
  validator: 'type',
  actual: 'wrong age',
  expected: { type: 'number' },
  path: '$.age',
  schema: 'UserSchema',
  model: 'User',
  op: 'insertOne',
  args: [ { name: 'nswbmw', age: 'wrong age' } ],
  pluginName: 'MongolassSchema',
  pluginOp: 'beforeInsertOne',
  pluginArgs: [] }

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

3.3.4 Error.prepareStackTrace

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

Error.prepareStackTrace(error, structuredStackTrace)

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

正常的 throw error:

function c () {
  throw new Error('error!!!')
}

function b () {
  c()
}

function a () {
  b()
}

try {
  a()
} catch (e) {
  console.log(e.stack)
}
// 输出
Error: error!!!
    at c (/Users/nswbmw/Desktop/test/app.js:2:9)
    at b (/Users/nswbmw/Desktop/test/app.js:6:3)
    at a (/Users/nswbmw/Desktop/test/app.js:10:3)
    at Object.<anonymous> (/Users/nswbmw/Desktop/test/app.js:14:3)
    at ...

使用 Error.prepareStackTrace 格式化 stack:

Error.prepareStackTrace = function (error, callSites) {
  return error.toString() + '\n' + callSites.map(callSite => {
    return '    -> ' + callSite.getFunctionName() + ' ('
      + callSite.getFileName() + ':'
      + callSite.getLineNumber() + ':'
      + callSite.getColumnNumber() + ')'
  }).join('\n')
}

function c () {
  throw new Error('error!!!')
}

function b () {
  c()
}

function a () {
  b()
}

try {
  a()
} catch (e) {
  console.log(e.stack)
}
// 输出
Error: error!!!
    -> c (/Users/nswbmw/Desktop/test/app.js:11:9)
    -> b (/Users/nswbmw/Desktop/test/app.js:15:3)
    -> a (/Users/nswbmw/Desktop/test/app.js:19:3)
    -> null (/Users/nswbmw/Desktop/test/app.js:23:3)
    -> ...

可以看出:我们自定义了一个 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 的模块,可以用来获取函数调用相关的信息,例如获取执行该函数所在的文件名:

const callsites = require('callsites')

function getFileName() {
  console.log(callsites()[0].getFileName())
  //=> '/Users/nswbmw/Desktop/test/app.js'
}

getFileName()

我们来看一下源代码:

module.exports = () => {
  const _ = Error.prepareStackTrace
  Error.prepareStackTrace = (_, stack) => stack
  const stack = new Error().stack.slice(1)
  Error.prepareStackTrace = _
  return stack
}

注意以下几点:

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

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

const callsites = require('callsites')

function b () {
  console.log(callsites()[1].getFunctionName())
  // => 'a'
}

function a () {
  b()
}
a()

3.3.6 Error.stackTraceLimit

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

3.3.7 Long Stack Trace

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

const foo = function () {
  throw new Error('error!!!')
}
const bar = function () {
  setTimeout(foo)
}
bar()
// 输出
/Users/nswbmw/Desktop/test/app.js:2
  throw new Error('error!!!')
  ^

Error: error!!!
    at Timeout.foo [as _onTimeout] (/Users/nswbmw/Desktop/test/app.js:2:9)
    at ontimeout (timers.js:469:11)
    at tryOnTimeout (timers.js:304:5)
    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

results matching ""

    No results matching ""