如何解决 Node.js 20 升级中未预期的请求问题

在 Tubi,我们使用 Node.js 为 Web/OTT 应用进行服务端渲染及代理请求。近来,为了从新版本的性能改进和新功能中受益,我们将 Node.js 从 14.x 版本升级到了 20.x。

升级像 Node.js 这样的基础设施绝非易事,尤其是有着许多第三方依赖的大型项目,因为我们无法预测可能会出现的问题。在升级过程中,我们很快便遇到了第一个问题:服务器端发送的每个请求都会失败,并返回 404 的错误。

调试

第一个被怀疑的对象是仓库里已经逐渐被我们弃用的 request 库。我们在 Node.js 20 环境下使用 request 库发送请求复现了该问题:request.get('foo.bar/get'),服务端返回了包括 404 响应在内的多种错误。该库使用了 Node.js 内置的 http(s) 模块来发送请求。为了探究发送请求时究竟发生了什么,我们又给测试添加了NODE_DEBUG=http,net 和 -trace-event-categories node.http,node.net.native 标志。

json 复制代码
{"cat":"node,node.http","name":"http.client.request","args":{"data":{"path":"/"}}}

以下是我们的发现:

  • 我们请求的是 foo.bar/get,但 http.request 的 path 却是 /。
  • 如果我们将 Node.js 降级到 14 版本并使用同样的代码,path 的值则是正确的 /get。这解释了为什么服务器返回了 404 响应。

我们尝试在 Node.js 20 版本下构造两个最小复现案例: 一个案例采用了原生的 Node.js 模块 http,另一个案例使用了 request 库;但这两个案例都无法复现这一异常情况。这让我们开始怀疑,我们的代码库中是否有可能存在问题?

在深入研究代码库之前,我们在该库 GitHub 的 issues 中发现了一个类似问题,其中提到了该问题可能与用于追踪错误的 Sentry SDK 有关。我们通过在 Node.js 20 中引入 Sentry 和该库成功地复现了这一问题。那么,为什么 Request 库 + Sentry + Node.js 20 会导致这一问题出现? 我们来看看它们分别对 Node.js 的 http(s) 模块做了什么。

调试内置的 Node.js 模块

为了检查传递给 Node.js 内置 http(s) 模块的真实参数,我们通过在命令中添加了 -expose-internals 参数(以及 -r internal/test/binding 标志来访问 primordials),并从 Node.js 代码库中复制了 http(s) 模块文件:

ini 复制代码
// 将 
const https = require('https');
// 修改为
const https = require('./path/to/local/https');

现在我们可以打印日志、添加断点了,还可以直接修改模块代码。我们还发现了更多线索:http(s) 模块接受WHATWG URL 对象或一个普通对象作为参数。在我们的案例中,request 库将一个普通对象作为参数发送给 http(s) 模块,但由于某种原因,http(s) 模块将其视为了 URL 对象。而如果它是一个 URL 对象,Node.js 会将其转换为 http(s) 需要的参数,并将 path 设置为 URL.pathname + URL.search。而这个库传递的对象上不存在这些属性,于是导致了真正发出的请求中 path 为空:

scss 复制代码
// The request function that http(s) module use:
// <https://github.com/nodejs/node/blob/36c72c8b2fb03414e02ffd8402c05129647ce123/lib/https.js#L367C5-L369>
function request(...args) {
 ...
 if (isURL(args[0])) {
   options = urlToHttpOptions(ArrayPrototypeShift(args));
 }
}
javascript 复制代码
// https://github.com/nodejs/node/blob/36c72c8b2fb03414e02ffd8402c05129647ce123/lib/internal/url.js#L1322C1-L1344
function urlToHttpOptions(url) {
 const { pathname, search } = url;
 const options = {
  ...
  path: `${pathname || ''}${search || ''}`,
 }
 return options
}

那为什么 http(s) 模块会将来自该库的普通对象认为是一个 URL 对象呢? 在 isURL 函数中,如果在参数中找到 href 和 protocol 字段,则会认为其是一个 URL 对象:

ruby 复制代码
// <https://github.com/nodejs/node/blob/36c72c8b2fb03414e02ffd8402c05129647ce123/lib/internal/url.js#L761>
function isURL(self) {
 return Boolean(self?.href && self.protocol && self.auth === undefined);
}

