引子

在工作中,我时不时地会须要在Go中调用外部命令。前段时间我做了一个工具,在钉钉群中增加了一个机器人,@这个机器人能够让它执行一些写好的脚本程序实现指定的工作。机器人倒是不难,照着钉钉开发者文档增加好机器人,而后@这个机器人就会向一个你指定的服务器发送一个POST申请,申请中会附带文本音讯。所以我要做的就是搭一个Web服务器,能够用go原生的net/http包,也能够用gin/fasthttp/fiber这些Web框架。收到申请之后,查看附带文本中的关键字去调用对应的程序,而后返回后果。

go规范库中的os/exec包对调用内部程序提供了反对,本文具体介绍os/exec的应用姿态。

运行命令

Linux中有个cal命令,它能够显示指定年、月的日历,如果不指定年、月,默认为以后工夫对应的年月。如果应用的是Windows,举荐装置msys2,这个软件蕴含了绝大多数的Linux常用命令。

那么,在Go代码中怎么调用这个命令呢?其实也很简略:

func main() {  cmd := exec.Command("cal")  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }}

首先,咱们调用exec.Command传入命令名,创立一个命令对象exec.Cmd。接着调用该命令对象的Run()办法运行它。

如果你理论运行了,你会发现什么也没有产生,哈哈。事实上,应用os/exec执行命令,规范输入和规范谬误默认会被抛弃。

显示输入

exec.Cmd对象有两个字段StdoutStderr,类型皆为io.Writer。咱们能够将任意实现了io.Writer接口的类型实例赋给这两个字段,继而实现规范输入和规范谬误的重定向。io.Writer接口在 Go 规范库和第三方库中随处可见,例如*os.File*bytes.Buffernet.Conn。所以咱们能够将命令的输入重定向到文件、内存缓存甚至发送到网络中。

显示到规范输入

exec.Cmd对象的StdoutStderr这两个字段都设置为os.Stdout,那么输入内容都将显示到规范输入:

func main() {  cmd := exec.Command("cal")  cmd.Stdout = os.Stdout  cmd.Stderr = os.Stderr  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }}

运行程序。我在git bash运行,失去如下后果:

输入了中文,检查一下环境变量LANG的值,果然是zh_CN.UTF-8。如果想输入英文,能够将环境变量LANG设置为en_US.UTF-8

$ echo $LANGzh_CN.UTF-8$ LANG=en_US.UTF-8 go run main.go

失去输入:

输入到文件

关上或创立文件,而后将文件句柄赋给exec.Cmd对象的StdoutStderr这两个字段即可实现输入到文件的性能。

func main() {  f, err := os.OpenFile("out.txt", os.O_WRONLY|os.O_CREATE, os.ModePerm)  if err != nil {    log.Fatalf("os.OpenFile() failed: %v\n", err)  }  cmd := exec.Command("cal")  cmd.Stdout = f  cmd.Stderr = f  err = cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }}

os.OpenFile关上一个文件,指定os.O_CREATE标记让操作系统在文件不存在时主动创立一个,返回该文件对象*os.File*os.File实现了io.Writer接口。

运行程序:

$ go run main.go$ cat out.txt    November 2022   Su Mo Tu We Th Fr Sa       1  2  3  4  5 6  7  8  9 10 11 1213 14 15 16 17 18 1920 21 22 23 24 25 2627 28 29 30

发送到网络

当初咱们来编写一个日历服务,接管年、月信息,返回该月的日历。

func cal(w http.ResponseWriter, r *http.Request) {  year := r.URL.Query().Get("year")  month := r.URL.Query().Get("month")  cmd := exec.Command("cal", month, year)  cmd.Stdout = w  cmd.Stderr = w  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }}func main() {  http.HandleFunc("/cal", cal)  http.ListenAndServe(":8080", nil)}

这里为了简略,错误处理都省略了。失常状况下,year和month参数都须要做合法性校验。exec.Command函数接管一个字符串类型的可变参数作为命令的参数:

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

运行程序,应用浏览器申请localhost:8080/cal?year=2021&month=2失去:

保留到内存对象中

*bytes.Buffer同样也实现了io.Writer接口,故如果咱们创立一个*bytes.Buffer对象,并将其赋给exec.CmdStdoutStderr这两个字段,那么命令执行之后,该*bytes.Buffer对象中保留的就是命令的输入。

func main() {  buf := bytes.NewBuffer(nil)  cmd := exec.Command("cal")  cmd.Stdout = buf  cmd.Stderr = buf  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  fmt.Println(buf.String())}

运行:

$ go run main.go    November 2022   Su Mo Tu We Th Fr Sa       1  2  3  4  5 6  7  8  9 10 11 1213 14 15 16 17 18 1920 21 22 23 24 25 2627 28 29 30

