You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Error: 13 INTERNAL: Request message serialization failure: Expected argument of type keycenter.SecretData
at Object.callErrorFromStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call.js:31:26)
at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client.js:176:52)
at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:342:141)
at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:305:181)
at /Users/zhouhongxuan/programming/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call-stream.js:124:78
at processTicksAndRejections (internal/process/task_queues.js:75:11)
还记得最初的问题么?问题的抛错 Error: 13 INTERNAL: Request message serialization failure: Expected argument of type XXX 来自于 grpc-tools 生成的 Nodejs 版 xxx_grpc_pb.js 代码:
functionserialize_keycenter_SecretData(arg){if(!(arginstanceofkeycenter_pb.SecretData)){thrownewError('Expected argument of type keycenter.SecretData');}returnBuffer.from(arg.serializeBinary());}
结合上面的情况,对于 new A instanceof A === false 的问题,基本可以认定为是 new A' instanceof A === false(注意里面的 A 和 A')。也就是在
functionserialize_keycenter_SecretData(arg){if(!(arginstanceofkeycenter_pb.SecretData)){thrownewError('Expected argument of type keycenter.SecretData');}returnBuffer.from(arg.serializeBinary());}
注意第二行 var msg = new proto.keycenter.SecretData,使用了 proto.keycenter.SecretData 这个构造函数,而我们根据前面的代码可以知道,这里的 proto 其实是 [global].proto。所以一旦我们的全局对象上的指向被修改后,这里使用的 keycenter.SecretData 其实就是另一个构造函数了。
本文记录了使用 Node gRPC(static codegen 方式)时,遇到的一个“奇怪”的坑。虽然问题本身并不常见,但顺着问题排查发现其中涉及到了一些有意思的点。去沿着问题追根究底、增长经验是一种不错的学习方式。所以我把这次排查的过程以及涉及到的点记录了下来。
1、场景还原
如果在你了解过或在 NodeJS 中使用过 gRPC,那么一定会知道它有两种使用模式 ——「动态代码生成」(dynamic codegen)和「静态代码生成」(static codegen)。
我们的项目使用了公司内部的解密组件包(也是我们维护的),叫 keycenter。解密组件中需要用到 gRPC 请求,并且它使用了「静态代码生成」这种模式。
之前项目一直都正常运行。直到有一天引入了 redis 组件来实现缓存功能。在满心欢喜地加完代码运行后,控制台报出了如下错误信息:
Error: 13 INTERNAL: Request message serialization failure: Expected argument of type keycenter.SecretData at Object.callErrorFromStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call.js:31:26) at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client.js:176:52) at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:342:141) at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:305:181) at /Users/zhouhongxuan/programming/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call-stream.js:124:78 at processTicksAndRejections (internal/process/task_queues.js:75:11)
而这个 redis 组件确实间接依赖了 gRPC。这里放一个组件模块依赖关系,说明一下项目使用的各组件包之间的关系。
其中每个黄色组件就是一单独的 npm 包。业务代码直接使用了 keycenter 包进行了秘钥的解密;同时引入了 redis 缓存组件,而缓存模块间接依赖了 keycenter。最终 keycenter 组件通过「静态代码生成」的方式使用 gRPC。
下面我们就来一起看看这个问题。
2、问题排查
2.1、莫非是 redis 组件内部逻辑出错了?
最直接的想法就是:新引入的这个 redis 组件有问题。因为出现问题的第一时间,我就把项目里下面这行代码注释掉了:
注释完果然就好了。所以引入新组件确实导致了问题。
由于报错和 gRPC 有关,而 redis 内部也间接依赖到了 gRPC(因为间接依赖了 keycenter),那么我的第一反应就是,这个组件内部逻辑可能有问题。也许是哪步操作使用到了 keycenter 方法,然后报出了错误。
但这个想法出现的有多快,排除的就有多快。
通过添加断点、日志的方式,很快就得出了一个结论:redis 组件虽然依赖到了 keycenter,但是整个实例化过程中完全不会调用它的方法,既然没有调用,这个 gRPC 的错误自然不是它直接导致的。
但它和 redis 组件或多或少脱不了关系。
2.2、是否真的是 redis 实例化导致了报错?
上面我通过注释掉 Redis 实例化的代码行后运行正常,初步判断是实例化导致的问题。然而我忽略了重要的一点,typescript 编译时,对于 import 但是没有使用的模块,在产出的代码里是会把模块引入的这段删除的。
例如下面这段代码,导入的模块实际没有使用,在编译产出的代码中就不会导入该模块:
而如果是这样
或者这样
则模块引入的代码
require(@infra-node/redis)
在产出中会被保留。因此,实例化操作很可能并不是导致问题的原因。通过进一步测试,发现直接原因是引入了
@infra-node/redis
模块。导入模块就会导致问题,只要不导入就没事儿,我第一时间的直觉有两个:到这里,我们先回到最初的问题。
2.3、
new A instanceof A === false
?还记得最初的问题么?问题的抛错
Error: 13 INTERNAL: Request message serialization failure: Expected argument of type XXX
来自于 grpc-tools 生成的 Nodejs 版 xxx_grpc_pb.js 代码:serialize_keycenter_SecretData
是用于在请求时将SecretData
实例序列化为二进制数据的方法。可以看到,方法里会判断arg
是否是keycenter_pb.SecretData
的实例。在我们项目的场景下,我们事先会得到了 pb 对象二进制的 base64 编码值,所以在代码中会使用 xxx_pb.js 文件提供的反序列化生成
SecretData
的实例,并设置其他属性。并且这里我打印
arg
后,在控制台看起来它的值也很正常。SecretData.deserializeBinary
的方法实现如下:从
var msg = new proto.keycenter.SecretData;
看起其就是通过SecretData
构造函数创建了一个实例,并传入.deserializeBinaryFromReader
方法中进行赋值,最后返回该实例。所以目前从这个错误看起来,像是一个
new A instanceof A === false
的伪命题。但显然并不可能。所以我的判断是,这里面一定有一个“李鬼” —— 有一个看起来像是SecretData
但实际不是的家伙冒充了它。听起来似乎很奇怪。只能揣着性子继续排查。
2.4、“奇怪”的依赖安装?
首先回顾一下上面列出的包/模块依赖关系:
我瞟了下目前实际的包安装情况。大致如下(省略了一些无关的包信息):
上面列出了目前项目中的包安装情况。可以看到一个比较有意思的地方:外层存在一个 keycenter 包,同时在 redis 内部也安装了一个 keycenter 包。这是为什么呢?
原因很简单:项目直接依赖的 keycenter 版本声明与 redis 中的依赖版本无法合并指向同一版本,所以会在两个地方分别安装。这是 npm 的正常机制。一般这种情况也并不会出现问题。
但当我手动删除了 redis 中的 keycenter 后,项目又可以正常运行了。看来“李鬼”就是这儿了。
2.5、莫非引用了错误的模块文件?
结合上面的情况,对于
new A instanceof A === false
的问题,基本可以认定为是new A' instanceof A === false
(注意里面的 A 和 A')。也就是在这个方法执行时,传入的
arg
的构造函数与方法中的keycenter_pb.SecretData
实际不同。这让我怀疑,是不是引用了错误的 _pb.js 文件。例如一个是用的外层 keycenter 中的keycenter_pb.js
,另一个则是使用到了 redis 中 keycenter 中的keycenter_pb.js
。两个文件一模一样,函数签名一模一样,但看起相同的两个对象,实则不同,自然过不了判断。难道是构造
arg
参数时引入的keycenter_pb.js
和serialize_keycenter_SecretData
方法引入的keycenter_pb.js
不同么?基于我对 Nodejs
require
机制的了解,基本排除了这个可能。它们是通过相对路径引入,根据模块寻路的规则,都会命中各自包内的代码模块。不存在引到其他包内的代码文件的情况。2.6、模块是如何被“污染”的?
如果引用的模块没有问题,那么会不会是模块内的变量被“污染”了?
这就和我最开始的直觉 —— “副作用”,有些关联了。副作用的产生场景很多,但是有一个场景非常典型,就是全局变量的使用。在查看
keycenter_pb.js
文件的代码后,我发现果然如此:代码通过
Function('return this')()
获取了全局对象。然后通过执行goog.exportSymbol
方法,在全局对象上挂载global.proto.keycenter.SecretData
属性值。最后再在exports
上挂载proto.keycenter
对象作为导出。但如果仔细分析,仅仅上述代码,并不会导致这个错误。因为它会先修改 global 引用的指向,再修改 global 上对应的对象。例如引入模块后引用关系大致如下:
当运行环境中再次引入一个同样内容
_pb'.js
文件后,就会变成如下引用关系。可以看到原先的 proto 对象并不会被修改,即外部之前导入的对象并不会变。那么究竟是如何被“污染”的呢?
其实问题来自于 2.3 节中用到的
.deserializeBinary
这个方法。这是_pb.js
在构造函数上暴露出来的静态方法,可以根据二进制数据生成对应的实例对象:注意第二行
var msg = new proto.keycenter.SecretData
,使用了proto.keycenter.SecretData
这个构造函数,而我们根据前面的代码可以知道,这里的 proto 其实是[global].proto
。所以一旦我们的全局对象上的指向被修改后,这里使用的keycenter.SecretData
其实就是另一个构造函数了。真相大白。导致错误的过程如下:
keycenter_grpc_pb.js
引入了同目录下keycenter_pb.js
文件,模块中的keycenter.SecretData
构造函数这时候就确定了keycenter_pb-2.js
。它和keycenter_pb.js
内容一摸一样,不过是两个文件。这时候 global 上指向的对象就被修改了keycenter_pb.js
模块,再使用SecretData.deserializeBinary
生成实例,传入keycenter_grpc_pb.js
中的方法就会出错了✨ 为了大家更好理解,我复刻了这个问题的核心逻辑,做成了 demo,大家可以 clone 到本地再配合文章内容来查看、运行。
☕️ 上面已经完成了问题的排查,下面的文章会进入到另一个主题 —— 问题修复。本身以为会较为顺畅的修复过程,也遇到一些意料之外的问题。
3、解决思路
如果理解了错误原因,就会发现这个错误出现的条件还是比较苛刻的。需要同时满足以下几个必要条件才会复现:
_pb.js
文件.deserializeBinary
方法来创建实例对象_grpc_pb.js
,再导入_pb'.js
(同内容的另一个 pb 文件)针对 2~4 这三个条件,我们只要破坏其一,就可以避免问题发生。我在 demo 项目中分别写了对应的代码(correct-2.ts、correct-3.ts、correct-4.ts),感兴趣的话可以试下。
如果作为包提供方,要解决这个问题虽然看似方式很多,但是现实上我们能控制的有限 ——
.deserializeBinary
是功能要求,如果要规避这个方法的坑会使代码变得较为 tricky;所以我们尽量还是希望能找一个“正规”的路子,使得通过 grpc-tools 或者 protoc 生成的
_pb.js
文件,不会产生全局污染(也就是破除条件 1)。4、修复之路
4.1、让 protoc 生成的代码避免全局污染
按上面的思路,我们会希望在 protoc 生成时就产出一份“安全”的
_pb.js
静态文件。protoc 支持在 js_out 参数中设置
import_style
来控制模块类型。官方文档里提供了commonjs
这个参数。但是遗憾的是,这个参数并不会生成我们预想的代码,它生成的代码就是我们在上文中看到的“问题代码”。所以还有其他
import_style
么?文档里没有,只能去源码里找答案了。
在源码中可以发现,其支持的 style 值并非只有 commonjs 和 closure 两种:
但大致浏览完源码后,我发现 browser 和 es6 两种 style 实际也不能满足我们的需求。这时候就剩下
commonjs_strict
了。这个 strict 感觉就会非常贴合我们的目标。主要的相关代码如下:
这里就可以看出
commonjs_strict
和commonjs
最大的区别就是是否使用了全局变量。如果是commonjs_strict
则会使用var proto = {};
来代替全局变量。完全满足需求!但是,实际使用后,我发现了另一个问题。
4.2、grpc-tools 并不适配
commonjs_strict
import_style=commonjs_strict
另一个最大的区别在于导出代码的生成:这样看可能不太直观,直接贴两种 style 生成的代码就很明白了。
下面是用
commonjs_strict
生成的:下面是用
commonjs
生成的:这样就能明显看出区别了。
commonjs
形式导出时会导出 package 下的对象。因此,在我们使用对应的_pb.js
文件时,会需要调整一下导入的代码。此外,grpc-tools 生成的 _grpc_pd.js 静态代码因为也会导入_pb.js
文件,因此也需要适配这种导出。而当我满心欢喜地去翻阅 grpc-tools 源码时发现,
它并不会考虑
import_style=commonjs_strict
这种情况,而是固定生成对应commonjs
的导入代码。也有 issue 提到了这个问题。4.3、只能自己动手了
好吧,这个导入/导出的问题目前没有特别好的解决办法。
我们这边之前因为一些特殊需求,所以 folk 了 grpc-tools 的代码,修改了内部实现以适配我们的 RPC 框架。因此这块就自己上手,支持了
import_style=commonjs_strict
这种情况,修改了导入时的代码:当然还需要配合做一些其他改动,例如 CLI 入参的判断处理等,这里就不贴了。
当然,令人头疼的问题不止这一个,如果你使用了其他 protoc 插件自动生成 .d.ts 文件的话,这块也会需要适配
import_style=commonjs_strict
的情况。5、最后
本文主要记录了一次 gRPC 相关报错的排查过程。包括找出原因、提出解决思路到最后修复的整个过程。
排查问题是每个工程师经常会面对的事儿,也常常充满挑战。往往这些问题的落脚处可能并不大,修复工作也只是简单几行代码。而排障的过程,伴随着各类知识或技术点的使用,从表象到真相,整个过程也是工程师独有的乐趣。
而在文章写作上,相比介绍一个技术点,要写好一篇排障文章往往更不容易,所以也想挑战一下自己。
The text was updated successfully, but these errors were encountered: