关于godailylib:Go中调用外部命令的几种姿势

51次阅读

共计 8377 个字符,预计需要花费 21 分钟才能阅读完成。

引子

在工作中,我时不时地会须要在 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 $LANG
zh_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 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 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 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 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 main

import (
  "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/bash

echo $NAME
echo $AGE
echo $GOPATH

程序运行后果:

$ go run main.go 
darjun
18
D:\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.exe
no 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

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

正文完
 0