运行命令,而后失去输入的字符串或字节切片这种模式是如此的广泛,并且应用便当,os/exec包提供了一个便捷办法:CombinedOutput

输入到多个目的地

有时,咱们心愿能输入到文件和网络,同时保留到内存对象。应用go提供的io.MultiWriter能够很容易实现这个需要。io.MultiWriter很不便地将多个io.Writer转为一个io.Writer

咱们略微批改下面的web程序:

func cal(w http.ResponseWriter, r *http.Request) {  year := r.URL.Query().Get("year")  month := r.URL.Query().Get("month")  f, _ := os.OpenFile("out.txt", os.O_CREATE|os.O_WRONLY, os.ModePerm)  buf := bytes.NewBuffer(nil)  mw := io.MultiWriter(w, f, buf)  cmd := exec.Command("cal", month, year)  cmd.Stdout = mw  cmd.Stderr = mw  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  fmt.Println(buf.String())}

调用io.MultiWriter将多个io.Writer整合成一个io.Writer,而后将cmd对象的StdoutStderr都赋值为这个io.Writer。这样,命令运行时产出的输入会别离送往http.ResponseWriter*os.File以及*bytes.Buffer

运行命令,获取输入

后面提到,咱们经常须要运行命令,返回输入。exec.Cmd对象提供了一个便捷办法:CombinedOutput()。该办法运行命令,将输入内容以一个字节切片返回便于后续解决。所以,下面获取输入的程序能够简化为:

func main() {  cmd := exec.Command("cal")  output, err := cmd.CombinedOutput()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  fmt.Println(string(output))}

So easy!

CombinedOutput()办法的实现很简略,先将规范输入和规范谬误重定向到*bytes.Buffer对象,而后运行程序,最初返回该对象中的字节切片:

func (c *Cmd) CombinedOutput() ([]byte, error) {  if c.Stdout != nil {    return nil, errors.New("exec: Stdout already set")  }  if c.Stderr != nil {    return nil, errors.New("exec: Stderr already set")  }  var b bytes.Buffer  c.Stdout = &b  c.Stderr = &b  err := c.Run()  return b.Bytes(), err}

CombinedOutput办法前几行判断表明,StdoutStderr必须是未设置状态。这其实很好了解,个别状况下,如果曾经打算应用CombinedOutput办法获取输入内容,不会再自找麻烦地再去设置StdoutStderr字段了。

CombinedOutput相似的还有Output办法,区别是Output只会返回运行命令产出的规范输入内容。

别离获取规范输入和规范谬误

创立两个*bytes.Buffer对象,别离赋给exec.Cmd对象的StdoutStderr这两个字段,而后运行命令即可别离获取规范输入和规范谬误。

func main() {  cmd := exec.Command("cal", "15", "2012")  var stdout, stderr bytes.Buffer  cmd.Stdout = &stdout  cmd.Stderr = &stderr  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  fmt.Printf("output:\n%s\nerror:\n%s\n", stdout.String(), stderr.String())}

规范输出

exec.Cmd对象有一个类型为io.Reader的字段Stdin。命令运行时会从这个io.Reader读取输出。先来看一个最简略的例子:

func main() {  cmd := exec.Command("cat")  cmd.Stdin = bytes.NewBufferString("hello\nworld")  cmd.Stdout = os.Stdout  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }}

如果不带参数运行cat命令,则进入交互模式,cat按行读取输出,并且原样发送到输入。

再来看一个简单点的例子。Go规范库中compress/bzip2包只提供解压办法,并没有压缩办法。咱们能够利用Linux命令bzip2实现压缩。bzip2从规范输出中读取数据,将其压缩,并发送到规范输入。

func bzipCompress(d []byte) ([]byte, error) {  var out bytes.Buffer  cmd := exec.Command("bzip2", "-c", "-9")  cmd.Stdin = bytes.NewBuffer(d)  cmd.Stdout = &out  err := cmd.Run()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  return out.Bytes(), nil}

参数-c示意压缩,-9示意压缩等级,9为最高。为了验证函数的正确性,写个简略的程序,先压缩"hello world"字符串,而后解压,看看是否能失去原来的字符串:

func main() {  data := []byte("hello world")  compressed, _ := bzipCompress(data)  r := bzip2.NewReader(bytes.NewBuffer(compressed))  decompressed, _ := ioutil.ReadAll(r)  fmt.Println(string(decompressed))}

运行程序,输入"hello world"。

环境变量

环境变量能够在肯定水平上微调程序的行为,当然这须要程序的反对。例如,设置ENV=production会克制调试日志的输入。每个环境变量都是一个键值对。exec.Cmd对象中有一个类型为[]string的字段Env。咱们能够通过批改它来达到管制命令运行时的环境变量的目标。

