利用Decorator和SourceMap优化JavaScript错误堆栈
配合源码阅读体验更佳。
最近收到用户吐槽 @cloudbase/js-sdk(云开发Cloudbase的JavaScript SDK)的报错信息不够清晰,比如下面这条报错:
这属于业务型报错,对于熟悉云开发能力细节的用户一眼就能看出错误的症结出在安全规则配置上,但是对于刚接触云开发的新用户或者之前没有遇到类似问题的用户来说,看到这样简短的错误信息肯定会一头雾水,分不清楚到底是业务报错还是代码写的不对。所以大部分人的第一反应是按照Error的堆栈信息进行debug,试图找到抛出Error的具体代码。然后就会遇到另一个让人头疼的问题:Error堆栈太深了,要想找到是哪一行代码引起的报错并不是一件很容易的事。
虽然云开发是一款toB的产品,相对来说B端开发者的容忍度会「略」高于C端用户,但是糟糕的开发体验肯定是会拉低开发者对产品的好感和认可度。所以优化报错信息成了一件必须要做的事情。
在详述优化方案之前,先看一下最终的优化效果:
图中打印的错误跟第一张图是同一个,代表当前的登录类型受到函数的安全规则限制,导致没有调用函数的权限。错误信息分为两部分:
- 上半部分的黑色字体提示包含了后端 API 返回的错误信息以及针对此类问题的一些解决方案建议;
- 下半部分的红色字体是经优化后的错误堆栈,第一条直接定位到 SDK 源码(
index.ts
),第二条直接定位到调用报错 API 的业务源码(App.callFn
)。
看到index.ts
这样的信息估计大部分人都明白这里用到了SourceMap。确实SourceMap是支撑这套优化方案的必备要素,借助SourceMap可以定位到SDK的源码。但只有SourceMap是不够的,优化的核心点在于:如何把原始错误冗长的堆栈中直接定位到关键代码行?
这就是优化的目标。
有了目标之后的第一步要做的不是立即去扣实现细节,而是设计整体方案,包括两部分:
- 第一是确定优化的对象。
是不是所有的类型的报错堆栈都需要优化?答案是否定的。优化的对象应该是业务报错,具体到代码就是SDK的public API。其他类型的错误(比如SDK自身的语法错误)是应该在发布SDK之前开发团队自测解决的,不应该被带给用户。只针对业务报错这一前提给优化方案一个基调:所有的错误信息格式是固定的(如果做不到这一点就说明SDK不合格)。 - 第二是确定接入方式。
优化的目的是改善体验,必须做到一点:不侵入SDK的原本逻辑。这个前提堵死了一条最容易也是最笨的路:直接改SDK的API代码,对所有的关键代码块加一层try-catch
。所以接入的方式必然是一种类似插件的机制,并且成本低、可定制。
除了以上两点之外,还有一个重要的问题需要提前确定:Error应该在SDK代码的什么位置抛出?举个例子,当业务代码调用SDK提供的callFunction
API后,SDK内部再发起网络请求之前有一些前序逻辑,比如判断入参是否正确、获取本地登录态信息等等。如果不做任何处理的话,当发生错误时抛出的Error堆栈是最内层的代码行,如下图:
但是用户关心的只是callFunction
成功还是失败,不会在意这个API内部是如何工作的,内层的Error堆栈对于用户来说没有任何帮助甚至由于加深了堆栈层级反而加重了debug难度。所以期望最佳的效果是由callFunction
所在的代码行抛出Error,最笨的实现方案就是为callFunction
的逻辑块整体包一层try-catch
统一抛出Error,但可惜这条路已经被堵死了。
那么剩下的唯一办法就是精简由内层逻辑抛出的Error的堆栈,把内层逻辑的堆栈全部剔除,只保留到最外层的callFunction
。
梳理一下上面的内容可以得出优化方案的关键信息:
选项 | 说明 |
---|---|
优化对象 | 只针对业务型逻辑报错,错误格式固定 |
接入方式 | 不侵入SDK原本逻辑,使用类似插件的机制 |
预期目标 | 精简Error堆栈,剔除无用条目直接定位到 SDK 的 API代码行 |
精简Error堆栈的基本思路是在SDK的API代码块内捕获内层逻辑抛出的Error,然后重新new一个Error对象抛出,这种方式可以将内层逻辑的堆栈全部消除。实现方式也很简单,在API代码块内用try-catch
包装内存逻辑即可,但这样会涉及修改API原本逻辑,而且工作量也不小,所以行不通。
即不侵入API原本逻辑,又能够影响API的表现,首先想到的便是装饰器Decorator。
Decorator
Decorator的优势有两点:
- 不侵入SDK原本逻辑,接入成本很低,只需要几行代码;
- TypeScript将Decorator编译为ES5语法之后有固定的格式,可以方便地在Error堆栈中找出对应的代码行,为精简Error堆栈提供便利。
写到这里其实大体的思路就定型了,步骤如下:
- 给API添加Decorator;
- 在Decorator内将API重新赋值,保持原本逻辑的前提下,为原本逻辑包装
try-catch
。
大致代码如下:
function catchErrorsDecorator(options){
return function(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<Function>
){
const fn = descriptor.value;
// 重新被装饰的API原本逻辑
descriptor.value = function(...args:any[]) {
try {
return fn.apply(this, args);
} catch (err) {
throw err;
}
}
}
}
然后为API添加装饰器:
class Cloudbase {
@catchErrorsDecorator({
// ...options
})
public init(){
// ...
}
}
这样修改后调用API的行为方式被修改为执行Decorator的逻辑。但是在Decorator的catch
代码块中抛出的Error对象没有经过任何处理,仍然是API抛出的Error对象,也就是说同样携带着API内层逻辑的堆栈信息。接下来的工作就是想办法把堆栈信息精简。
精简Error堆栈
首先缕一下当附加Decorator的API被调用时的堆栈顺序,同样是以上文提到的callFunction
为例,当外层业务逻辑调用这个API时整体的链路如下图所示:
这只是源码的链路,实际上使用TypeScript或ES6语法编写的源码需要经过语法转换或者引入polyfill才能在浏览器中运行,所以实际上的链路长度远远大于上图,尤其是async
函数(因为目前的语法转译通常会把async/await
转化为generator)。这也是造成错误堆栈层次太深的主要原因之一。
上文提到的catchErrorsDecorator
的工作分两步:
- 第一步是Decorator自身的逻辑,也就是复写API原本逻辑的代码块,这一步是给API添加Decorator之后立即执行的;
- 第二步是当外层逻辑调用
callFunction
之后,执行descriptor.value
内部逻辑。
这两个步骤并不是连续的,而是分属于两条链路,第一条发生在SDK初始化时,第二条发生在外层逻辑调用API时。
在SDK初始化的链路内,Decorator的第一步逻辑的前序环节是初始化被装饰的API,所以在这里可以拿到原API的源码行,可以借助Error.stack取到,如下:
/**
* decorate在stack中一般都特定的规范
*/
const REG_STACK_DECORATE = isFirefox ?
/(\.js\/)?__decorate(\$\d+)?<@.*\d$/ :
/(\/\w+\.js\.)?__decorate(\$\d+)?\s*\(.*\)$/;
const REG_STACK_LINK = /https?\:\/\/.+\:\d*\/.*\.js\:\d+\:\d+/;
function catchErrorsDecorator(options){
return function(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<Function>
){
let sourceLink = '';
const outterErrStacks = (new Error()).stack.split('\n');
const indexOfDecorator = outterErrStacks.findIndex(str=>REG_STACK_DECORATE.test(str));
if(indexOfDecorator!==-1){
const match = REG_STACK_LINK.exec(outterErrStacks[indexOfDecorator+1]||'');
sourceLink = match?match[0]:'';
}
const fn = descriptor.value;
// 重新被装饰的API原本逻辑
descriptor.value = function(...args:any[]) {
const innerErr = getRewritedError({
err: new Error(),
className,
methodName: fnName,
sourceLink
})
try {
return fn.apply(this, args);
} catch (err) {
throw err;
}
}
}
}
之所以把获取原API代码行的逻辑放在Decorator的第一步,是由于此时距离原API的堆栈层数比较浅,而如果放到第二步(即descriptor.value
内部)获取,则有可能由于堆栈太深取不到。
这里需要说明的一点,获取原API代码行是通过匹配Error.stack信息。调用throw Error或console.error后在浏览器的控制台打印的堆栈是完整的,但是浏览器在返回Error.stack信息时并不是将全部的堆栈返回,而是只返回最前列的几条,一般是5-10条。这也是为何将获取原API代码行的逻辑放在descriptor.value
外执行的主要原因。
另外在上述代码中添加了如下一段逻辑:
const innerErr = getRewritedError({
err: new Error(),
className,
methodName: fnName,
sourceLink
})
其中工具函数getRewritedError
的作用是在Error.stack中找到执行descriptor.value
的前一条信息,这条信息便是外层逻辑调用callFunction
时执行被复写的callFunction
API的代码行,而这条信息之前(Error堆栈是倒序排列)的所有堆栈都是callFunction
的内层逻辑,是要被剔除的无用信息。
getRewritedError
函数的代码比较长就不写了,感兴趣的可以去看源码。
接下来的工作就简单了,从Error.stack中过滤无用的信息,然后把descriptor.value
条目的链接替换为先前拿到的原API代码行,最后new一个Error对象将其stack替换为处理之后的在抛出即可。
边角料工作
截止到这里,优化工作的核心内容就已经完成了,剩下的就是完善一下逻辑支持更丰富的场景,比如:
- 支持同步和异步两种模式;
- 用
console.group
打印错误信息和解决方案建议; - 兼容多种构建工具(Webpack和Rollup,不同的构建工具混淆后的Decorator堆栈有略微差异);
- 兼容多种浏览器(不同浏览器内核的堆栈格式有差异)
等等。这些小事就不写了,感兴趣的可以去阅读源码。
最终的接入方式就是import这个Decorator,然后为API添加装饰器,如下:
class Cloudbase {
@catchErrorsDecorator({
//同步模式
mode: 'sync',
// title和message是错误提示信息,可定制
title: 'Cloudbase 初始化失败',
messages: [
'请确认以下各项:',
' 1 - 调用 cloudbase.init() 的语法或参数是否正确',
' 2 - 如果是非浏览器环境,是否配置了安全应用来源(https://docs.cloudbase.net/api-reference/webv2/adapter.html#jie-ru-liu-cheng)',
`如果问题依然存在,建议到官方问答社区提问或寻找帮助:${COMMUNITY_SITE_URL}`
]
})
public init(options){
// ...
}
}
最后值得一提的是,这种优化方案只支持在开发环境下使用,一是因为逻辑比较繁琐,带入到生产环境中会产生不必要的资源消耗;二是由于生产环境的js通常是所有模块打包到一起并且经过混淆,造成堆栈信息难以定位。