天天看點

Go中調用外部指令的幾種姿勢

引子

在工作中,我時不時地會需要在Go中調用外部指令。前段時間我做了一個工具,在釘釘群中添加了一個機器人,@這個機器人可以讓它執行一些寫好的腳本程式完成指定的任務。機器人倒是不難,照着釘釘開發者文檔添加好機器人,然後@這個機器人就會向一個你指定的伺服器發送一個POST請求,請求中會附帶文本消息。是以我要做的就是搭一個Web伺服器,可以用go原生的net/http包,也可以用gin/fasthttp/fiber這些Web架構。收到請求之後,檢查附帶文本中的關鍵字去調用對應的程式,然後傳回結果。

go标準庫中的os/exec包對調用外部程式提供了支援,本文詳細介紹os/exec的使用姿勢。

運作指令

Linux中有個​

​cal​

​指令,它可以顯示指定年、月的月曆,如果不指定年、月,預設為目前時間對應的年月。如果使用的是Windows,推薦安裝msys2,這個軟體包含了絕大多數的Linux常用指令。

Go中調用外部指令的幾種姿勢
Go中調用外部指令的幾種姿勢

那麼,在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運作,得到如下結果:

Go中調用外部指令的幾種姿勢

輸出了中文,檢查一下環境變量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      

得到輸出:

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​

​得到:

Go中調用外部指令的幾種姿勢

儲存到記憶體對象中

​*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或者自己魔改😄。

參考

  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