引子
在工作中,我時不時地會需要在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
本文參與了思否技術徵文,歡迎正在閲讀的你也加入。