记一个生产工具过于智能导致的坑
背景
近期在做一个 proto 文件处理的 CLI 工具,之前使用 proto 文件,一般分为两种方式:
- 直接引用 proto 文件,采用运行时动态生成 JS 代码
- 通过 protoc 工具生成对应的 JS 文件,并在项目中引用
后者性能会更高一些,因为编译过程在程序运行之前,所以一般会采用后者来使用。
问题现象
因为是一个通用的工具,所以 proto 文件也会是动态的,在本地环境简单的模拟了一下可能出现的场景,然后终端执行 protoc 命令:
# grpc_tools_node_protoc 为 protoc Node.js 版本的封装
grpc_tools_node_protoc --js_out=import_style=commonjs,binary:./src/main/proto --grpc_out=grpc_js:./src/main/proto ./protos/**/*.proto
发现一切运行正常,遂将对应代码写入脚本中,替换部分路径为变量,提交代码,发包,本地安装,验证。
结果就出现了这样的问题:
Could not make proto path relative: ./protos/**/*.proto: No such file or directory
/usr/local/lib/node_modules/@infra-node/grpc-tools/bin/protoc.js:43
throw error;
^
Error: Command failed: /usr/local/lib/node_modules/@infra-node/grpc-tools/bin/protoc --plugin=protoc-gen-grpc=/usr/local/lib/node_modules/@infra-node/grpc-tools/bin/grpc_node_plugin --js_out=import_style=commonjs,binary:./src/main/proto --grpc_out=grpc_js:./src/main/proto ./protos/**/*.proto
Could not make proto path relative: ./protos/**/*.proto: No such file or directory
at ChildProcess.exithandler (child_process.js:303:12)
at ChildProcess.emit (events.js:315:20)
at maybeClose (internal/child_process.js:1021:16)
at Socket. (internal/child_process.js:443:11)
at Socket.emit (events.js:315:20)
at Pipe. (net.js:674:12) {
killed: false,
code: 1,
signal: null,
cmd: '/usr/local/lib/node_modules/@infra-node/grpc-tools/bin/protoc --plugin=protoc-gen-grpc=/usr/local/lib/node_modules/@infra-node/grpc-tools/bin/grpc_node_plugin --js_out=import_style=commonjs,binary:./src/main/proto --grpc_out=grpc_js:./src/main/proto ./protos/**/*.proto'
}
令人震惊,并且更令人匪夷所思的是,当我将 cmd
中的内容复制到终端中再次运行时,发现一切都是正常的。
震惊之余,还是重新检查自己的代码实现。
问题排查
首先是怀疑是不是执行命令所采用的方式不对,当前所使用的是 exec
,因为 grpc_tools_node_protoc
也是一个封装的 Node.js 模块,所以顺带着看了它的源码,发现源码采用的是 execFile
,然后去翻看 Node.js 的文档,查看两者是否会有区别,因为前边报错信息是 No such file or directory
,首先怀疑是不是因为 CLI 是全局安装而导致路径不对,所以针对性的看了一下两个 API 对于 current working directory 的定义,果不其然发现了一丢丢区别:
exec
的 cwd
参数描述为 Current working directory of the child process. Default: process.cwd().
,而 execFile
的 cwd
参数描述为 Current working directory of the child process.
。
看起来后者并没有默认值,那么是不是因为工作目录不对而导致的呢,所以我们在代码中添加了 cwd
参数,重新进行验证流程。
结果,并没有什么区别,依然是报错。
所以翻看了一下 Node.js 关于 exec
与 execFile
API 实现上的区别,来确认是否为 cwd
的原因,结果发现 exec
内部调用的就是 execFile
,那么基本可以确认两者在 cwd
参数的默认值处理上并不会有什么区别,同时在源码中添加了 DEBUG 信息输出查看 cwd
也确实是我们预期的当前进行运行所在的目录。
既然问题不在这里,那么我们就要从其他地方再进行分析,因为对自己的代码比较自信(也确实没有几行),所以又仔细的看了一下 grpc-tools
的实现,发现代码是这样的:
var protoc = path.resolve(__dirname, 'protoc' + exe_ext);
var plugin = path.resolve(__dirname, 'grpc_node_plugin' + exe_ext);
var args = ['--plugin=protoc-gen-grpc=' + plugin].concat(process.argv.slice(2));
var child_process = execFile(protoc, args, function(error, stdout, stderr) {
if (error) {
throw error;
}
});
其中上边程序报错所输出的 cmd
参数其实也就是这里的 args
参数的结果了。
出于好奇,我们在源码处添加了一个 DEBUG 日志,结果发现了一个神奇的情况。
当我们通过 Node.js exec
运行的时候,输出是这样的:
[
'/usr/local/bin/node',
'/usr/local/bin/grpc_tools_node_protoc',
'--js_out=import_style=commonjs,binary:./src/main/proto',
'--grpc_out=grpc_js:./src/main/proto',
'./protos/**/*.proto'
]
而我们通过终端直接执行命令,输出结果是这样的:
[
'--plugin=protoc-gen-grpc=/usr/local/lib/node_modules/@infra-node/grpc-tools/bin/grpc_node_plugin',
'--js_out=import_style=commonjs,binary:./src/main/proto',
'--grpc_out=grpc_js:/./src/main/proto',
'./protos/examples/example-base-protos/kuaishou/base/base_message.proto'
]
两者的最后一个参数竟然是不一样的。
所以尝试着将 proto 的详细文件路径放到命令中,再次通过 exec
的方式运行,发现果然一切正常,所以问题就出在了最后 proto
文件路径上,合着 protoc
并不支持 **
这种通配符的文件输入。
那么新的问题就来了,为什么两种不同的运行方式会导致传入的参数发生变化呢。
因为 Node.js 模块的可执行文件都是通过 package bin 来注册的,有理由怀疑是不是 NPM 做了一些小动作,所以写了一个 shell 文件,很简单的一句输出:
echo $* # 输出所有的参数
用反向排除法,如果我们通过 sh test.sh **/*.json
能够得到 **/*.json
的输出,那么基本可以确定是 NPM 搞的鬼。
结果输出结果为:
package-lock.json package.json proto.json
通过终端来进行输出就已经能够拿到一个完整的文件路径了,说明至少不是 NPM 的一些操作。
突然间想到一种可能,键入 bash
然后再运行同样的命令 sh test.sh **/*.json
,果然我们得到了 **/*.json
。
想到自己的终端使用的是 zsh
,所以翻看对应的文档,果然找到了对应的说明:https://zsh.sourceforge.io/Doc/Release/Expansion.html ,[自行翻到 14.8.6 Recursive Globbing]
当我刚意识到问题所在的时候,内心飘过一行 oh my f**king zsh
。
zsh
会将路径进行递归匹配,然后将其展开在执行参数中,所以最终原因也定位了,是因为 zsh
的一个便民功能导致我误以为是 protoc
的一个功能,最终在一个非 zsh
环境暴露问题。
总结
本次遇到的问题现象很诡异,但是原因却令人很无奈,好在排查的过程中还是比较有收获的,被迫读了一些模块的源码,更深入的了解了 proto 文件的整个编译过程。
在习惯了使用 zsh 之后,一些它所提供的能力让我会误以为是程序所提供的,整个问题排查过程中也没有往那方面去考虑,也不知这样“好用”的工具会不会在其他场景再给我一些惊喜。