那么这两个属性是如何被添加到传递给 http(s) 模块的对象上的呢? 我们检查了库和 Sentry 库的源代码,发现 request 库添加了 href 属性,而 Sentry 添加了 protocol 属性

谜团终于解开了!

如何修复这一问题?

我们理解了造成 404 错误的原因,也很清楚因为 request 库、Sentry 和 Node.js 都存在各自的问题,所以修复这一问题将变得十分棘手;但问题的根源在于 Node.js 方面:isURL 函数无法准确判断是否为 URL 对象。因此,我们必须找到一种准确判断 URL 对象的方法。这应该不会太难吧?

我们无法找到一个最直接的解决方案,但我们注意到 isURL 函数上的另一个检查 self.auth === undefined 解决了类似的问题;这就是为什么我们添加了遗留的 URL 对象属性 self.path === undefined: path,而非 WHATWG URL 对象属性。通过验证 path 是否为 undefined 来确定是否为 WHATWG URL 对象,这是更合理的。在我们的案例中,path不是 undefined,这意味着该对象不是 WHATWG URL 对象。

于是我们决定在 GitHub 上开启了一个讨论,提交一个包含修复方案的 Pull Request,并对 Yagiz Nizipli 的这一评论做了深入思考:"这有点像鸡生蛋还是蛋生鸡的问题,我们既不能检查 URL 对象,也不能正确检查 url。我们唯一能做的就是检查它们的行为 / 定义。"

好消息是,我们提出的修复方案将在 v20.6.0 版本中发布!对于那些继续使用 v18.x 的用户,这一修复方案很可能也会在v18.18.0 版本中提供!

总结

Node.js、 request 库和 Sentry 库的变化导致 http(s) 模块误将一个普通对象识别为 URL 对象;这就是 http(s) 模块重置了 path 并引发了问题的原因。以下是 Node.js 和这两个库近期实现的一些变更:

我们的收获

我们在解决了 Node.js 20 升级中出现的意外请求问题后,希望与大家分享这样几点收获:

  • 分而治之

由多个第三方库交互而引发的问题可能很难调试与修复,但有一种方法是从底层开始,逐步追踪问题的源头,将其分解为更小的问题,以更容易处理。

  • 避免使用已弃用的库

这相当于在代码中放置了一个定时炸弹,它最终一定会爆炸。我们在实践中便遇到了这一问题。我们有计划替换已弃用的库,但在工作完成之前,这一定时炸弹就爆炸了。

  • 避免 patch 原生模块

在 Node.js 上 patch 原生模块(如 http(s))会使调试变得更加困难,尤其是对于第三方库,工程师们应避免这种做法。

  • 有时我们无法找到最完美的解决方案,我们能做的就是全力以赴。

Tubi 正在招聘

如你所见,Tubi 技术团队成功解决了在 Node.js 20 升级过程中出现的未预期的请求问题。我们快速锁定了由第三方依赖引起的 URL 对象检测变化是这一问题的根源。如果你和我们一样对这一类问题的解决感兴趣,欢迎加入我们!

Tubi 技术团队正在招聘,点击此处可查看 Tubi 的热招岗位。

作者:Zhuo Zhang, Tubi Senior Software Engineer

相关推荐
undefined&&懒洋洋10 分钟前
Web和UE5像素流送、通信教程
前端·ue5
VXbishe1 小时前
(附源码)基于springboot的“我来找房”微信小程序的设计与实现-计算机毕设 23157
java·python·微信小程序·node.js·c#·php·课程设计
IT小白32 小时前
node启动websocket保持后台一直运行
websocket·node.js
winkee2 小时前
在 git commit 中使用 gpg key 进行签名
架构·前端框架·代码规范
大前端爱好者2 小时前
React 19 新特性详解
前端
随云6322 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl
随云6322 小时前
WebGL编程指南之进入三维世界
前端·webgl
Dylanioucn2 小时前
【分布式微服务云原生】掌握 Redis Cluster架构解析、动态扩展原理以及哈希槽分片算法
算法·云原生·架构
寻找09之夏3 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
多多米10054 小时前
初学Vue(2)
前端·javascript·vue.js