共计 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
对象有两个字段 Stdout
和Stderr
,类型皆为 io.Writer
。咱们能够将任意实现了io.Writer
接口的类型实例赋给这两个字段,继而实现规范输入和规范谬误的重定向。io.Writer
接口在 Go 规范库和第三方库中随处可见,例如*os.File
、*bytes.Buffer
、net.Conn
。所以咱们能够将命令的输入重定向到文件、内存缓存甚至发送到网络中。
显示到规范输入
将 exec.Cmd
对象的 Stdout
和Stderr
这两个字段都设置为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
对象的 Stdout
和Stderr
这两个字段即可实现输入到文件的性能。
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.Cmd
的Stdout
和 Stderr
这两个字段,那么命令执行之后,该 *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 对象的Stdout
和Stderr
都赋值为这个 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
办法前几行判断表明,Stdout
和 Stderr
必须是未设置状态。这其实很好了解,个别状况下,如果曾经打算应用 CombinedOutput
办法获取输入内容,不会再自找麻烦地再去设置 Stdout
和Stderr
字段了。
与 CombinedOutput
相似的还有 Output
办法,区别是 Output
只会返回运行命令产出的规范输入内容。
别离获取规范输入和规范谬误
创立两个 *bytes.Buffer
对象,别离赋给 exec.Cmd
对象的 Stdout
和Stderr
这两个字段,而后运行命令即可别离获取规范输入和规范谬误。
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))
}
下面代码获取零碎的环境变量,而后又增加了两个环境变量 NAME
和AGE
。最初应用 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.Cmd
的Stdout
和 Stderr
字段。为了使用方便,我编写了一个包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😄
参考
- Advanced command execution in go with os/exec: https://blog.kowalczyk.info/article/wOYk/advanced-command-execution-in-go-with-osexec.html
- goexec: https://github.com/darjun/goexec
- Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib
本文参加了思否技术征文,欢送正在浏览的你也退出。