Go每日一庫(kù)之調(diào)用外部命令的幾種姿勢(shì)
引子
在工作中,我時(shí)不時(shí)地會(huì)需要在Go中調(diào)用外部命令。前段時(shí)間我做了一個(gè)工具,在釘釘群中添加了一個(gè)機(jī)器人,@這個(gè)機(jī)器人可以讓它執(zhí)行一些寫(xiě)好的腳本程序完成指定的任務(wù)。機(jī)器人倒是不難,照著釘釘開(kāi)發(fā)者文檔添加好機(jī)器人,然后@這個(gè)機(jī)器人就會(huì)向一個(gè)你指定的服務(wù)器發(fā)送一個(gè)POST請(qǐng)求,請(qǐng)求中會(huì)附帶文本消息。所以我要做的就是搭一個(gè)Web服務(wù)器,可以用go原生的net/http包,也可以用gin/fasthttp/fiber這些Web框架。收到請(qǐng)求之后,檢查附帶文本中的關(guān)鍵字去調(diào)用對(duì)應(yīng)的程序,然后返回結(jié)果。
go標(biāo)準(zhǔn)庫(kù)中的os/exec包對(duì)調(diào)用外部程序提供了支持,本文詳細(xì)介紹os/exec的使用姿勢(shì)。
運(yùn)行命令
Linux中有個(gè)cal命令,它可以顯示指定年、月的日歷,如果不指定年、月,默認(rèn)為當(dāng)前時(shí)間對(duì)應(yīng)的年月。如果使用的是Windows,推薦安裝msys2,這個(gè)軟件包含了絕大多數(shù)的Linux常用命令。


那么,在Go代碼中怎么調(diào)用這個(gè)命令呢?其實(shí)也很簡(jiǎn)單:
func?main()?{
??cmd?:=?exec.Command("cal")
??err?:=?cmd.Run()
??if?err?!=?nil?{
????log.Fatalf("cmd.Run()?failed:?%v\n",?err)
??}
}
首先,我們調(diào)用exec.Command傳入命令名,創(chuàng)建一個(gè)命令對(duì)象exec.Cmd。接著調(diào)用該命令對(duì)象的Run()方法運(yùn)行它。
如果你實(shí)際運(yùn)行了,你會(huì)發(fā)現(xiàn)什么也沒(méi)有發(fā)生,哈哈。事實(shí)上,使用os/exec執(zhí)行命令,標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤默認(rèn)會(huì)被丟棄。
顯示輸出
exec.Cmd對(duì)象有兩個(gè)字段Stdout和Stderr,類(lèi)型皆為io.Writer。我們可以將任意實(shí)現(xiàn)了io.Writer接口的類(lèi)型實(shí)例賦給這兩個(gè)字段,繼而實(shí)現(xiàn)標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤的重定向。io.Writer接口在 Go 標(biāo)準(zhǔn)庫(kù)和第三方庫(kù)中隨處可見(jiàn),例如*os.File、*bytes.Buffer、net.Conn。所以我們可以將命令的輸出重定向到文件、內(nèi)存緩存甚至發(fā)送到網(wǎng)絡(luò)中。
顯示到標(biāo)準(zhǔn)輸出
將exec.Cmd對(duì)象的Stdout和Stderr這兩個(gè)字段都設(shè)置為os.Stdout,那么輸出內(nèi)容都將顯示到標(biāo)準(zhǔn)輸出:
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)
??}
}
運(yùn)行程序。我在git bash運(yùn)行,得到如下結(jié)果:

輸出了中文,檢查一下環(huán)境變量LANG的值,果然是zh_CN.UTF-8。如果想輸出英文,可以將環(huán)境變量LANG設(shè)置為en_US.UTF-8:
$?echo?$LANG
zh_CN.UTF-8
$?LANG=en_US.UTF-8?go?run?main.go
得到輸出:

輸出到文件
打開(kāi)或創(chuàng)建文件,然后將文件句柄賦給exec.Cmd對(duì)象的Stdout和Stderr這兩個(gè)字段即可實(shí)現(xiàn)輸出到文件的功能。
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打開(kāi)一個(gè)文件,指定os.O_CREATE標(biāo)志讓操作系統(tǒng)在文件不存在時(shí)自動(dòng)創(chuàng)建一個(gè),返回該文件對(duì)象*os.File。*os.File實(shí)現(xiàn)了io.Writer接口。
運(yùn)行程序:
$?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
發(fā)送到網(wǎng)絡(luò)
現(xiàn)在我們來(lái)編寫(xiě)一個(gè)日歷服務(wù),接收年、月信息,返回該月的日歷。
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)
}
這里為了簡(jiǎn)單,錯(cuò)誤處理都省略了。正常情況下,year和month參數(shù)都需要做合法性校驗(yàn)。exec.Command函數(shù)接收一個(gè)字符串類(lèi)型的可變參數(shù)作為命令的參數(shù):
func?Command(name?string,?arg?...string)?*Cmd
運(yùn)行程序,使用瀏覽器請(qǐng)求localhost:8080/cal?year=2021&month=2得到:

