Go标准库os/exec使用指南

Go标准库os/exec使用指南

有时我们在写程序的时候会需要调用系统的某个命令来完成一些任务。Go语言os/exec标准库就提供这种调用外部命令的功能。

如下面的代码调用ls命令来查看指定目录下面的文件:

import (
"os"
"os/exec"
)

func ls(path string) error {
cmd := exec.Command("ls", path)
cmd.Stdout = os.Stdout
return cmd.Run()
}

func main() {
if err := ls("/"); err != nil {
panic(err)
}
}

再举一个例子,将小写转成大写:

import (
"bytes"
"fmt"
"log"
"os/exec"
"strings"
)

func main() {
cmd := exec.Command("tr", "a-z", "A-Z")
cmd.Stdin = strings.NewReader("some input")

var out bytes.Buffer
cmd.Stdout = &out

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
fmt.Printf("in all caps: %q\n", out.String())
}

概述

golang下的os/exec包执行外部命令,它将os.StartProcess进行包装使得它更容易映射到stdin/stdout、管道I/O。

与C语言或者其他语言中的“系统”库调用不同,os/exec包并不调用系统shell,也不展开任何glob模式,也不处理通常由shell完成的其他扩展、管道或重定向。这个包的行为更像C语言的“exec”函数家族。要展开glob模式,可以直接调用shell,注意避免任何危险的输入,或者使用path/filepath包的glob函数。如果要展开环境变量,请使用package os的ExpandEnv。

所谓的 glob 模式是指 shell 所使用的简化了的正则表达式。星号(*)匹配零个或多个任意字符;[abc]匹配任何一个列在方括号中的字符(这个例子要么匹配一个 a,要么匹配一个 b,要么匹配一个 c);问号(?)只匹配一个任意字符;如果在方括号中使用短划线分隔两个字符,表示所有在这两个字符范围内的都可以匹配(比如 [0-9] 表示匹配所有 0 到 9 的数字)。

注意,这个包中的示例假设是Unix系统。它们不能在Windows上运行,也不能在golang.org和godoc.org使用的Go Playground上运行。

相关函数定义如下:

Variables
func LookPath(file string) (string, error)
type Cmd
//方法返回一个*Cmd, 用于执行name指定的程序(携带arg参数)
func Command(name string, arg ...string) *Cmd
//
func CommandContext(ctx context.Context, name string, arg ...string) *Cmd
//执行Cmd中包含的命令,并返回标准输出与标准错误合并后的切片
func (c *Cmd) CombinedOutput() ([]byte, error)
//执行Cmd中包含的命令,并返回标准输出的切片
func (c *Cmd) Output() ([]byte, error)
//执行Cmd中包含的命令,阻塞直到命令执行完成
func (c *Cmd) Run() error
//执行Cmd中包含的命令,该方法立即返回,并不等待命令执行完成
func (c *Cmd) Start() error
//返回一个管道,该管道会在Cmd中的命令被启动后连接到其标准输入
func (c *Cmd) StderrPipe() (io.ReadCloser, error)
//返回一个管道,该管道会在Cmd中的命令被启动后连接到其标准输出
func (c *Cmd) StdinPipe() (io.WriteCloser, error)
//返回一个管道,该管道会在Cmd中的命令被启动后连接到其标准错误
func (c *Cmd) StdoutPipe() (io.ReadCloser, error)
//
func (c *Cmd) String() string
//该方法会阻塞直到Cmd中的命令执行完成,但该命令必须是被Start方法开始执行的
func (c *Cmd) Wait() error
type Error
func (e *Error) Error() string
func (e *Error) Unwrap() error
type ExitError
func (e *ExitError) Error() string

各个函数详解

func LookPath

函数定义:

func LookPath(file string) (string, error)

在环境变量PATH指定的目录中搜索可执行文件,如过file中有文件分隔符(斜杠),则只在当前目录搜索。即:默认在系统的环境变量里查找给定的可执行命令文件,如果查找到返回路径,否则报错𝑤𝑠是PATH$。可提供相对路径下进行查找,并返回相对路径。

