Skip to content

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)
	}
}

重要注意事项

  1. 安全考虑: 从Go 1.19开始,如果路径查找由于PATH中包含当前目录(".")而解析到当前目录中的可执行文件,LookPath和Command将返回一个满足errors.Is(err, exec.ErrDot)的错误,而不是返回相对路径,这是为了避免安全隐患
  2. 不使用Shell: os/exec包不会调用系统shell,因此不会处理shell特有的功能如管道(|)、重定向(>、<)或环境变量扩展,如需这些功能,需要直接调用shell,或者分别实现这些功能
  3. 资源管理: 使用Cmd.Start()后,必须调用Cmd.Wait()来释放相关系统资源
  4. 错误处理: 命令执行失败时,错误通常为*ExitError类型,该类型包含进程的退出状态和可能的标准错误输出
  5. 管道使用: 同时使用Cmd.Run()StdoutPipe()/StderrPipe()是不正确的,因为Run()内部会调用Wait(),而Wait()会在所有读取完成前关闭管道
  6. 取消操作: 使用CommandContext创建的命令,在context取消时,会调用预设的Cancel函数(默认是Kill进程),可以通过设置cmd.Cancel来自定义取消行为
  7. WaitDelay: Cmd结构体的WaitDelay字段可以限制等待的时间,防止子进程不退出或I/O管道不关闭导致的阻塞

如何实现一个 "系统命令"

所谓的"系统命令", 就是可以在命令行中运行的程序, 如: ls git lazygit

在命令行中运行的程序分为两种:

  • 纯指令(解析参数)如: git commit -m "init repo"
  • 带有UI界面和快捷键,称之为 TUI 如: lazygit vim/neovim

TUI 库

推荐阅读TUI框架排行榜 不限Go语言也包括其他语言, 如Rust

指令解析库

主要是学习 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))
	}
}

Released under the MIT License.