本文记录了使用 Node gRPC(static codegen 方式)时,遇到的一个“奇怪”的坑。虽然问题自己并不常见,但顺着问题排查发现其中涉及到了一些有意思的点。去沿着问题追根究底、增加经验是一种不错的学习方式。因此我把此次排查的过程以及涉及到的点记录了下来。javascript
为了让你们在阅读时有更好的体验,我准备了一个 demo 来还原该问题,感兴趣的朋友能够 clone 下来,配合文章一块儿“食用”。java
若是在你了解过或在 NodeJS 中使用过 gRPC,那么必定会知道它有两种使用模式 ——「动态代码生成」(dynamic codegen)和「静态代码生成」(static codegen)。node
这里简单解释下(对 gRPC 有了解的小伙伴能够直接跳过这段)。RPC 框架通常都会选择一种 IDL,而 gRPC 默认使用的就是 protocol bufffers,咱们通常会叫该文件 PB 或 proto 文件。根据 PB 文件能够自动生成序列化/反序列化代码(xxx_pb.js),用于 gRPC 时还会生成适配 gRPC 的代码(xxx_grpc_pb.js`)。若是在 Nodejs 进程启动后,再 load PB 文件生成对应方法,叫作「动态代码生成」;而先用工具生成出对应的 js 文件,运行时直接 require 生成的 js 则叫做「静态代码生成」。能够参见 gRPC 官方库中提供的示例。c++
咱们的项目使用了公司内部的解密组件包(也是咱们维护的),叫 keycenter。解密组件中须要用到 gRPC 请求,而且它使用了「静态代码生成」这种模式。git
以前项目一直都正常运行。直到有一天引入了 redis 组件来实现缓存功能。在满心欢喜地加完代码运行后,控制台报出了以下错误信息:es6
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。这里放一个组件模块依赖关系,说明一下项目使用的各组件包之间的关系。github
其中每一个黄色组件就是一单独的 npm 包。业务代码直接使用了 keycenter 包进行了秘钥的解密;同时引入了 redis 缓存组件,而缓存模块间接依赖了 keycenter。最终 keycenter 组件经过「静态代码生成」的方式使用 gRPC。redis
下面咱们就来一块儿看看这个问题。typescript
❗️ 如下的章节顺序并不是是排查时的实际顺序。你们实际排查问题时,仍是建议先看“最近的现场”。 👀 例如这个问题,就会首先去
Request message serialization failure
抛错的地方查看状况。同时再辅以上层(外层)逻辑的排查,两头夹逼找到真相。但为了让文章阅读起来更顺畅,可以有从问题表象一步步走近真相,因此选择了目前的文章结构。我会尝试去尽可能保留实际的排查路径。npm
最直接的想法就是:新引入的这个 redis 组件有问题。由于出现问题的第一时间,我就把项目里下面这行代码注释掉了:
- this.redis = new Redis(redisConfig);
+ // this.redis = new Redis(redisConfig);
复制代码
注释完果真就行了。因此引入新组件确实致使了问题。
因为报错和 gRPC 有关,而 redis 内部也间接依赖到了 gRPC(由于间接依赖了 keycenter),那么个人第一反应就是,这个组件内部逻辑可能有问题。也许是哪步操做使用到了 keycenter 方法,而后报出了错误。
但这个想法出现的有多快,排除的就有多快。
经过添加断点、日志的方式,很快就得出了一个结论:redis 组件虽然依赖到了 keycenter,可是整个实例化过程当中彻底不会调用它的方法,既然没有调用,这个 gRPC 的错误天然不是它直接致使的。
但它和 redis 组件或多或少脱不了关系。
上面我经过注释掉 Redis 实例化的代码行后运行正常,初步判断是实例化致使的问题。然而我忽略了重要的一点,typescript 编译时,对于 import 可是没有使用的模块,在产出的代码里是会把模块引入的这段删除的。
例以下面这段代码,导入的模块实际没有使用,在编译产出的代码中就不会导入该模块:
import Redis from '@infra-node/redis';
export default 1;
复制代码
而若是是这样
import Redis from '@infra-node/redis';
Redis;
复制代码
或者这样
import '@infra-node/redis';
复制代码
则模块引入的代码 require(@infra-node/redis)
在产出中会被保留。所以,实例化操做极可能并非致使问题的缘由。
经过进一步测试,发现直接缘由是引入了 @infra-node/redis
模块。导入模块就会致使问题,只要不导入就没事儿,我第一时间的直觉有两个:
到这里,咱们先回到最初的问题。
new A instanceof A === false
?还记得最初的问题么?问题的抛错 Error: 13 INTERNAL: Request message serialization failure: Expected argument of type XXX
来自于 grpc-tools 生成的 Nodejs 版 xxx_grpc_pb.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());
}
复制代码
serialize_keycenter_SecretData
是用于在请求时将 SecretData
实例序列化为二进制数据的方法。能够看到,方法里会判断 arg
是不是 keycenter_pb.SecretData
的实例。
在咱们项目的场景下,咱们事先会获得了 pb 对象二进制的 base64 编码值,因此在代码中会使用 xxx_pb.js 文件提供的反序列化生成 SecretData
的实例,并设置其余属性。
import { SecretData } from '../gen/keycenter_pb';
// ...
// 反序列化二进制
const secretData = SecretData.deserializeBinary(Buffer.from(base64, 'base64'));
secretData.setKeyName(keyName);
keyCenter.decrypt(secretData, metadata, (err, res) => {
// ...
});
复制代码
而且这里我打印 arg
后,在控制台看起来它的值也很正常。
SecretData.deserializeBinary
的方法实现以下:
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
但实际不是的家伙冒充了它。
听起来彷佛很奇怪。只能揣着性子继续排查。
首先回顾一下上面列出的包/模块依赖关系:
我瞟了下目前实际的包安装状况。大体以下(省略了一些无关的包信息):
.
├── grpc-js
│ ...
├── keycenter
└── redis
├── Changelog.md
├── LICENSE
├── README.md
├── built
├── node_modules
│ ├── @infra-node
│ │ │ ...
│ │ └── keycenter
│ ├── chokidar
│ ├── debug
│ ├── p-map
│ └── readdirp
└── package.json
复制代码
上面列出了目前项目中的包安装状况。能够看到一个比较有意思的地方:外层存在一个 keycenter 包,同时在 redis 内部也安装了一个 keycenter 包。这是为何呢?
缘由很简单:项目直接依赖的 keycenter 版本声明与 redis 中的依赖版本没法合并指向同一版本,因此会在两个地方分别安装。这是 npm 的正常机制。通常这种状况也并不会出现问题。
但当我手动删除了 redis 中的 keycenter 后,项目又能够正常运行了。看来“李鬼”就是这儿了。
结合上面的状况,对于 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());
}
复制代码
这个方法执行时,传入的 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
机制的了解,基本排除了这个可能。它们是经过相对路径引入,根据模块寻路的规则,都会命中各自包内的代码模块。不存在引到其余包内的代码文件的状况。
若是引用的模块没有问题,那么会不会是模块内的变量被“污染”了?
这就和我最开始的直觉 —— “反作用”,有些关联了。反作用的产生场景不少,可是有一个场景很是典型,就是全局变量的使用。在查看 keycenter_pb.js
文件的代码后,我发现果真如此:
var jspb = require('google-protobuf');
var goog = jspb;
var global = Function('return this')();
// ...
goog.exportSymbol('proto.keycenter.SecretData', null, global);
// ...
goog.object.extend(exports, proto.keycenter);
复制代码
代码经过 Function('return this')()
获取了全局对象。而后经过执行 goog.exportSymbol
方法,在全局对象上挂载 global.proto.keycenter.SecretData
属性值。最后再在 exports
上挂载 proto.keycenter
对象做为导出。
但若是仔细分析,仅仅上述代码,并不会致使这个错误。由于它会先修改 global 引用的指向,再修改 global 上对应的对象。例如引入模块后引用关系大体以下:
当运行环境中再次引入一个一样内容 _pb'.js
文件后,就会变成以下引用关系。
能够看到原先的 proto 对象并不会被修改,即外部以前导入的对象并不会变。那么到底是如何被“污染”的呢?
其实问题来自于 2.3 节中用到的 .deserializeBinary
这个方法。这是 _pb.js
在构造函数上暴露出来的静态方法,能够根据二进制数据生成对应的实例对象:
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
其实就是另外一个构造函数了。
真相大白。致使错误的过程以下:
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 到本地再配合文章内容来查看、运行。
☕️ 上面已经完成了问题的排查,下面的文章会进入到另外一个主题 —— 问题修复。自己觉得会较为顺畅的修复过程,也遇到一些意料以外的问题。
若是理解了错误缘由,就会发现这个错误出现的条件仍是比较苛刻的。须要同时知足如下几个必要条件才会复现:
_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)。
按上面的思路,咱们会但愿在 protoc 生成时就产出一份“安全”的 _pb.js
静态文件。
protoc 支持在 js_out 参数中设置 import_style
来控制模块类型。官方文档里提供了 commonjs
这个参数。
protoc --proto_path=src --js_out=import_style=commonjs,binary:build/gen src/foo.proto src/bar/baz.proto
复制代码
可是遗憾的是,这个参数并不会生成咱们预想的代码,它生成的代码就是咱们在上文中看到的“问题代码”。因此还有其余 import_style
么?
文档里没有,只能去源码里找答案了。
下面会涉及到 protoc,这里简单介绍了一下,便于不了解的朋友能快速理解。protobuf 这个仓库中包含了 Protocol Compiler。其中各个语言相关的代码生成器放在了
src/google/protobuf/compiler/
下面对应名称的文件夹里。例如 JavaScript 就是/js
文件夹内。
在源码中能够发现,其支持的 style 值并不是只有 commonjs 和 closure 两种:
// ...
else if (options[i].first == "import_style") {
if (options[i].second == "closure") {
import_style = kImportClosure;
} else if (options[i].second == "commonjs") {
import_style = kImportCommonJs;
} else if (options[i].second == "commonjs_strict") {
import_style = kImportCommonJsStrict;
} else if (options[i].second == "browser") {
import_style = kImportBrowser;
} else if (options[i].second == "es6") {
import_style = kImportEs6;
} else {
*error = "Unknown import style " + options[i].second + ", expected " +
"one of: closure, commonjs, browser, es6.";
}
}
// ...
复制代码
但大体浏览完源码后,我发现 browser 和 es6 两种 style 实际也不能知足咱们的需求。这时候就剩下 commonjs_strict
了。这个 strict 感受就会很是贴合咱们的目标。
主要的相关代码以下:
// Generate "require" statements.
if ((options.import_style == GeneratorOptions::kImportCommonJs ||
options.import_style == GeneratorOptions::kImportCommonJsStrict)) {
printer->Print("var jspb = require('google-protobuf');\n");
printer->Print("var goog = jspb;\n");
// Do not use global scope in strict mode
if (options.import_style == GeneratorOptions::kImportCommonJsStrict) {
printer->Print("var proto = {};\n\n");
} else {
printer->Print("var global = Function('return this')();\n\n");
}
// ...
}
复制代码
这里就能够看出 commonjs_strict
和 commonjs
最大的区别就是是否使用了全局变量。若是是 commonjs_strict
则会使用 var proto = {};
来代替全局变量。彻底知足需求!
可是,实际使用后,我发现了另外一个问题。
commonjs_strict
import_style=commonjs_strict
另外一个最大的区别在于导出代码的生成:
// if provided is empty, do not export anything
if (options.import_style == GeneratorOptions::kImportCommonJs &&
!provided.empty()) {
printer->Print("goog.object.extend(exports, $package$);\n", "package",
GetNamespace(options, file));
} else if (options.import_style == GeneratorOptions::kImportCommonJsStrict) {
printer->Print("goog.object.extend(exports, proto);\n", "package",
GetNamespace(options, file));
}
复制代码
这样看可能不太直观,直接贴两种 style 生成的代码就很明白了。
下面是用 commonjs_strict
生成的:
goog.object.extend(exports, proto);
复制代码
下面是用 commonjs
生成的:
goog.object.extend(exports, proto.keycenter);
复制代码
这样就能明显看出区别了。commonjs
形式导出时会导出 package 下的对象。所以,在咱们使用对应的 _pb.js
文件时,会须要调整一下导入的代码。此外,grpc-tools 生成的 _grpc_pd.js 静态代码由于也会导入 _pb.js
文件,所以也须要适配这种导出。
这里简单介绍下 grpc-tools 的角色。它作了两件事,一个是 wrap 了一些 protoc 命令行,这样用户能够直接使用 grpc-tools 而不去关心 protoc;另外一个是实现了一个 protoc 的 grpc 插件。关于 protoc 插件机制与如何实现一个 protoc 插件,后续有机会能够单写篇文章介绍。
而当我满心欢喜地去翻阅 grpc-tools 源码时发现,
grpc::string file_path =
GetRelativePath(file->name(), GetJSMessageFilename(file->name()));
out->Print("var $module_alias$ = require('$file_path$');\n", "module_alias",
ModuleAlias(file->name()), "file_path", file_path);
复制代码
它并不会考虑 import_style=commonjs_strict
这种状况,而是固定生成对应 commonjs
的导入代码。也有 issue 提到了这个问题。
好吧,这个导入/导出的问题目前没有特别好的解决办法。
咱们这边以前由于一些特殊需求,因此 folk 了 grpc-tools 的代码,修改了内部实现以适配咱们的 RPC 框架。所以这块就本身上手,支持了 import_style=commonjs_strict
这种状况,修改了导入时的代码:
grpc::string pb_package = file->package();
if (params.commonjs_strict && !pb_package.empty()) {
out->Print("var $module_alias$ = require('$file_path$').$pb_package$;\n", "module_alias",
ModuleAlias(file->name()), "file_path", file_path, "pb_package", pb_package);
} else {
out->Print("var $module_alias$ = require('$file_path$');\n", "module_alias",
ModuleAlias(file->name()), "file_path", file_path);
}
复制代码
固然还须要配合作一些其余改动,例如 CLI 入参的判断处理等,这里就不贴了。
固然,使人头疼的问题不止这一个,若是你使用了其余 protoc 插件自动生成 .d.ts 文件的话,这块也会须要适配 import_style=commonjs_strict
的状况。
本文主要记录了一次 gRPC 相关报错的排查过程。包括找出缘由、提出解决思路到最后修复的整个过程。
排查问题是每一个工程师常常会面对的事儿,也经常充满挑战。每每这些问题的落脚处可能并不大,修复工做也只是简单几行代码。而排障的过程,伴随着各种知识或技术点的使用,从表象到真相,整个过程也是工程师独有的乐趣。
而在文章写做上,相比介绍一个技术点,要写好一篇排障文章每每更不容易,因此也想挑战一下本身。
文章内容有一个配套的 demo 代码,能够用来配合理解文章中的问题。