示例:

import (
"fmt"
"os/exec"
)

func main() {
f, err := exec.LookPath("ls")
if err != nil {
fmt.Println(err)
}

fmt.Println(f) //输出 /bin/ls
}

struct Cmd

Cmd代表一个正在准备或者在执行中的外部命令。

type Cmd struct {
// Path是将要执行的命令的路径。
// 该字段不能为空,如为相对路径会相对于Dir字段。
Path string

// Args保管命令的参数,包括命令名作为第一个参数;如果为空切片或者nil,相当于无参数命令。
// 典型用法下,Path和Args都应被Command函数设定。
Args []string

// Env指定进程的环境,如为nil,则是在当前进程的环境下执行。
Env []string

// Dir指定命令的工作目录。如为空字符串,会在调用者的进程当前目录下执行。
Dir string

// Stdin指定进程的标准输入,如为nil,进程会从空设备读取(os.DevNull)
Stdin io.Reader
// Stdout和Stderr指定进程的标准输出和标准错误输出。
// 如果任一个为nil,Run方法会将对应的文件描述符关联到空设备(os.DevNull)
// 如果两个字段相同,同一时间最多有一个线程可以写入。
Stdout io.Writer
Stderr io.Writer

// ExtraFiles指定额外被新进程继承的已打开文件流,不包括标准输入、标准输出、标准错误输出。
// 如果本字段非nil,entry i会变成文件描述符3+i。
//
// BUG: 在OS X 10.6系统中,子进程可能会继承不期望的文件描述符。
// http://golang.org/issue/2603
ExtraFiles []*os.File

// SysProcAttr保管可选的、各操作系统特定的sys执行属性。
// Run方法会将它作为os.ProcAttr的Sys字段传递给os.StartProcess函数。
SysProcAttr *syscall.SysProcAttr

// Process是底层的,只执行一次的进程。
Process *os.Process

// ProcessState包含一个已经存在的进程的信息,只有在调用Wait或Run后才可用。
ProcessState *os.ProcessState

// 下面是隐藏或非导出字段
ctx context.Context // nil means none
lookPathErr error // LookPath error, if any.
finished bool // when Wait was called
childFiles []*os.File
closeAfterStart []io.Closer
closeAfterWait []io.Closer
goroutine []func() error
errch chan error // one send per goroutine
waitDone chan struct{}
}

注:exec在执行调用系统命令时,会先对需要执行的操作进行一次封装,然后在执行。封装后的命令对象具有以上struct属性。而封装方式即使用下边的Command函数。

func Command

函数定义:

func Command(name string, arg ...string) *Cmd

函数返回一个*Cmd,用于使用给出的参数执行name指定的程序。返回值只设定了Path和Args两个参数。

如果name不含路径分隔符(如果不是相对路径),将使用LookPath获取完整路径(就是用默认的全局变量路径);否则直接使用name。参数arg不应包含命令名。

示例:

func main() {
cmd := exec.Command("go", "version")
fmt.Println(cmd.Path, cmd.Args)
//输出: /usr/local/go/bin/go [go version]
}

注:在调用命令执行封装时,如果不提供相对路径,系统会使用LookPath获取完整路径;即这里可以给一个相对路径。

以上操作只会将命令进行封装,相当于告诉系统将进行哪些操作,但是执行时无法获取相关信息。

func Run

函数定义:

func (c *Cmd) Run() error

Run执行c包含的命令,并阻塞直到完成。

如果命令成功执行,stdin、stdout、stderr的转交没有问题,并且返回状态码为0,方法的返回值为nil(执行Run函数的返回状态,正确执行Run函数,并不代表正确执行了命令);如果函数没有执行或者执行失败,会返回*ExitError类型的错误;否则返回的error可能是表示I/O问题。

