Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

记一次 Node gRPC 静态生成文件引发的问题 #47

Open
alienzhou opened this issue Apr 27, 2021 · 0 comments
Open

记一次 Node gRPC 静态生成文件引发的问题 #47

alienzhou opened this issue Apr 27, 2021 · 0 comments

Comments

@alienzhou
Copy link
Owner

alienzhou commented Apr 27, 2021

image

本文记录了使用 Node gRPC(static codegen 方式)时,遇到的一个“奇怪”的坑。虽然问题本身并不常见,但顺着问题排查发现其中涉及到了一些有意思的点。去沿着问题追根究底、增长经验是一种不错的学习方式。所以我把这次排查的过程以及涉及到的点记录了下来。

为了让大家在阅读时有更好的体验,我准备了一个 demo 来还原该问题,感兴趣的朋友可以 clone 下来,配合文章一起“食用”。

1、场景还原

如果在你了解过或在 NodeJS 中使用过 gRPC,那么一定会知道它有两种使用模式 ——「动态代码生成」(dynamic codegen)和「静态代码生成」(static codegen)。

这里简单解释下(对 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 官方库中提供的示例

我们的项目使用了公司内部的解密组件包(也是我们维护的),叫 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。这里放一个组件模块依赖关系,说明一下项目使用的各组件包之间的关系。

image

其中每个黄色组件就是一单独的 npm 包。业务代码直接使用了 keycenter 包进行了秘钥的解密;同时引入了 redis 缓存组件,而缓存模块间接依赖了 keycenter。最终 keycenter 组件通过「静态代码生成」的方式使用 gRPC。

下面我们就来一起看看这个问题。

2、问题排查

❗️ 以下的章节顺序并非是排查时的实际顺序。大家实际排查问题时,还是建议先看“最近的现场”。 👀 例如这个问题,就会首先去 Request message serialization failure 抛错的地方查看情况。同时再辅以上层(外层)逻辑的排查,两头夹逼找到真相。但为了让文章阅读起来更顺畅,能够有从问题表象一步步走近真相,所以选择了目前的文章结构。我会尝试去尽量保留实际的排查路径。

2.1、莫非是 redis 组件内部逻辑出错了?

最直接的想法就是:新引入的这个 redis 组件有问题。因为出现问题的第一时间,我就把项目里下面这行代码注释掉了:

- this.redis = new Redis(redisConfig);
+ // this.redis = new Redis(redisConfig);

注释完果然就好了。所以引入新组件确实导致了问题。

由于报错和 gRPC 有关,而 redis 内部也间接依赖到了 gRPC(因为间接依赖了 keycenter),那么我的第一反应就是,这个组件内部逻辑可能有问题。也许是哪步操作使用到了 keycenter 方法,然后报出了错误。

但这个想法出现的有多快,排除的就有多快。

通过添加断点、日志的方式,很快就得出了一个结论:redis 组件虽然依赖到了 keycenter,但是整个实例化过程中完全不会调用它的方法,既然没有调用,这个 gRPC 的错误自然不是它直接导致的。

但它和 redis 组件或多或少脱不了关系。

2.2、是否真的是 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 模块。导入模块就会导致问题,只要不导入就没事儿,我第一时间的直觉有两个:

  • 副作用
  • 依赖关系

到这里,我们先回到最初的问题。

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 代码:

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 后,在控制台看起来它的值也很正常。

image

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 但实际不是的家伙冒充了它。

听起来似乎很奇怪。只能揣着性子继续排查。

2.4、“奇怪”的依赖安装?

首先回顾一下上面列出的包/模块依赖关系:

image

我瞟了下目前实际的包安装情况。大致如下(省略了一些无关的包信息):

.
├── 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 后,项目又可以正常运行了。看来“李鬼”就是这儿了。

2.5、莫非引用了错误的模块文件?

结合上面的情况,对于 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.jsserialize_keycenter_SecretData 方法引入的 keycenter_pb.js 不同么?

基于我对 Nodejs require 机制的了解,基本排除了这个可能。它们是通过相对路径引入,根据模块寻路的规则,都会命中各自包内的代码模块。不存在引到其他包内的代码文件的情况。

2.6、模块是如何被“污染”的?

如果引用的模块没有问题,那么会不会是模块内的变量被“污染”了?

这就和我最开始的直觉 —— “副作用”,有些关联了。副作用的产生场景很多,但是有一个场景非常典型,就是全局变量的使用。在查看 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 上对应的对象。例如引入模块后引用关系大致如下:

image

当运行环境中再次引入一个同样内容 _pb'.js 文件后,就会变成如下引用关系。

image

可以看到原先的 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 其实就是另一个构造函数了。

真相大白。导致错误的过程如下:

  1. 首先 keycenter_grpc_pb.js 引入了同目录下 keycenter_pb.js 文件,模块中的 keycenter.SecretData 构造函数这时候就确定了
  2. 因为一些其他原因,某个包引用了另一个地方的、内容相同的 pb 文件,为了区分我们叫它 keycenter_pb-2.js。它和 keycenter_pb.js 内容一摸一样,不过是两个文件。这时候 global 上指向的对象就被修改了
  3. 然后导入 keycenter_pb.js 模块,再使用 SecretData.deserializeBinary 生成实例,传入 keycenter_grpc_pb.js 中的方法就会出错了

✨ 为了大家更好理解,我复刻了这个问题的核心逻辑,做成了 demo,大家可以 clone 到本地再配合文章内容来查看、运行。


☕️ 上面已经完成了问题的排查,下面的文章会进入到另一个主题 —— 问题修复。本身以为会较为顺畅的修复过程,也遇到一些意料之外的问题。


3、解决思路

如果理解了错误原因,就会发现这个错误出现的条件还是比较苛刻的。需要同时满足以下几个必要条件才会复现:

  1. 进行了挂载全局变量的操作
  2. 项目同时 import 两个内容相同的 _pb.js 文件
  3. 使用了 .deserializeBinary 方法来创建实例对象
  4. 模块的 import 顺序需要先导入 _grpc_pb.js,再导入 _pb'.js(同内容的另一个 pb 文件)

针对 2~4 这三个条件,我们只要破坏其一,就可以避免问题发生。我在 demo 项目中分别写了对应的代码(correct-2.ts、correct-3.ts、correct-4.ts),感兴趣的话可以试下。

如果作为包提供方,要解决这个问题虽然看似方式很多,但是现实上我们能控制的有限 ——

  • 先是第 2 条,会需要保证只安装一个 keycenter 包。不同包、模块对于包的版本依赖是外部控制的,不受包自身控制,因此很难确保根除;
  • 然后是第 3 条,使用 .deserializeBinary 是功能要求,如果要规避这个方法的坑会使代码变得较为 tricky;
  • 最后是第 4 条,引用顺序显然也是外部控制的,不受包自身所控

所以我们尽量还是希望能找一个“正规”的路子,使得通过 grpc-tools 或者 protoc 生成的 _pb.js 文件,不会产生全局污染(也就是破除条件 1)。

4、修复之路

4.1、让 protoc 生成的代码避免全局污染

按上面的思路,我们会希望在 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_strictcommonjs 最大的区别就是是否使用了全局变量。如果是 commonjs_strict 则会使用 var proto = {}; 来代替全局变量。完全满足需求!

但是,实际使用后,我发现了另一个问题。

4.2、grpc-tools 并不适配 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 提到了这个问题。

4.3、只能自己动手了

好吧,这个导入/导出的问题目前没有特别好的解决办法。

我们这边之前因为一些特殊需求,所以 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 的情况。

5、最后

本文主要记录了一次 gRPC 相关报错的排查过程。包括找出原因、提出解决思路到最后修复的整个过程。

排查问题是每个工程师经常会面对的事儿,也常常充满挑战。往往这些问题的落脚处可能并不大,修复工作也只是简单几行代码。而排障的过程,伴随着各类知识或技术点的使用,从表象到真相,整个过程也是工程师独有的乐趣。

而在文章写作上,相比介绍一个技术点,要写好一篇排障文章往往更不容易,所以也想挑战一下自己。

文章内容有一个配套的 demo 代码,可以用来配合理解文章中的问题。

@alienzhou alienzhou changed the title 【排障系列】记一次 Node gRPC 静态生成文件引发的问题 记一次 Node gRPC 静态生成文件引发的问题 Apr 27, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant