断点调试之压缩造成的血案

2022-02-07
6分钟阅读时长

提醒:本文最后更新于 794 天前,文中所描述的信息可能已发生改变,请谨慎使用。

Featured Image

前段时间组里的小伙伴让我帮忙排查一个线上问题,我觉得排查流程比较有意思,想着记录一下看看是否能对其它同学有所帮助,遂有此文。

事情的起因是前几天线上突然收到一个报警,错误内容是 TypeError: C.fn is not a function。相关同学尝试排查无果后又回滚了最近上线的变更也没有排查到问题。虽然最终确认了复现路径,但是在本地却无法复现。

🔍 初步排查

在线上复现该错误后,点击错误堆栈的文件跳转,快速定位到线上出错的代码。由于线上都是压缩过的代码,这里我们可以点击左下角的 {} 进行代码美化。

经过美化后我们可以看出来,应该就是 189624 行出了问题。我们直接尝试在这一行上打断点,之后会发现代码会在这块疯狂打转。这是因为它处于一个 for 循环中。仔细观察不难看出代码其实上是 this.head 这个链的递归执行,每次执行完当前 C 都会被赋值成链的下一个值,并执行该值对应的 fn() 方法。也就是问题是这个链上的某个值没有 fn() 方法,最终导致了这个报错。

大概确认问题后,我们需要看一下最终这个 C 的值是什么。由于处在循环当中,一次一次的点击下一步实在是麻烦。由于我们有明确的目标,所以我们可以尝试添加条件断点,让只有符合我们条件的断点才停下来,否则都忽略正常执行。

在 189624 行右键点击 Add conditional breakpoint… 选项,并输入 typeof C.fn !== 'function' 作为条件表达式。这样我们就实现了一个仅在 C.fn 不是一个方法的时候才会触发的条件断点。

条件断点触发后,我们可以在控制台中基于断点时的上下文输出变量进行调试。可以从下左图我们可以清晰的看到,此时的 C.fn 的确是不存在的。

由于刚才我们已知 this.head 应该是一条链,依次执行链上的方法。所以理论上来说链上的每个元素都是一样的。于是乎我就尝试输出了 this.head 链上所有的元素想看一下这个链到底是什么样子的。模拟代码里的循环我也在控制台尝试写了下,发现输出的结果如下左图展示。在链的最后一个元素就是我们有问题的元素。

而之前我们已知的是在本地开发环境是无法复现这个问题的,所以我照猫画虎在本地同样的位置也输出了一下 this.head 链,结果见上右图。发现和线上输出的,除了最后这个有问题的元素,其它的输出基本是一样的。

看来问题的原因就在于线上的代码执行在链上增加了这么一个玩意导致的,而本地由于没有这个多余的元素所以没有触发问题。

🐞 确认问题

找到原因后我就想着从代码层面捋一下是哪里给增加了这么个玩意。由于之前的代码中可以明显的看到 i.prototype.finish 的字样,初步猜测这应该是一个类的定义。于是乎就想看看这个类是在哪里实例化执行的。

通过刚报错时的压缩后的代码,我们可以看到报错的模块是”protobuf.js“这个模块。于是乎我在项目和依赖中查找是哪个模块依赖了它,最终查到了是我们内部使用的一个 IM 消息模块有用到。

之后在具体的依赖模块中搜索 .finish() 相关字样,查到了最终的调用在如下地方。serialize() 方法会调用 Request.encode() 方法,它返回一个 $Writer 基类的实例,而 $Writer 就是 protobuf.js 模块中的 Writer 基类。Request.encode() 方法实例化完 Writer 基类后会执行一系列的成员函数,执行完毕后会返回 Writer 实例,并调用它的 finish() 方法。

了解执行流程之后,我就顺着 Request.encode(req).finish() 这一句开始向上对 Request.encode() 方法进行断点(下左图)。如下图先尝试在末尾断点输出 o.heado 是压缩后指向 Writer 实例的变量),发现此时已经存在异常链元素了(下右图)。