保存到內(nèi)存對(duì)象中
*bytes.Buffer同樣也實(shí)現(xiàn)了io.Writer接口,故如果我們創(chuàng)建一個(gè)*bytes.Buffer對(duì)象,并將其賦給exec.Cmd的Stdout和Stderr這兩個(gè)字段,那么命令執(zhí)行之后,該*bytes.Buffer對(duì)象中保存的就是命令的輸出。
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())
}
運(yùn)行:
$?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
運(yùn)行命令,然后得到輸出的字符串或字節(jié)切片這種模式是如此的普遍,并且使用便利,os/exec包提供了一個(gè)便捷方法:CombinedOutput。
輸出到多個(gè)目的地
有時(shí),我們希望能輸出到文件和網(wǎng)絡(luò),同時(shí)保存到內(nèi)存對(duì)象。使用go提供的io.MultiWriter可以很容易實(shí)現(xiàn)這個(gè)需求。io.MultiWriter很方便地將多個(gè)io.Writer轉(zhuǎn)為一個(gè)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())
}
調(diào)用io.MultiWriter將多個(gè)io.Writer整合成一個(gè)io.Writer,然后將cmd對(duì)象的Stdout和Stderr都賦值為這個(gè)io.Writer。這樣,命令運(yùn)行時(shí)產(chǎn)出的輸出會(huì)分別送往http.ResponseWriter、*os.File以及*bytes.Buffer。
運(yùn)行命令,獲取輸出
前面提到,我們常常需要運(yùn)行命令,返回輸出。exec.Cmd對(duì)象提供了一個(gè)便捷方法:CombinedOutput()。該方法運(yùn)行命令,將輸出內(nèi)容以一個(gè)字節(jié)切片返回便于后續(xù)處理。所以,上面獲取輸出的程序可以簡(jiǎn)化為:
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()方法的實(shí)現(xiàn)很簡(jiǎn)單,先將標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤重定向到*bytes.Buffer對(duì)象,然后運(yùn)行程序,最后返回該對(duì)象中的字節(jié)切片:
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必須是未設(shè)置狀態(tài)。這其實(shí)很好理解,一般情況下,如果已經(jīng)打算使用CombinedOutput方法獲取輸出內(nèi)容,不會(huì)再自找麻煩地再去設(shè)置Stdout和Stderr字段了。
與CombinedOutput類(lèi)似的還有Output方法,區(qū)別是Output只會(huì)返回運(yùn)行命令產(chǎn)出的標(biāo)準(zhǔn)輸出內(nèi)容。
分別獲取標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤
創(chuàng)建兩個(gè)*bytes.Buffer對(duì)象,分別賦給exec.Cmd對(duì)象的Stdout和Stderr這兩個(gè)字段,然后運(yùn)行命令即可分別獲取標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤。
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())
}
標(biāo)準(zhǔn)輸入
exec.Cmd對(duì)象有一個(gè)類(lèi)型為io.Reader的字段Stdin。命令運(yùn)行時(shí)會(huì)從這個(gè)io.Reader讀取輸入。先來(lái)看一個(gè)最簡(jiǎn)單的例子:
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)
??}
}
如果不帶參數(shù)運(yùn)行cat命令,則進(jìn)入交互模式,cat按行讀取輸入,并且原樣發(fā)送到輸出。
再來(lái)看一個(gè)復(fù)雜點(diǎn)的例子。Go標(biāo)準(zhǔn)庫(kù)中compress/bzip2包只提供解壓方法,并沒(méi)有壓縮方法。我們可以利用Linux命令bzip2實(shí)現(xiàn)壓縮。bzip2從標(biāo)準(zhǔn)輸入中讀取數(shù)據(jù),將其壓縮,并發(fā)送到標(biāo)準(zhǔn)輸出。
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
}
參數(shù)-c表示壓縮,-9表示壓縮等級(jí),9為最高。為了驗(yàn)證函數(shù)的正確性,寫(xiě)個(gè)簡(jiǎn)單的程序,先壓縮"hello world"字符串,然后解壓,看看是否能得到原來(lái)的字符串:
func?main()?{
??data?:=?[]byte("hello?world")
??compressed,?_?:=?bzipCompress(data)
??r?:=?bzip2.NewReader(bytes.NewBuffer(compressed))
??decompressed,?_?:=?ioutil.ReadAll(r)
??fmt.Println(string(decompressed))
}
運(yùn)行程序,輸出"hello world"。
環(huán)境變量
環(huán)境變量可以在一定程度上微調(diào)程序的行為,當(dāng)然這需要程序的支持。例如,設(shè)置ENV=production會(huì)抑制調(diào)試日志的輸出。每個(gè)環(huán)境變量都是一個(gè)鍵值對(duì)。exec.Cmd對(duì)象中有一個(gè)類(lèi)型為[]string的字段Env。我們可以通過(guò)修改它來(lái)達(dá)到控制命令運(yùn)行時(shí)的環(huán)境變量的目的。
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))
}
上面代碼獲取系統(tǒng)的環(huán)境變量,然后又添加了兩個(gè)環(huán)境變量NAME和AGE。最后使用bash運(yùn)行腳本test.sh:
#!/bin/bash
echo?$NAME
echo?$AGE
echo?$GOPATH
程序運(yùn)行結(jié)果:
$?go?run?main.go?
darjun
18
D:\workspace\code\go
檢查命令是否存在
一般在運(yùn)行命令之前,我們通過(guò)希望能檢查要運(yùn)行的命令是否存在,如果存在則直接運(yùn)行,否則提示用戶(hù)安裝此命令。os/exec包提供了函數(shù)LookPath可以獲取命令所在目錄,如果命令不存在,則返回一個(gè)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)
??}
}
運(yùn)行:
$?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%
封裝
執(zhí)行外部命令的流程比較固定:
-
調(diào)用
exec.Command()創(chuàng)建命令對(duì)象; -
調(diào)用
Cmd.Run()執(zhí)行命令
如果要獲取輸出,需要調(diào)用CombinedOutput/Output之類(lèi)的方法,或者手動(dòng)創(chuàng)建bytes.Buffer對(duì)象并賦值給exec.Cmd的Stdout和Stderr字段。為了使用方便,我編寫(xiě)了一個(gè)包goexec。
接口如下:
//?執(zhí)行命令,丟棄標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤
func?RunCommand(cmd?string,?arg?[]string,?opts?...Option)?error
//?執(zhí)行命令,以[]byte類(lèi)型返回輸出
func?CombinedOutput(cmd?string,?arg?[]string,?opts?...Option)?([]byte,?error)
//?執(zhí)行命令,以string類(lèi)型返回輸出
func?CombinedOutputString(cmd?string,?arg?[]string,?opts?...Option)?(string,?error)
//?執(zhí)行命令,以[]byte類(lèi)型返回標(biāo)準(zhǔn)輸出
func?Output(cmd?string,?arg?[]string,?opts?...Option)?([]byte,?error)
//?執(zhí)行命令,以string類(lèi)型返回標(biāo)準(zhǔn)輸出
func?OutputString(cmd?string,?arg?[]string,?opts?...Option)?(string,?error)
//?執(zhí)行命令,以[]byte類(lèi)型分別返回標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤
func?SeparateOutput(cmd?string,?arg?[]string,?opts?...Option)?([]byte,?[]byte,?error)
//?執(zhí)行命令,以string類(lèi)型分別返回標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯(cuò)誤
func?SeparateOutputString(cmd?string,?arg?[]string,?opts?...Option)?(string,?string,?error)
相較于直接使用os/exec包,我傾向于一次函數(shù)調(diào)用就能獲得結(jié)果。對(duì)輸入、設(shè)置環(huán)境變量這些功能,我通過(guò)Option模式來(lái)提供支持。
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)
??}
}
使用非常簡(jiǎn)單:
func?main()?{
??fmt.Println(goexec.CombinedOutputString("cal",?nil,?goexec.WithEnv("LANG",?"en_US.UTF-8")))
}
有一點(diǎn)我不太滿(mǎn)意,為了使用Option模式,本來(lái)可以用可變參數(shù)來(lái)傳遞命令參數(shù),現(xiàn)在只能用切片了,即使不需要指定參數(shù),也必須要傳入一個(gè)nil。暫時(shí)還沒(méi)有想到比較優(yōu)雅的解決方法。
總結(jié)
本文介紹了使用os/exec這個(gè)標(biāo)準(zhǔn)庫(kù)調(diào)用外部命令的各種姿勢(shì)。同時(shí)為了便于使用,我編寫(xiě)了一個(gè)goexec包封裝對(duì)os/exec的調(diào)用。這個(gè)包目前for我自己使用是沒(méi)有問(wèn)題的,大家有其他需求可以提issue或者自己魔改??。
大家如果發(fā)現(xiàn)好玩、好用的 Go 語(yǔ)言庫(kù),歡迎到 Go 每日一庫(kù) 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 每日一庫(kù) GitHub:https://github.com/darjun/go-daily-lib
推薦閱讀
我為大家整理了一份 從入門(mén)到進(jìn)階的Go學(xué)習(xí)資料禮包 ,包含學(xué)習(xí)建議:入門(mén)看什么,進(jìn)階看什么。 關(guān)注公眾號(hào) 「polarisxu」,回復(fù)? ebook ?獲??;還可以回復(fù)「進(jìn)群」,和數(shù)萬(wàn) Gopher 交流學(xué)習(xí)。