即:该命令只会执行且阻塞到执行结束,如果执行函数有错则返回报错信息,没错则返回nil,并不会返回执行结果。

func Start

函数定义:

func (c *Cmd) Start() error

Start开始执行c包含的命令,但并不会等待该命令完成即返回。

func Wait

函数定义:

func (c *Cmd) Wait() error

Wait会阻塞直到该命令执行完成,该命令必须是被Start方法开始执行的。

如果命令成功执行,stdin、stdout、stderr的转交没有问题,并且返回状态码为0,方法的返回值为nil;如果命令没有执行或者执行失败,会返回*ExitError类型的错误;否则返回的error,可能是表示I/O问题。Wait方法会在命令返回后释放相关的资源。

Start和Run的区别

示例:

func main() {
cmd := exec.Command("sleep", "5")
// 如果用Run, 执行到该步会阻塞等待5s
//err := cmd.Run()
err := cmd.Start()
if err != nil {
fmt.Println(err)
}
fmt.Printf("Waiting for command to finish...")
// Start, 上面的内容会先输出,然后这里会阻塞等待5s
err = cmd.Wait()
fmt.Printf("Command finished with error: %v", err)
}

注:一个命令只能使用Start()或者Run()中的一个启动命令,不能两个同时使用。

func CombinedOutput

函数定义:

func (c *Cmd) CombinedOutput() ([]byte, error)

执行Cmd中包含的命令,并返回标准输出与标准错误合并后的切片。

示例:

func main() {
cmd := exec.Command("ls", "-a", "-l")
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(err)
}
fmt.Println(string(out))
}

func Output

函数定义:

func (c *Cmd) Output() ([]byte, error)

执行Cmd中包含的命令,并返回标准输出的切片。

示例:

import (
"fmt"
"os/exec"
)

func main() {
cmd := exec.Command("ls", "-a", "-l")
out, err := cmd.Output()
if err != nil {
fmt.Println(err)
}
fmt.Println(string(out))
}

注:Output()和CombinedOutput()不能够同时使用,因为command的标准输出只能有一个,同时使用的话便会定义了两个,便会报错。

我们还可以通过指定一个对象连接到对应的管道进行传输参数(stdinpipe),获取输出(stdoutpipe),获取错误(stderrpipe)

func StdinPipe

函数定义:

func (c *Cmd) StdinPipe() (io.WriteCloser, error)

err 返回的是执行函数时的错误。StdinPipe方法返回一个在命令Start后与命令标准输入关联的管道。当命令退出时,Wait方法将关闭这个管道。必要时调用者可以调用Close方法来强行关闭管道,例如命令在输入关闭后才会执行返回时需要显式关闭管道。

示例:

func main() {
cmd := exec.Command("cat")
stdin, err := cmd.StdinPipe()

if err != nil {
fmt.Println(err)
}

_, err = stdin.Write([]byte("tmp.txt"))
if err != nil {
fmt.Println(err)
}

stdin.Close()
cmd.Stdout = os.Stdout
cmd.Start()
}

func StdoutPipe

函数定义:

func (c *Cmd) StdoutPipe() (io.ReadCloser, error)

StdoutPipe方法返回一个在命令Start后与命令标准输出关联的管道。当命令退出时,Wait方法将关闭这个管道。但是在从管道读取完全部数据之前调用Wait是错误的;同样使用StdoutPipe方法时调用Run函数也是错误的。

示例:

func main() {
//执行系统命令, 第一个参数是命令名称, 后面参数可以有多个,命令参数
cmd := exec.Command("ls", "-a", "-l")
//获取输出对象,可以从该对象中读取输出结果
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Println(err)
}
//保证关闭输出流
defer stdout.Close()

//运行命令
if err := cmd.Start(); err != nil {
fmt.Println(err)
}
//读取输出结果
content, err := ioutil.ReadAll(stdout)
if err != nil {
fmt.Println(err)
}
fmt.Println(string(content))
}

输出重定向到文件:

