..

子进程无法使用命令行通配符

问题

最近在写 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 通配符不会自动展开。

解决方案

搞清楚了原因,那么解决的方案也就呼之欲出了。应该有两种解决方案:

  1. 借助 shell 来解析通配符。
  2. 自己解析通配符,一般会借助一些库。

后一种方法这里就不展开说了,看起来“笨笨”的。除非是实现一些自定义命令,不然一般不会去自己解析通配符。

按前一种解决方案来的话,那么代码就要改成 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 命令。