中间的代码稍微打了下断点发现也依旧如此。最终在头部断点处发现了端倪。尝试在开头增加断电之后,发现在 120274 行执行完毕之后 o.head 链上就已经存在了异常数据了。

那我们尝试翻看下代码看一下 o.create() 方法具体干了什么。从下图左我们可以看到 Writer.create() 本质其实就是 Writer 基类的实例化工厂方法。而下图中可以看到 Writer 的构造方法对一些成员属性赋了初值。其中关键的 this.head 的初值是一个 Op 基类的实例。下图右可以看到 Op 基类的构造方法中也是赋了一些初值。同时我们可以看到 function noop() {} 实际上就是一个空方法。也就是说 this.head 默认指向了一个空方法实例化的 Op 对象。

乍一看整个流程其实非常简单,本质上构造函数内都是一些简单的赋值操作,不会有什么问题。于是乎还是按照链路依次向上排查问题。因为上一趴我们排查到执行完 Writer.create() 工厂方法后就有问题了,所以这里我们需要对 Writer 的构造函数进行断点排查。

尝试如下图在构造方法末尾断点后,输出 this.head 链,发现此时已经有异常数据了。而这个时候不过只是做了初值的操作而已,这怎么就能出问题了呢?由于断点情况下我能在当前上下文中进行调试,所以此时我尝试自己执行一下 Op 基类的实例化操作(见下图)。这时候发现确实它的 next 属性不对,是我们要找的问题元素!

此时此刻,我感觉我们已经越来越接近真相了!

如下图左我们在 f 变量上 hover 一会儿,会出现它的定义处链接,点击后会直接跳转到它的定义处下图右(其实就离的不太远)。

大家可能也都注意到了,我们刚才看的代码中 this.next 明明是定义成 undefined 怎么这里给定义成 g 了?而这个 g 又对上了 189456 行 g = s.base64,所以我们才看到 this.head.next 的值这么奇怪。而我们尝试看一下引用的 protobuf.js 代码,发现代码里 this.next 虽然是等于 g 但是它并没有关联到 u.base64 上。

由于我之前有解决过一些压缩再压缩后代码异常的 Case,所以至此我基本上可以断定,由于 protobuf.js 在我们的依赖中是引入的压缩后的代码,而压缩后的代码再走压缩导致了变量指向出现错乱从而导致的问题。这也侧面印证了为什么只有线上可以,本地无法复现的原因。因为本地是没有走压缩的。

🛠 如何解决

找到问题后有两种解决方法。一是正向的去查找压缩工具造成这个问题的原因;二是反向的去规避该问题,我们不引入压缩后的代码而是正常引入未压缩的代码,最终统一由项目进行压缩处理。

这两种方法都能解决问题。而第一种需要的时间会比较久,所以我们先采用了第二种方法临时解决一下。由于该依赖包不是我们维护的,我们只能使用 patch-package 给模块打补丁的方式进行修复。它的功能是在安装完依赖后会根据我们的 diff 文件对依赖进行修改。

这里我们的修改比较简单,找到我们依赖模块引入 protobuf.min.js 的地方,将其修改成 protobuf.js 即可。

🗒 后记

undefined 在压缩后就变成了 g 这个初步猜想应该是本地想要定义一个没有定义的变量,这样就是 undefined 了。我尝试克隆了下 protobuf.js 仓库进行了尝试,发现应该是 UglifyJS 中配置了 marguel.eval 导致有这个特性。

以上就是压缩造成的血案完整的排查经过,整个的过程总结一下有以下几个经验可以供大家参考:

  1. 除了单步断点,我们还有条件断点、日志断点等多种断点方式帮助我们排查问题,合理使用会加速我们排查问题的速度。
  2. 断点后当前 JS 环境会停留在当时的上下文中,我们可以在控制台执行、输出我们想要的当时环境的数据帮助排查。
  3. 控制台中我们也可以 hover 查看定义位置,进行定义间快速跳转。
  4. 压缩后的代码不可怕,我们可以通过源码对比,无法压缩的关键字进行定位查找。
  5. 只要是可以复现的问题,那都不是问题!

最后祝大家开工大吉,新的一年没有 Bug!

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

0 评论