子进程无法使用命令行通配符
问题
最近在写 ci 工具的时候发现,子进程无法使用命令行通配符,总是提示文件不存在。样例代码及执行结果如下:
package main
import (
"os"
"os/exec"
)
func main() {
cmd := exec.Command("ls", "*.gz")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
os.Exit(-1)
}
}
$ touch example.gz # 当前目录下建个测试的文件
$ go run main.go
ls: cannot access '*.gz': No such file or directory
一开始以为是子进程环境变量有问题,比如 PWD 之类的,导致当前目录下确实没有 *.gz
文件。但是把代码改成 exec.Command("ls", "example.gz")
再执行一遍又没报错。这说明是子进程没有正确解析通配符导致的。
原因
简单的搜索之后发现, ls
命令本身是没有处理通配符匹配的。我们日常在终端里输入 ls *.gz
之所以没有报错,那是因为 shell
在调用 ls
命令之前,把 *.gz
这个通配符进行了解析匹配,把匹配到的文件名作为调用 ls
的参数。
有许多 Linux 命令是“支持”通配符匹配的,其所谓的支持估计大部分是 shell
实现的,而非命令本身。
看了 Stackoverflow 上的这个回答,我们通过 strace 命令来进一步验证上面的原因。
为了方便对比,我们在原先的 go 程序目录下,运行 strace ls *.gz
,看看发生了哪些系统调用。
$ strace ls *.gz
execve("/usr/bin/ls", ["ls", "example.gz"], 0x7ffe24bdeba8 /* 27 vars */) = 0
brk(NULL) = 0x55f420767000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffe8d914a00) = -1 EINVAL (Invalid argument)
# 省略......
close(1) = 0
close(2) = 0
exit_group(0) = ?
+++ exited with 0 +++
不加任何参数的 strace 会输出超多内容,而且无法通过 redirect stdio 把输出保存到文件里。没有深究为什么会这样,可能 strace 是直接通过写 tty 来实现输出的,可以手动复制到一个文件里,方便对内容进行搜索。
不过这里就可以略过这一步了,因为我们从第一行就能看出,系统调用 execve
传给 ls 的参数是被展开后的 example.gz
而不是我们输入的 *.gz
。显然,strace 没有记录 shell 把 *.gz
解析匹配成 example.gz
的过程。
我们再试试多增加几个 gz 文件,或者一个 gz 文件都不存在的情况。
# 多创建几个 gz 文件
$ touch 1.gz 2.gz 3.gz
$ ls *.gz
1.gz 2.gz 3.gz example.gz
# 这次加个 -e trace=execve 只跟着 execve 这个系统调用
$ strace -e trace=execve ls *.gz
execve("/usr/bin/ls", ["ls", "1.gz", "2.gz", "3.gz", "example.gz"], 0x7ffcbd5ceb30 /* 27 vars */) = 0
1.gz 2.gz 3.gz example.gz
+++ exited with 0 +++
# 这次把所有 gz 文件都删除,再看看
$ rm ./*.gz
$ strace -e trace=execve ls *.gz
execve("/usr/bin/ls", ["ls", "*.gz"], 0x7ffcc0ef9728 /* 27 vars */) = 0
ls: cannot access '*.gz': No such file or directory
+++ exited with 2 +++
从上面的实验可以看出:
- shell 在能解析匹配出统配符的时候,会把解析结果作为命令的参数
- 解析不出来的时候,会把整个通配符作为参数传给命令
我们再来看看前面有问题的那个 go 程序:
$ touch 1.gz 2.gz 3.gz
$ ls *.gz
1.gz 2.gz 3.gz
# 如果直接用 strace 去跟踪 go run main.go 会产生太多输出。因为这过程还有编译 main.go 等过程
# 所以我们先把 main.go 编译成二进制,再去跟踪,这样会清晰一点。
$ go mod init playground
$ go build # 得到 ./playground 的二进制文件
$ strace -e trace=execve ./playground
execve("./playground", ["./playground"], 0x7ffd1f515e40 /* 27 vars */) = 0
--- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=1584357, si_uid=1000} ---
ls: cannot access '*.gz': No such file or directory
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1584362, si_uid=1000, si_status=2, si_utime=0, si_stime=0} ---
+++ exited with 255 +++
# 可以看到,上面压根没有调用 ls 的 execve 调用
# 这是因为,ls 是在子进程里调用的。多加一个 -f 参数即可跟踪子进程
$ strace -f -e trace=execve ./playground
execve("./playground", ["./playground"], 0x7ffc5c51b238 /* 27 vars */) = 0
...
[pid 1588068] execve("/usr/bin/ls", ["ls", "*.gz"], 0xc000140000 /* 27 vars */) = 0
ls: cannot access '*.gz': No such file or directory
...
+++ exited with 255 +++
从上方的实验的输出内容就能验证前面所说的原因了!go 里面的 ls *.gz
通配符不会自动展开。
解决方案
搞清楚了原因,那么解决的方案也就呼之欲出了。应该有两种解决方案:
- 借助 shell 来解析通配符。
- 自己解析通配符,一般会借助一些库。
后一种方法这里就不展开说了,看起来“笨笨”的。除非是实现一些自定义命令,不然一般不会去自己解析通配符。
按前一种解决方案来的话,那么代码就要改成 cmd := exec.Command("/bin/sh", "-c", "ls *.gz")
,即把 ls *.gz
当作一段 shell 脚本让 /bin/sh
来执行。一些编程语言的子进程库里(如 python 的 subprocess),会提供一个 shell=True
的选项,做的事情是一样的。
修改完后,我们再来实验一下。
# 修改完代码,保存
$ go build
$ strace -f -e trace=execve ./playground
execve("./playground", ["./playground"], 0x7ffd95675978 /* 27 vars */) = 0
...
[pid 1780874] execve("/bin/sh", ["/bin/sh", "-c", "ls *.gz"], 0xc00011e000 /* 27 vars */) = 0
...
[pid 1780875] execve("/usr/bin/ls", ["ls", "1.gz", "2.gz", "3.gz"], 0x5654f5baa9a8 /* 27 vars */) = 0
1.gz 2.gz 3.gz
...
可以看出,多了一次 execve
调用。经过 /bin/sh
展开了通配符后,再转给 ls
命令。