本文記錄了使用 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 代碼,能夠用來配合理解文章中的問題。