引子
在工作中,我时不时地会须要在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 $LANGzh_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 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.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 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对象的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 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))}
下面代码获取零碎的环境变量,而后又增加了两个环境变量NAME
和AGE
。最初应用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.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
本文参加了思否技术征文,欢送正在浏览的你也退出。