import (
"fmt"
"os"
"os/exec"
)

func main() {
cmd := exec.Command("ls", "-a", "-l")
//重定向标准输出到文件
stdout, err := os.OpenFile("stdout.log", os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Println(err)
}
defer stdout.Close()

cmd.Stdout = stdout
//执行命令
if err := cmd.Start(); err != nil {
fmt.Println(err)
}
}

func StderrPipe

函数定义:

func (c *Cmd) StderrPipe() (io.ReadCloser, error)

StderrPipe方法返回一个在命令Start后与命令标准错误输出关联的管道。当命令退出时,Wait方法将关闭这个管道,一般不需要显式的关闭该管道。但是在从管道读取完全部数据之前调用Wait是错误的;同样使用StderrPipe方法时调用Run函数也是错误的。

示例:

func main() {
cmd := exec.Command("mv", "hello")
i, err := cmd.StderrPipe()
if err != nil {
fmt.Printf("Error:%s\n", err)
}
b, _ := ioutil.ReadAll(i)
if err := cmd.Wait(); err != nil {
fmt.Printf("Error: %s\n", err)
}
fmt.Println(string(b))
}

应用示例

项目中需要执行shell命令,虽然exec包提供了CombinedOutput()方法,在shell运行结束会返回shell执行的输出,但是用户在发起一次任务时,可能在不停的刷新log,想达到同步查看log的目的,但是CombinedOutput()方法只能在命令完全执行结束才返回整个shell的输出,所以肯定达不到效果,所以,需要寻找其它方法达到命令一边执行log一边输出的目的。

使用重定向

如果你的shell比较简单,并且log的文件路径也很容易确定,那么直接对shell执行的命令添加重定向最简单不过了,程序参考如下:

import (
"fmt"
"os/exec"
)

func main() {
cmdStr := `
#!/bin/bash
for var in {1..10}
do
sleep 1
echo "Hello, Welcome ${var} times "
done`

cmd := exec.Command("bash", "-c", cmdStr+" >> file.log")
if err := cmd.Start(); err != nil {
fmt.Println(err)
}

if err := cmd.Wait(); err != nil {
fmt.Println(err)
}
}

上面程序定义了一个每秒1次的shell,但是在shell执行前,对shell进行了拼接,使用了重定向,所以我们可以在另外一个终端中实时的看到 log 的变化

指定shell执行时的输出

使用exec.Command创建一个Shell后,就具有了两个变量:Stdout io.Writer和Stderr io.Writer。

这两个变量是用来指定程序的标准输出和标准错误输出的位置,所以我们就利用这两个变量,直接打开文件,然后将打开的文件指针赋值给这两个变量即可将程序的输出直接输出到文件中,也能达到相同的效果,参考程序如下:

import (
"fmt"
"os"
"os/exec"
)

func main() {
cmdStr := `
#!/bin/bash
for var in {1..10}
do
sleep 1
echo "Hello, Welcome ${var} times "
done`

cmd := exec.Command("bash", "-c", cmdStr)
file, _ := os.OpenFile("file.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
defer file.Close()

//指定输出位置
cmd.Stderr = file
cmd.Stdout = file
if err := cmd.Start(); err != nil {
fmt.Println(err)
}
if err := cmd.Wait(); err != nil {
fmt.Println(err)
}
}

从shell执行结果的管道中获取输出

我们可以通过管道获取命令执行过程中的输出,参考程序如下:

import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)