package mainimport (  "fmt"  "log"  "os"  "os/exec")func main() {  cmd := exec.Command("bash", "-c", "./test.sh")  nameEnv := "NAME=darjun"  ageEnv := "AGE=18"  newEnv := append(os.Environ(), nameEnv, ageEnv)  cmd.Env = newEnv  out, err := cmd.CombinedOutput()  if err != nil {    log.Fatalf("cmd.Run() failed: %v\n", err)  }  fmt.Println(string(out))}

下面代码获取零碎的环境变量,而后又增加了两个环境变量NAMEAGE。最初应用bash运行脚本test.sh

#!/bin/bashecho $NAMEecho $AGEecho $GOPATH

程序运行后果:

$ go run main.go darjun18D:\workspace\code\go

查看命令是否存在

个别在运行命令之前,咱们通过心愿能查看要运行的命令是否存在,如果存在则间接运行,否则提醒用户装置此命令。os/exec包提供了函数LookPath能够获取命令所在目录,如果命令不存在,则返回一个error。

func main() {  path, err := exec.LookPath("ls")  if err != nil {    fmt.Printf("no cmd ls: %v\n", err)  } else {    fmt.Printf("find ls in path:%s\n", path)  }  path, err = exec.LookPath("not-exist")  if err != nil {    fmt.Printf("no cmd not-exist: %v\n", err)  } else {    fmt.Printf("find not-exist in path:%s\n", path)  }}

运行:

$ go run main.go find ls in path:C:\Program Files\Git\usr\bin\ls.exeno cmd not-exist: exec: "not-exist": executable file not found in %PATH%

封装

执行外部命令的流程比拟固定:

  • 调用exec.Command()创立命令对象;
  • 调用Cmd.Run()执行命令

如果要获取输入,须要调用CombinedOutput/Output之类的办法,或者手动创立bytes.Buffer对象并赋值给exec.CmdStdoutStderr字段。为了使用方便,我编写了一个包goexec

接口如下:

// 执行命令,抛弃规范输入和规范谬误func RunCommand(cmd string, arg []string, opts ...Option) error// 执行命令,以[]byte类型返回输入func CombinedOutput(cmd string, arg []string, opts ...Option) ([]byte, error)// 执行命令,以string类型返回输入func CombinedOutputString(cmd string, arg []string, opts ...Option) (string, error)// 执行命令,以[]byte类型返回规范输入func Output(cmd string, arg []string, opts ...Option) ([]byte, error)// 执行命令,以string类型返回规范输入func OutputString(cmd string, arg []string, opts ...Option) (string, error)// 执行命令,以[]byte类型别离返回规范输入和规范谬误func SeparateOutput(cmd string, arg []string, opts ...Option) ([]byte, []byte, error)// 执行命令,以string类型别离返回规范输入和规范谬误func SeparateOutputString(cmd string, arg []string, opts ...Option) (string, string, error)

相较于间接应用os/exec包,我偏向于一次函数调用就能取得后果。对输出、设置环境变量这些性能,我通过Option模式来提供反对。

type Option func(*exec.Cmd)func WithStdin(stdin io.Reader) Option {  return func(c *exec.Cmd) {    c.Stdin = stdin  }}func Without(stdout io.Writer) Option {  return func(c *exec.Cmd) {    c.Stdout = stdout  }}func WithStderr(stderr io.Writer) Option {  return func(c *exec.Cmd) {    c.Stderr = stderr  }}func WithOutWriter(out io.Writer) Option {  return func(c *exec.Cmd) {    c.Stdout = out    c.Stderr = out  }}func WithEnv(key, value string) Option {  return func(c *exec.Cmd) {    c.Env = append(os.Environ(), fmt.Sprintf("%s=%s", key, value))  }}func applyOptions(cmd *exec.Cmd, opts []Option) {  for _, opt := range opts {    opt(cmd)  }}

应用非常简单:

func main() {  fmt.Println(goexec.CombinedOutputString("cal", nil, goexec.WithEnv("LANG", "en_US.UTF-8")))}

有一点我不太称心,为了应用Option模式,原本能够用可变参数来传递命令参数,当初只能用切片了,即便不须要指定参数,也必须要传入一个nil。临时还没有想到比拟优雅的解决办法。

总结

本文介绍了应用os/exec这个规范库调用外部命令的各种姿态。同时为了便于应用,我编写了一个goexec包封装对os/exec的调用。这个包目前for我本人应用是没有问题的,大家有其余需要能够提issue或者本人魔改。

大家如果发现好玩、好用的 Go 语言库,欢送到 Go 每日一库 GitHub 上提交 issue

参考

  1. Advanced command execution in go with os/exec: https://blog.kowalczyk.info/article/wOYk/advanced-command-execution-in-go-with-osexec.html
  2. goexec: https://github.com/darjun/goexec
  3. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

本文参加了思否技术征文,欢送正在浏览的你也退出。