记一个生产工具过于智能导致的坑

背景

近期在做一个 proto 文件处理的 CLI 工具,之前使用 proto 文件,一般分为两种方式:

  1. 直接引用 proto 文件,采用运行时动态生成 JS 代码
  2. 通过 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 的定义,果不其然发现了一丢丢区别:

execcwd 参数描述为 Current working directory of the child process. Default: process.cwd().,而 execFilecwd 参数描述为 Current working directory of the child process.

看起来后者并没有默认值,那么是不是因为工作目录不对而导致的呢,所以我们在代码中添加了 cwd 参数,重新进行验证流程。

结果,并没有什么区别,依然是报错。

所以翻看了一下 Node.js 关于 execexecFile 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 之后,一些它所提供的能力让我会误以为是程序所提供的,整个问题排查过程中也没有往那方面去考虑,也不知这样“好用”的工具会不会在其他场景再给我一些惊喜。