Go 执行系统命令
os/exec - 用于运行外部命令, 包装了os.StartProcess,使重映射stdin/stdout、连接I/O管道和其他调整变得更加容易, 与C和其他语言中的"system"库调用不同,os/exec包有意识地不调用系统 shell, 也不展开任何glob模式或处理通常由shell完成的其他展开、管道或重定向,
| 函数/方法 | 说明 |
|---|---|
| exec.Command | 创建一个Cmd结构体,用于执行指定的程序和参数,如果name不包含路径分隔符,会使用LookPath解析完整路径 |
| exec.CommandContext | 与Command类似,但包含context.Context,可用于在命令完成前通过context取消进程 |
| exec.LookPath | 在PATH环境变量列出的目录中搜索可执行文件,如果文件包含斜杠,会直接尝试,不会查询PATH |
| Cmd.Run | 启动指定命令并等待其完成,如果命令运行正常,没有I/O问题,且以零退出状态退出,则返回nil,否则返回*ExitError或其他错误 |
| Cmd.Start | 启动指定命令但不等待其完成,成功调用后,必须调用Cmd.Wait来释放相关系统资源 |
| Cmd.Wait | 等待命令退出,并等待从stdin复制到stdout/stderr的操作完成,命令必须已通过Cmd.Start启动 |
| Cmd.Output | 运行命令并返回其标准输出,任何返回的错误通常为*ExitError类型,如果c.Stderr为nil,Output会填充ExitError.Stderr |
| Cmd.CombinedOutput | 运行命令并返回组合的标准输出和标准错误 |
| Cmd.StdinPipe | 返回一个管道,该管道将在命令启动时连接到命令的标准输入,调用者需要在适当的时候关闭这个管道 |
| Cmd.StdoutPipe | 返回一个管道,该管道将在命令启动时连接到命令的标准输出,Cmd.Wait会在命令退出后关闭管道 |
| Cmd.StderrPipe | 返回一个管道,该管道将在命令启动时连接到命令的标准错误,Cmd.Wait会在命令退出后关闭管道 |
| Cmd.Environ | 返回命令当前配置下将运行的环境的副本 |
| Cmd.String | 返回命令的人类可读描述,仅用于调试,不适合用作shell输入 |
代码实例
go
package main
import (
"context"
"fmt"
"log"
"os/exec"
"time"
)
func main() {
// 1. 简单执行命令并获取输出
// 使用Command创建命令
cmd := exec.Command("echo", "Hello, World!")
// 使用Output执行命令并获取输出
output, err := cmd.Output()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Output: %s\n", output)
// 2. 执行命令并获取组合输出(stdout+stderr)
cmd = exec.Command("ls", "-la", "/nonexistent")
combinedOutput, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Command failed with error: %v\n", err)
}
fmt.Printf("Combined output:\n%s\n", string(combinedOutput))
// 3. 使用LookPath查找可执行文件路径
path, err := exec.LookPath("go")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Go executable found at: %s\n", path)
// 4. 使用管道处理标准输入输出
cmd = exec.Command("tr", "[:lower:]", "[:upper:]")
// 创建stdin管道
stdin, err := cmd.StdinPipe()
if err != nil {
log.Fatal(err)
}
// 创建stdout管道
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Fatal(err)
}
// 启动命令
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
// 写入数据到stdin
_, err = stdin.Write([]byte("hello world"))
if err != nil {
log.Fatal(err)
}
// 关闭stdin,表示完成写入
stdin.Close()
// 读取stdout
result := make([]byte, 100)
n, err := stdout.Read(result)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Uppercase result: %s\n", string(result[:n]))
// 等待命令完成
if err := cmd.Wait(); err != nil {
log.Fatal(err)
}
// 5. 使用context设置超时
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cmd = exec.CommandContext(ctx, "sleep", "1")
err = cmd.Run()
if err != nil {
fmt.Printf("Command with timeout failed: %v\n", err)
}
// 6. 获取命令环境变量
cmd = exec.Command("go", "version")
env := cmd.Environ()
fmt.Println("Command environment variables (first 5):")
for i, e := range env {
if i >= 5 {
break
}
fmt.Println(e)
}
}重要注意事项
- 安全考虑: 从Go 1.19开始,如果路径查找由于PATH中包含当前目录(".")而解析到当前目录中的可执行文件,LookPath和Command将返回一个满足
errors.Is(err, exec.ErrDot)的错误,而不是返回相对路径,这是为了避免安全隐患 - 不使用Shell: os/exec包不会调用系统shell,因此不会处理shell特有的功能如管道(|)、重定向(>、<)或环境变量扩展,如需这些功能,需要直接调用shell,或者分别实现这些功能
- 资源管理: 使用
Cmd.Start()后,必须调用Cmd.Wait()来释放相关系统资源 - 错误处理: 命令执行失败时,错误通常为
*ExitError类型,该类型包含进程的退出状态和可能的标准错误输出 - 管道使用: 同时使用
Cmd.Run()和StdoutPipe()/StderrPipe()是不正确的,因为Run()内部会调用Wait(),而Wait()会在所有读取完成前关闭管道 - 取消操作: 使用
CommandContext创建的命令,在context取消时,会调用预设的Cancel函数(默认是Kill进程),可以通过设置cmd.Cancel来自定义取消行为 - WaitDelay: Cmd结构体的WaitDelay字段可以限制等待的时间,防止子进程不退出或I/O管道不关闭导致的阻塞
如何实现一个 "系统命令"
所谓的"系统命令", 就是可以在命令行中运行的程序, 如: ls git lazygit
在命令行中运行的程序分为两种:
纯指令(解析参数)如: git commit -m "init repo"带有UI界面和快捷键,称之为 TUI 如: lazygit vim/neovim
TUI 库
推荐阅读TUI框架排行榜 不限Go语言也包括其他语言, 如Rust
- bubbletea 这个在终端中处理 Github ISSUE 和 PR 的工具就是用它编写的
- gocui 大名鼎鼎的 lazygit 就是用这个库开发的, 而且这个库比较简单, 代码不多, 值得阅读源码
指令解析库
主要是学习 flag 标准库文档, 这个基础的学会了, 才有可能学会另外两个封装的库
- flag Go标准库命令行解析参数方法
- urfave/cli 声明式命令行程序库
- spf13/cobra Go 版本的 Commander(如果你用过 node.js版本的 commander 的话)
flag 学习
| 函数 | 说明 |
|---|---|
| flag.Args | 返回所有非标志(non-flag)的命令行参数,即那些未被解析为标志的参数 |
| flag.Arg(i) | 返回第 i 个非标志命令行参数,索引从 0 开始,如果不存在则返回空字符串 |
| flag.NArg() | 返回非标志命令行参数的数量 |
| flag.Parse() | 解析命令行标志,必须在所有标志定义后和程序访问标志前调用 |
| flag.Bool(name, value, usage) | 定义一个 bool 类型的标志,返回指向该标志值的指针 |
| flag.Int(name, value, usage) | 定义一个 int 类型的标志,返回指向该标志值的指针 |
| flag.String(name, value, usage) | 定义一个 string 类型的标志,返回指向该标志值的指针 |
| flag.BoolVar(p, name, value, usage) | 将 bool 标志绑定到变量 p,不返回值 |
| flag.IntVar(p, name, value, usage) | 将 int 标志绑定到变量 p,不返回值 |
| flag.StringVar(p, name, value, usage) | 将 string 标志绑定到变量 p,不返回值 |
| flag.PrintDefaults() | 打印所有定义的命令行标志的默认值和使用信息 |
| flag.Lookup(name) | 返回指定名称的 Flag 结构,如果不存在则返回 nil |
| flag.Set(name, value) | 设置指定名称标志的值 |
| flag.Visit(fn) | 按字典序遍历所有已设置的命令行标志 |
| flag.VisitAll(fn) | 按字典序遍历所有命令行标志,包括未设置的 |
快速开始
go
package main
import (
"flag"
"fmt"
)
// 编译: go build -o main && chmod +x ./main
// 运行:
// ./main --name "Golang" --age 21 -verbose
func main() {
// 第1种方式: 使用变量接收 flag.<dataType> 函数返回的值
// flag.StringVar("选项名", "选项默认值", "选项名的描述")
name := flag.String("name", "go", "姓名")
age := flag.Int("age", 20, "年龄")
// 第2种方式: 直接传入一个指针, 在 parse 的时候, 会自动通过指针字节修改值
// flag.StringVar(&变量名, "选项名", "选项默认值", "选项名的描述")
var verbose bool
flag.BoolVar(&verbose, "verbose", false, "详细模式")
// 解析命令行参数
flag.Parse()
// 使用标志(是一个指针变量)
fmt.Printf("type: name %T, age %T \n", name, age) // name *string, age: *int
fmt.Printf("addr: name %v, age %v \n", name, age) // name 0xc00002a060, age 0xc0000120c0
fmt.Printf("data: name %s, age %d, verbose %v \n", *name, *age, verbose)
// 由于 verbose 是传入指针, 它是通过指针修改的值, 所以可以直接使用普通变量 verbose
// data: name tom, age 21, verbose true
}标志语法
-name或--name: 这两种语法是等价的-age=x或-age x:= 和 空格这两种语法是等价的--flag: 仅适用于 bool 类型的标志
标志解析会在第一个非标志参数前或在终止符 -- 后停止
自定义 FlagSet
可以使用 flag.NewFlagSet 创建独立的标志集, 适用于实现命令行接口中的子命令
什么是子命令
比如: go test -v ./tests, 这个 test 就是子命令, 它是通过主命令 go 来执行的, 但它有自己独立的选项
go
package main
import (
"flag"
"fmt"
"os"
)
// 编译: go build -o main && chmod +x ./main
// 运行:
// ./main test --type=fuzz
// ./main test --type=fuzz --debug
// ./main test --type=fuzz --debug ./tests
// ./main test --type=fuzz ./tests --debug
func main() {
// 自定义子命令 test
testCmd := flag.NewFlagSet("test", flag.ExitOnError)
testType := testCmd.String("type", "unit", "测试类型")
debugMode := testCmd.Bool("debug", false, "调试模式")
// 解析
testCmd.Parse(os.Args[2:]) // 跳过程序名(main)和子命令(test)
fmt.Println("testType:", *testType)
fmt.Println("debugMode:", *debugMode)
// 返回非标志命令行参数的数量(不是 - 开头的参数数量 ./tests)
narg := testCmd.NArg()
if narg > 0 {
// 取第0个就行,因为 - 开头的匹配不到
fmt.Println("testFiles:", testCmd.Arg(0))
}
}