对于 JavaScript 中的 Error,想必大家已经很熟悉了,毕竟天天与它打交道。
Node.js 内置的 Error 类型有:
new Error('error!!!')
require('vm').runInThisContext('binary ! isNotOk')
doesNotExist
require('url').parse(() => {})
encodeURI('\uD800')
assert(false)
每个 Error 对象通常有 name、message、stack、constructor 等属性。当程序抛出异常时,我们需要根据错误栈(error.stack)定位到出错代码。希望本节能够帮助读者理解并玩转错误栈,写出错误栈清晰的代码,方便调试。
错误栈本质上就是调用栈(或者叫:堆栈追踪)。所以我们先复习一下 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 的用法。
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 的模块。
这里先大体讲讲 Mongolass 的用法。Mongolass 与 Mongoose 类似,有 Model 的概念,Model 上挂载的方法对应对 MongoDB 的 collections 的操作,例如:User.insert。User 是一个 Model 实例,User.insert 方法返回的是一个 Query 实例。Query 的代码如下:
User.insert
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 类型的值。
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 时需要注意两点:
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 }
注意以下几点:
假如我们想获取当前函数的父函数名,则可以这样用:
const callsites = require('callsites') function b () { console.log(callsites()[1].getFunctionName()) // => 'a' } function a () { b() } a()
Node.js 还暴露了一个 Error.stackTraceLimit 的设置,可以通过设置这个值来改变输出的 stack 的行数,默认值是 10。
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 包括:
Long Stack Trace 相关的库可以参考:
node@8+ 提供了强大的 async_hooks 模块,在本书的后面章节会介绍如何使用。
上一节:3.2 Async + Await
下一节:3.4 Node@8
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8