//通过管道同步获取日志的函数
func syncLog(reader io.ReadCloser) {
file, _ := os.OpenFile("file.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
defer file.Close()

buf := make([]byte, 1024, 1024)
for {
strNum, err := reader.Read(buf)
if strNum > 0 {
outputByte := buf[:strNum]
file.WriteString(string(outputByte))
}
if err != nil {
//读到结尾
if err == io.EOF || strings.Contains(err.Error(), "file already closed") {
err = nil
}
}
}
}

func main() {
cmdStr := `
#!/bin/bash
for var in {1..10}
do
sleep 1
echo "Hello, Welcome ${var} times "
done`

cmd := exec.Command("bash", "-c", cmdStr)
//这里得到标准输出和标准错误输出的两个管道,此处获取了错误处理
cmdStdoutPipe, _ := cmd.StdoutPipe()
cmdStderrPipe, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
fmt.Println(err)
}

go syncLog(cmdStdoutPipe)
go syncLog(cmdStderrPipe)
if err := cmd.Wait(); err != nil {
fmt.Println(err)
}
time.Sleep(time.Second * 5)
}

扩展 - 解决log格式乱的问题

上面第三种方式,我们直接是通过打开文件,然后将读取到的程序输出写入文件,但是实际上可能别人又已经封装好了一个logger,让你往logger里面写。比如,我这里就使用log包提供的log然后将程序的执行结果写入,但是因为使用了log包,所以写入的格式和log本身的格式造成格式会有部分的错乱,参考我的解决办法,解释都在注释里,如下:

import (
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
)

//通过管道同步获取日志的函数
func syncLog(logger *log.Logger, reader io.ReadCloser) {
//因为logger的print方法会自动添加一个换行,所以我们需要一个cache暂存不满一行的log
cache := ""
buf := make([]byte, 1024, 1024)
for {
strNum, err := reader.Read(buf)
if strNum > 0 {
outputByte := buf[:strNum]

outputSlice := strings.Split(string(outputByte), "\n")
logText := strings.Join(outputSlice[:len(outputSlice)-1], "\n")
logger.Printf("%s%s", cache, logText)
cache = outputSlice[len(outputSlice)-1]
}
if err != nil {
//读到结尾
if err == io.EOF || strings.Contains(err.Error(), "file already closed") {
err = nil
}
}
}
}

func main() {
cmdStr := `
#!/bin/bash
for var in {1..10}
do
sleep 1
echo "Hello, Welcome ${var} times "
done`

cmd := exec.Command("bash", "-c", cmdStr)
//这里得到标准输出和标准错误输出的两个管道,此处获取了错误处理
cmdStdoutPipe, _ := cmd.StdoutPipe()
cmdStderrPipe, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
fmt.Println(err)
}

//打开一个文件,用作log封装输出
file, _ := os.OpenFile("file.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
defer file.Close()

//创建封装的log,第三个参数设置log输出的格式
logger := log.New(file, "", log.LstdFlags)
logger.Print("start print log:")
oldFlags := logger.Flags()

//为了保证shell的输出和标准的log格式不冲突,并且为了整齐,关闭logger自身的格式
logger.SetFlags(0)
go syncLog(logger, cmdStdoutPipe)
go syncLog(logger, cmdStderrPipe)

err := cmd.Wait()
//执行完后再打开log输出的格式
logger.SetFlags(oldFlags)
logger.Print("log print done")
if err != nil {
fmt.Println(err)
}
}

程序执行结果如下:

2019/08/11 21:30:51 start print log:
Hello, Welcome 1 times
Hello, Welcome 2 times
Hello, Welcome 3 times
Hello, Welcome 4 times
Hello, Welcome 5 times
Hello, Welcome 6 times
Hello, Welcome 7 times
Hello, Welcome 8 times
Hello, Welcome 9 times
Hello, Welcome 10 times
2019/08/11 21:31:01 log print done

参考资料及衍生读物

  1. Go基础篇5:内置模块
  2. golang os/exec 执行外部命令
  3. https://gowalker.org/os/exec
  4. [译]使用os/exec执行命令 GO语言
  5. golang os/exec包用法之Kill进程及其子进程

欢迎支持笔者的作品《深入理解Kafka: 核心设计与实践原理》和《RabbitMQ实战指南》,同时欢迎关注笔者的微信公众号:朱小厮的博客(ID: hiddenkafka)。
本文作者: 朱小厮

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×