Go

Go 外部コマンド実行系のまとめ

Go でコードを書いているが、Google に頼りすぎて嫌になって来たので、腰を落ち着けて、がっつり理解する事にした。やりたい事は外部コマンドの実行ではあるが、その前後に色々やりたいことがある。

  • 外部コマンドが存在するか確認したい
  • 外部コマンドを実行したい
  • 外部コマンドに引き数は動的に設定したい
  • 外部コマンドを実行中はインタラクティブに実行結果が表示されて欲しい
  • 外部コマンドの成功失敗を取得したい

今回は、terraform のプロバイダを作ろうと思っているので、時間がかかる処理なので、実行中何も表示されないのは利用者としてきついと思うので、インタラクティブに実行結果が出て欲しい。作っているのはマルチプラットフォームで動作するCLIなので、Linux, Mac, Windows で動作して欲しい。

外部コマンドが存在するか確認したい

方法としては、コマンドを実行してみて、結果を見る方法。例えば、terraform の実行に限って言えば、次の方法で良い。ちなみに、"--help" をつけないとどうなるか?というと、ステータスコード、127 で落ちた。コマンドが見つからないとのこと。

func isCommandAvailable(name string) bool {
    cmd := exec.Command(name, "--help") // terraform works. it is not for everyCommand. Make it simple. it should depend on provider. 
    if err := cmd.Run(); err != nil {
        fmt.Println(err.Error())
        return false
    }
    return true
}

ちなみに、shell で実行ステータスコードを確認すると次のようになる。見た目はどちらもヘルプが出るので同じように見えるがステータスコードは異なっている様子。

--help あり

$ terraform --help
   :
$ echo $?
0

なし

$ terraform
    :
$ echo $?
127

ちなみに、Windows の PowerShell では、次のようにしてステータスコードを同様に確認できる。

$lastExitCode

試してみたが結果は同じだった。

Terraform の実行に関してはこれで良い。ただし、他のコマンドでは--help があるかわからないので、残念ながら汎用的ではない。

参考:(ただし、下記の方法は、Linux しか考慮していない)
* [Golang] Check If A Program (Command) Exists
*Linx: 終了ステータス
*Windows: 1.10. Determine the Status of the Last Command

外部コマンドの実行

上記のコードでもすでに実行しているが、各種オプションに関して調査して見る。

コマンドの実行は、Command 関数を実行し、Cmd 構造体を取得、その関数を実行することで、得られる。

cmd := exec.Command("sleep", "1")
err := cmd.Run()
  :

仕様はこちら

ちなみに、Run() の他に、Start() という関数もあるが、Run() がコマンドの完了を待つのに対して、Start() は待たないという違いがある。Start() はその代わり、Wait() という関数もあり、そちらは Start() で始めたコマンドを Wait() で待つということができる。つまり、コマンド実行の間他の処理を実施して、Wait()で待つというコードを書ける。

外部コマンド実行のパラメータを動的に設定する

Command の定義は、func Command(name string, arg ...string) *Cmd となっており、可変長の引数を受け付ける (Variadic Functions) 関数の方では、スライスから値を受け取れるようになる。引き渡す方をスライスにするためにはどうしたらいいだろう?これは簡単でスライスに...をつけて渡せば良い。

commandAndArgs := []string{
"init",
}
cmd := exec.Command("terraform", commandAndArgs...) 

外部コマンド実行中にコマンド実行結果を表示する

terraformのような実行時間がかかるコマンドに対しては、実行中の様子を表示して欲しいと思うだろう。Cmd の中に Stdout io.WriterStderr io.Writer がある。 nil の場合は、NullDeviceに、io.Writer を実装している os.File を渡せば良い。ということは、Stdout のデバイスファイルを渡せば良いということになる。

多分 Windows では実装が違うけど、こんな雰囲気で実装されているっぽい。

Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")

実際にうまくいったコード

    commandAndArgs := []string{
        "init",
    }
    cmd := exec.Command("terraform", commandAndArgs...) // ...enable us to pass them slice
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    err := cmd.Run()

他に便利コマンドとして、cmd.Output() で標準出力が、cmd.CombineOutput() 関数で、標準出力とエラー出力の合わさったものが[]byte に出力される。ただし、これは実行が終わった後になる。

エラー出力を画面出力すると同時に、バッファに出力してみる

ユーザーの利便性のために、画面出力すると同時に、ファイルや、バッファに出力したいという要望もあるかもしれない。その際は、io.MultiWriter を使うと良さげ。

var buf bytes.Buffer
multiWriter := io.MultiWriter(&buf, os.Stdout)
cmd.Stdout = multiWriter
   :
fmt.Println(buf.String())

コマンドの失敗成功を取得したい

コマンドの失敗成功は、Run() の戻り値で判断すると良い。

    err := cmd.Run() // Run for Wait for the execution.
    if err != nil {
        panic(err)
    }

例えば上記のコードでコマンド実行の戻り値が0以外だと次のようになる。err の中身はこのケースでは、exec.ExitError になる様子。ただし、他のオブジェクトになるケースもあるので、素直にエラーコードを取得して終了するのが良さげ。

panic: exit status 127

goroutine 1 [running]:
main.main()
    /Users/ushio/Codes/Strikes/src/github.com/TsuyoshiUshio/strikes/spikes/command/main.go:32 +0xa4e

ちなみに、StdoutPipe() 関数もあって、試してみたが、これを使うと、次のようになる。内部で、

cmd := exec.Command("terraform", commandAndArgs...)
stdout, err := cmd.StdoutPipe()
   :
err = cmd.Start()
result,_ := ioutil.ReadAll(stdout)
fmt.Println(string(result))

if err = cmd.Wait(); err != nil {
   :

まとめ

Go の exec パッケージのコマンド実行に関してはかなりわかってきた。exec.Command は組み込みのコマンドを実行する場合は一工夫必要という情報もある。自分はまずは、terraformの実行なので、この程度の理解があれば、安全に実行できるだろう。