配合源码阅读体验更佳。

最近收到用户吐槽 @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的优势有两点:

  1. 不侵入SDK原本逻辑,接入成本很低,只需要几行代码;
  2. TypeScript将Decorator编译为ES5语法之后有固定的格式,可以方便地在Error堆栈中找出对应的代码行,为精简Error堆栈提供便利。

写到这里其实大体的思路就定型了,步骤如下:

  1. 给API添加Decorator;
  2. 在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通常是所有模块打包到一起并且经过混淆,造成堆栈信息难以定位。

利用Decorator和SourceMap优化JavaScript错误堆栈的更多相关文章

  1. 利用Image对象,建立Javascript前台错误日志记录

    手记:摘自Javascript高级程序设计(第三版),利用Image对象发送请求,确实有很多优点,有时候这也许就是一个创意点,再次做个笔记供自己和大家参考. 原文: 开发 Web 应用程序过程中的一种 ...

  2. 利用模板将HTML从JavaScript中抽离

    利用模板将HTML从JavaScript中抽离 一.当需要注入大段的HTML标签到页面中时,应该使用服务器渲染(从服务器加载HTML标签) 该方法将模板放置于服务器中使用XMLHttpRequest对 ...

  3. JavaScript错误/异常处理

    JavaScript Try...Catch 语句 介绍:JavaScript中的try...carch语句的作用和C#中的try...catch语句的作用一样, 都是捕获并处理异常. 语法: try ...

  4. javascript错误处理与调试(转)

    JavaScript 在错误处理调试上一直是它的软肋,如果脚本出错,给出的提示经常也让人摸不着头脑. ECMAScript 第 3 版为了解决这个问题引入了 try...catch 和 throw 语 ...

  5. JavaScript错误处理

    JavaScript 错误 - Throw.Try 和 Catch JavaScript 测试和捕捉 try 语句允许我们定义在执行时进行错误测试的代码块. catch 语句允许我们定义当 try 代 ...

  6. 第一百二十三节,JavaScript错误处理与调试

    JavaScript错误处理与调试 学习要点: 1.浏览器错误报告 2.错误处理 3.错误事件 4.错误处理策略 5.调试技术 6.调试工具 JavaScript在错误处理调试上一直是它的软肋,如果脚 ...

  7. 【转】Javascript错误处理——try…catch

    无论我们编程多么精通,脚本错误怎是难免.可能是我们的错误造成,或异常输入,错误的服务器端响应以及无数个其他原因. 通常,当发送错误时脚本会立刻停止,打印至控制台. 但try...catch语法结构可以 ...

  8. [置顶] 利用Global.asax的Application_Error实现错误记录,错误日志

    利用Global.asax的Application_Error实现错误记录 错误日志 void Application_Error(object sender, EventArgs e) { // 在 ...

  9. 工作经验:Java 系统记录调用日志,并且记录错误堆栈

    前言:现在有一个系统,主要是为了给其他系统提供数据查询接口的,这个系统上线不会轻易更新,更不会跟随业务系统的更新而更新(这也是有一个数据查询接口系统的原因,解耦).这时,这个系统就需要有一定的方便的线 ...

随机推荐

  1. Python的基本运用(一)

    1.a**b  表示a的b次方. 2.def something(a,b):  定义函数,注意 python的缩进 . 3.print (a)与print a 的区别,python3中不支持print ...

  2. 好用的连接池-druid

    druid连接池是阿里巴巴的数据库连接池项目.它的一个亮点强大的监控功能以及防SQL注入,同时不影响性能.这里是它的GitHub地址.感觉druid扩展的功能还是很实用的. 实用的功能 详细的监控 E ...

  3. 迭代列表不要For循环,这是Python列表推导式最基本的概念

    如果你还在使用 For 循环迭代列表,那么你需要了解了解列表推导式,看看它的基本概念都是什么. 列表解析式(List comprehension)或者称为列表推导式,是 Python 中非常强大和优雅 ...

  4. Java 图书管理项目

    思路总结: 1.使用空布局 2.构造方法里写初始打开的界面 3.return 意思是 "否则"  代替else if,一切归于平静 4.连接数据库时 db=new database ...

  5. 浅析Facebook LibraBFT与比原链Bystack BBFT共识

    如果说什么是区块链的灵魂,那一定是共识机制. 它是区块链的根基.无论公链或是联盟链,共识机制都从基础上限制了区块链的交易处理能力和扩展性. 2019年6月18日,Facebook 发布了自己 Libr ...

  6. 手写IOC实现过程

    一.手写ioc前基础知识 1.什么是IOC(Inversion of Control 控制反转)? IoC不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合.更优良 ...

  7. LeetCode 413 Arithmetic Slices详解

    这个开始自己做的动态规划复杂度达到了O(n), 是用的是2维的矩阵来存前面的数据,复杂度太高了, 虽然好理解,但是没效率,后面看这个博客发现没有动态规划做了这个题 也是比较厉害. 转载地址: http ...

  8. ThinkPHP 6.0 基础教程 - 安装

    ThinkPHP6.0 的环境: PHP >= 7.1.0 我本地环境: Win10 PhpStudy 安装 PhpStudy 如果你已经安装 PhpStudy 或其他环境,请忽略这里 安装方法 ...

  9. 漏洞重温之文件上传(FUZZ)

    文件上传FUZZ思路通关upload-labs Pass-16 黑盒阶段 进入第十六关,首先我们能看到,该页面的上传点为图片上传. 首先,先把对方想的简单一点,这里虽然是上传图片,但是可能只是前端js ...

  10. Vue3.0数据响应式原理

    在vue2版本中响应式使用的是ES5对象的操作,通过遍历对象Object.defineProperty属性值的变化,实现监听数据 在3.0中使用的ES6版本的Proxy代理对象方式来实现数据的监听,省 ...