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_grpcpb.js 代码:
function serialize_keycenter_SecretData(arg) {
if (!(arg instanceof keycenter_pb.SecretData)) {
throw new Error('Expected argument of type keycenter.SecretData');
}
return Buffer.from(arg.serializeBinary());
}
proto.keycenter.SecretData.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.keycenter.SecretData;
return proto.keycenter.SecretData.deserializeBinaryFromReader(msg, reader);
};
proto.keycenter.SecretData.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = /** @type {string} */ (reader.readString());
msg.setKeyName(value);
break;
case 2:
...
}
}
return msg;
};
从 var msg = new proto.keycenter.SecretData; 看起其就是通过 SecretData 构造函数创建了一个实例,并传入 .deserializeBinaryFromReader 方法中进行赋值,最后返回该实例。
所以目前从这个错误看起来,像是一个 new A instanceof A === false 的伪命题。但显然并不可能。所以我的判断是,这里面一定有一个“李鬼” —— 有一个看起来像是 SecretData 但实际不是的家伙冒充了它。
结合上面的情况,对于 new A instanceof A === false 的问题,基本可以认定为是 new A' instanceof A === false(注意里面的 A 和 A')。也就是在
function serialize_keycenter_SecretData(arg) {
if (!(arg instanceof keycenter_pb.SecretData)) {
throw new Error('Expected argument of type keycenter.SecretData');
}
return Buffer.from(arg.serializeBinary());
}
proto.keycenter.SecretData.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.keycenter.SecretData;
return proto.keycenter.SecretData.deserializeBinaryFromReader(msg, reader);
};
注意第二行 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 组件来实现缓存功能。在满心欢喜地加完代码运行后,控制台报出了如下错误信息:
而这个 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_grpcpb.js 代码:serialize_keycenter_SecretData
是用于在请求时将SecretData
实例序列化为二进制数据的方法。可以看到,方法里会判断arg
是否是keycenter_pb.SecretData
的实例。在我们项目的场景下,我们事先会得到了 pb 对象二进制的 base64 编码值,所以在代码中会使用 _xxxpb.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 生成的 __grpcpd.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 相关报错的排查过程。包括找出原因、提出解决思路到最后修复的整个过程。
排查问题是每个工程师经常会面对的事儿,也常常充满挑战。往往这些问题的落脚处可能并不大,修复工作也只是简单几行代码。而排障的过程,伴随着各类知识或技术点的使用,从表象到真相,整个过程也是工程师独有的乐趣。
而在文章写作上,相比介绍一个技术点,要写好一篇排障文章往往更不容易,所以也想挑战一下自己。