代码篇 - Error Stack
对于 JavaScript 中的 Error,想必大家已经很熟悉了,毕竟天天与它打交道。
Node.js 内置的 Error 类型有:
- Error:通用的错误类型,例如:
new Error('error!!!')
。 - SyntaxError:语法错误,例如:
require('vm').runInThisContext('binary ! isNotOk')
。 - ReferenceError:引用错误,如引用一个未定义的变量,例如:
doesNotExist
。 - TypeError:类型错误,例如:
require('url').parse(() => {})
。 - URIError:全局的 URI 处理函数抛出的错误,例如:
encodeURI('\uD800')
。 - 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 是 Node.js 提供的一个 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 时需要注意两点:
- 这个方法是 V8 暴露出来的,所以只能在基于 V8 的 Node.js 或者 Chrome 里才能使用。
- 这个方法会修改全局 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
}
注意以下几点:
- 因为修改 Error.prepareStackTrace 会全局生效,所以将原来的 Error.prepareStackTrace 存到一个变量中,函数执行完后再重置回去,避免影响全局的 Error。
- Error.prepareStackTrace 函数直接返回 CallSite 对象数组,而不是格式化后的 stack 字符串。
- 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 相关的库可以参考:
node@8+ 提供了强大的 async_hooks 模块,在本书的后面章节会介绍如何使用。
3.3.8 参考链接
- https://zhuanlan.zhihu.com/p/25338849
- https://segmentfault.com/a/1190000007076507
- https://github.com/v8/v8/wiki/Stack-Trace-API
- https://www.jianshu.com/p/1d5120ad62bb
下一节:3.4 Node@8