【排障系列】記一次 Node gRPC 靜態生成文件引起的問題

本文記錄了使用 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

image.png

其中每一個黃色組件就是一單獨的 npm 包。業務代碼直接使用了 keycenter 包進行了祕鑰的解密;同時引入了 redis 緩存組件,而緩存模塊間接依賴了 keycenter。最終 keycenter 組件經過「靜態代碼生成」的方式使用 gRPC。redis

下面咱們就來一塊兒看看這個問題。typescript

二、問題排查

❗️ 如下的章節順序並不是是排查時的實際順序。你們實際排查問題時,仍是建議先看「最近的現場」。 👀 例如這個問題,就會首先去 Request message serialization failure 拋錯的地方查看狀況。同時再輔以上層(外層)邏輯的排查,兩頭夾逼找到真相。但爲了讓文章閱讀起來更順暢,可以有從問題表象一步步走近真相,因此選擇了目前的文章結構。我會嘗試去儘可能保留實際的排查路徑。npm

2.一、莫非是 redis 組件內部邏輯出錯了?

最直接的想法就是:新引入的這個 redis 組件有問題。由於出現問題的第一時間,我就把項目裏下面這行代碼註釋掉了:

- this.redis = new Redis(redisConfig);
+ // this.redis = new Redis(redisConfig);
複製代碼

註釋完果真就行了。因此引入新組件確實致使了問題。

因爲報錯和 gRPC 有關,而 redis 內部也間接依賴到了 gRPC(由於間接依賴了 keycenter),那麼個人第一反應就是,這個組件內部邏輯可能有問題。也許是哪步操做使用到了 keycenter 方法,而後報出了錯誤。

但這個想法出現的有多快,排除的就有多快。

經過添加斷點、日誌的方式,很快就得出了一個結論:redis 組件雖然依賴到了 keycenter,可是整個實例化過程當中徹底不會調用它的方法,既然沒有調用,這個 gRPC 的錯誤天然不是它直接致使的。

但它和 redis 組件或多或少脫不了關係。

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.三、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.png

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.四、「奇怪」的依賴安裝?

首先回顧一下上面列出的包/模塊依賴關係:

image.png

我瞟了下目前實際的包安裝狀況。大體以下(省略了一些無關的包信息):

.
├── 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.五、莫非引用了錯誤的模塊文件?

結合上面的狀況,對於 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.六、模塊是如何被「污染」的?

若是引用的模塊沒有問題,那麼會不會是模塊內的變量被「污染」了?

這就和我最開始的直覺 —— 「反作用」,有些關聯了。反作用的產生場景不少,可是有一個場景很是典型,就是全局變量的使用。在查看 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.png

當運行環境中再次引入一個一樣內容 _pb'.js 文件後,就會變成以下引用關係。

image.png

能夠看到原先的 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 到本地再配合文章內容來查看、運行。


☕️ 上面已經完成了問題的排查,下面的文章會進入到另外一個主題 —— 問題修復。自己覺得會較爲順暢的修復過程,也遇到一些意料以外的問題。


三、解決思路

若是理解了錯誤緣由,就會發現這個錯誤出現的條件仍是比較苛刻的。須要同時知足如下幾個必要條件纔會復現:

  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.一、讓 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.二、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.三、只能本身動手了

好吧,這個導入/導出的問題目前沒有特別好的解決辦法。

咱們這邊以前由於一些特殊需求,因此 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 代碼,能夠用來配合理解文章中的問題。

相關文章
相關標籤/搜索