【Golang】testingパッケージのError/ErrorfとFatal/Fatalfの違い

Golangのプログラムのテストを書く際、 testingパッケージ の関数 Error / ErrorfFatal / Fatalf の違いが分からなくなる時があるので整理してみました。

まとめの表

Error / Errorf / Fatal / Fatalf は簡易関数で、以下の

をそれぞれ異なる組み合わせで実行しています。

簡易
関数
実行される処理
Error Log: エラーログに引数で渡されたテキストを記録する。
Fail: 対象の関数のテストに失敗した記録を残すが、後続のテストは実行する
Errorf Logf: エラーログに引数で渡されたフォーマットでテキストを記録する。
Fail: 対象の関数のテストに失敗した記録を残すが、後続のテストは実行する
Fatal Log: エラーログに引数で渡されたテキストを記録する。
FailNow: 対象の関数のテストに失敗した記録を残し、後続のテストは実行しない
Fatalf Logf: エラーログに引数で渡されたフォーマットでテキストを記録する。
FailNow: 対象の関数のテストに失敗した記録を残し、後続のテストは実行しない

実装・挙動の違いから、上記の表のようになっていることを確認していきます。

実装

まず、実装の点から違いを確認します。

Go 1.16時点でのそれぞれの関数の実装は以下のようになっていて、ログ出力の処理とFailマークを付ける処理を行っていることが分かります。(補足のコメントを追記しています。)

func (c *common) Log(args ...interface{}) {
    c.log(fmt.Sprintln(args...))
}
func (c *common) Logf(format string, args ...interface{}) {
    c.log(fmt.Sprintf(format, args...))
}

func (c *common) Error(args ...interface{}) {
    c.log(fmt.Sprintln(args...)) // ↑のLog関数と同じ
    c.Fail()
}

func (c *common) Errorf(format string, args ...interface{}) {
    c.log(fmt.Sprintf(format, args...)) // ↑のLogf関数と同じ
    c.Fail()
}

func (c *common) Fatal(args ...interface{}) {
    c.log(fmt.Sprintln(args...)) // ↑のLog関数と同じ
    c.FailNow()
}

func (c *common) Fatalf(format string, args ...interface{}) {
    c.log(fmt.Sprintf(format, args...)) // ↑のLogf関数と同じ
    c.FailNow()
}

挙動

Error / ErrorfFatal / Fatalfそれぞれの挙動を確認してみます。

挙動の確認に使うコード

  • 足し算をするだけの関数 (calc.go)
    • error には nil を返す
  • 関数からエラーが返ってこないかを確認するテストコード(calc_test.go)

を使って確認してみます。

package calc

func calc(a, b int) (int, error) {
    return a + b, nil
}
package calc

import "testing"

func TestCalc1(t *testing.T) {
    ret, err := calc(1, 2)
    if err != nil {
        t.Error("[Error]", ret, err)
    }
    t.Log("[END]TestCalc1 with Error")
}

func TestCalc2(t *testing.T) {
    ret, err := calc(1, 2)
    if err != nil {
        t.Errorf("[Error] ret:%d, err: %v", ret, err)
    }
    t.Log("[END]TestCalc2 with Errorf")
}

func TestCalc3(t *testing.T) {
    ret, err := calc(1, 2)
    if err != nil {
        t.Fatal("[Fatal]", ret, err)
    }
    t.Log("[END]TestCalc3 with Fatal")
}

func TestCalc4(t *testing.T) {
    ret, err := calc(1, 2)
    if err != nil {
        t.Fatalf("[Fatal] ret:%d, err: %v", ret, err)
    }
    t.Log("[END]TestCalc4 with Fatalf")
}

calc 関数はエラーが返らないようにしてあるので、テストを実行すると以下のようにすべてパスします。

$ go test -v
=== RUN   TestCalc1
    calc_test.go:10: [END]TestCalc1 with Error
--- PASS: TestCalc1 (0.00s)
=== RUN   TestCalc2
    calc_test.go:18: [END]TestCalc2 with Errorf
--- PASS: TestCalc2 (0.00s)
=== RUN   TestCalc3
    calc_test.go:26: [END]TestCalc3 with Fatal
--- PASS: TestCalc3 (0.00s)
=== RUN   TestCalc4
    calc_test.go:34: [END]TestCalc4 with Fatalf
--- PASS: TestCalc4 (0.00s)
PASS
ok      go-testing  0.019s

テスト対象の関数 calc からエラーを返してみる

テスト対象の関数 calcを変更して必ずエラーが返るようにしてみます。

package calc

import (
        "errors"
)

func calc(a, b int) (int, error) {
    return a + b, errors.New("error in calc")
}

再度テストを実行すると、いずれのテストもFAILしていますが

  • Error/Errorf関数を使ったテストの場合、最後までテストを実行できる
    • テスト関数の最後のログ出力: [END]TestCalcX出力されている
  • Fatail/Fatalf関数を使ったテストの場合、途中でテストが終わっている
    • テスト関数の最後のログ出力: [END]TestCalcX出力されていない

という違いがあることが分かります。

$ go test -v
=== RUN   TestCalc1
    calc_test.go:8: [Error] 3 error in calc
    calc_test.go:10: [END]TestCalc1 with Error
--- FAIL: TestCalc1 (0.00s)
=== RUN   TestCalc2
    calc_test.go:16: [Error] ret:3, err: error in calc
    calc_test.go:18: [END]TestCalc2 with Errorf
--- FAIL: TestCalc2 (0.00s)
=== RUN   TestCalc3
    calc_test.go:24: [Fatal] 3 error in calc
--- FAIL: TestCalc3 (0.00s)
=== RUN   TestCalc4
    calc_test.go:32: [Fatal] ret:3, err: error in calc
--- FAIL: TestCalc4 (0.00s)
FAIL
exit status 1
FAIL    go-testing  0.040s

フォーマットを指定してログ出力した方が分かりやすいと感じます。

上記の例では、TestCalc1の中に [Error] 3 error in calcという内容になっていて、3回エラーが発生したのか、と勘違いしてしまいそうです。

TestCalc2 のようにフォーマットが指定されていれば、[Error] ret:3, err: error in calc のように返すことができ、ret は3が返ってきているけどエラーが発生したんだな、と分かるので、適宜フォーマットを指定してエラーメッセージを返してあげるのが良さそうです。

サンプルコード

github.com

【Golang】ビルドしたバイナリのバージョン情報を表示、コマンドラインオプションを受け取るには (flag, spf13/pflag パッケージ)

本番環境にデプロイしたビルド済のバイナリのバージョンを確認したいことがあります。 また、開発環境で使うちょっとしたツールを作る場合、ほとんどの場合いくつかのコマンドラインオプションを受け取れるようにしたいです。

これらのバージョン表示、コマンドラインオプションは共に

で実現できます。

しかし、サードパーティ製ライブラリを含めると種類が多いため、どれを使って良いか分かりづらいです。それぞれの特徴を見て、どのような時にどの方法で実現したら良いか紹介してみようと思います。

結論 (どのような時にどの方法で実現したら良いか)

結論としては

  • バージョン情報のみを表示したい場合
    • 標準の flagパッケージ
  • 社内でのみ利用するツールやコマンドラインオプションが少ない場合
    • 標準の flagパッケージ
  • 一般に公開するツールやコマンドラインオプションが多い場合

になるかと思います。

それぞれ特徴を見ていきます。

標準の flagパッケージ

特徴

特徴としては

  • 標準パッケージのためライブラリ単体のバージョン更新は不要
  • ロングオプションの形式が、GNU/POSIXコマンドラインツールと異なる
    • 例えば、ロングオプション version、ショートオプション vを追加する場合
      • ロングオプションの形式は -version になる。 (GNU/POSIX--version を推奨。こちらは-が2つ。)
      • ショートプションの形式は -v になる。
  • ロングオプションとショートオプションの両方を追加するのが煩雑

また、標準の flag パッケージを使う場合

の間に実装方法に差異はありません。

コード

コードを書いて確認していきたいと思います。ショートオプションとロングオプションの両方を追加したい場合、2行に分けて書く必要があります。

package main

import (
    "flag"
    "fmt"
)

var version = "1.0"

func main() {
    var withVersion bool

    // ショートオプション `-v` を追加(デフォルトはflase)
    flag.BoolVar(&withVersion, "v", false, "version: short option")

    // ロングオプション `-version` を追加(デフォルトはflase)
    flag.BoolVar(&withVersion, "version", false, "version: long option")

    // 指定されたオプションを読み込む
    flag.Parse()

    // versionオプションが指定されていれば標準出力して戻る
    if withVersion {
        fmt.Println("Version ", version)
        return
    }

    // オプションversionが指定されていない場合の処理
    // 〜〜〜
}

追加オプションをつけて、go run で実行すると、バージョン情報が表示されることを確認できます。

$ go run main.go  -v
Version  1.0
$ go run main.go  -version
Version  1.0

ちなみに、flag.Parse() を実行することで、-h-helpも使えるようになります。

$ go run main.go  -h
Usage of /tmp/go-build277262992/b001/exe/main:
  -v    version: short option
  -version
        version: long option

用途

という特徴から

  • バージョン情報のみを表示したい
  • 社内でのみ利用するツールやコマンドラインオプションが少ない

といったケースでは、標準の flagパッケージを使うのが良いと言えそうです。

サードパーティー製のパッケージ

flagパッケージの代替となるサードパーティー製のパッケージには様々なものがありますが、2021年2月現在 spf13/pflag がよく使われてます。

よく使われる理由として考えられるのは、 spf13/cobra というコマンドラインツールのフレームワークで使われているライブラリだからです。

cobra

  • github cli
  • docker cli
  • kubectl

等の様々なコマンドラインツールを実装するのに使われている人気のライブラリなので、長期的にメンテナンスされることが見込まれている、という点が大きいかと思います。

参考までに、よく使われている他のサードパーティ製パッケージと併せて

  • go.modで requireされている数
  • Githubのスターの数

を記載しておきます。

spf13/pflag (サードパーティ製パッケージ)

特徴

特徴としては

  • オプションの形式が、GNU/POSIXコマンドラインツールと同じになる
    • 例えば、ロングオプション version、ショートオプション vを追加する場合
      • ロングオプションの形式は --version になる。
      • ショートプションの形式は -v になる。
  • ロングオプションとショートオプションの両方を追加するのが容易
  • サードパーティのパッケージのためライブラリ単体でバージョンの更新が必要

になるかと思います。

コード

こちらもコードを書いて確認してみます。ショートオプションとロングオプションの両方を追加したい場合、1行で表現できるのが確認できます。複数のオプションが必要な場合はこちらの方法が便利です。

package main

import (
    "fmt"
    "github.com/spf13/pflag"
)

var version = "1.0"

func main() {
    var withVersion bool

    // ロングオプション `--version`、ショートオプション `-v`を追加
    // デフォルトはflase
    pflag.BoolVarP(&withVersion, "version", "v", false, "version")

    // 指定されたオプションを読み込む
    pflag.Parse()

    // versionオプションが指定されていれば標準出力して戻る
    if withVersion {
        fmt.Println("Version ", version)
        return
    }

    // オプションversionが指定されていない場合の処理
    // 〜〜〜
}

オプションをつけて、go run で実行すると、バージョン情報が表示されることを確認できます。

$ go run main.go  -v
Version  1.0
$ go run main.go  --version
Version  1.0

なお、spf13/pflagで何らかのオプションを追加する方法を紹介するために version オプションを追加するコードを載せていますが、コマンドラインフレームワークであるcobraと一緒に使う場合は、versionオプションは追加不要です。(参照)

こちらのサードパーティーのパッケージも、pflag.Parse() を実行することで、-h-helpも使えるようになります。ヘルプの表示フォーマットに少し違いがあり、ショートオプションとロングオプションは1行で表示されます。

$ go run main.go -h
Usage of /tmp/go-build962608044/b001/exe/main:
  -v, --version   version

用途

  • オプションの形式が、GNU/POSIXコマンドラインツールと同じになる
  • ロングオプションとショートオプションの両方を追加するのが容易

という特徴から

といったケースでは、spf13/pflag のようなサードパーティのパッケージを使うのが良いと言えそうです。

便利機能

他にも spf13/pflagには便利な機能があります。長期的に運用していく時に必要な機能になりそうです。

関連記事

今回はspf13/pflagに関する記事でしたが、コマンドラインフレームワークspf13/cobraにも興味持たれた方は、もしよろしければ以下の記事も参照してみてください。 simple-minds-think-alike.hatenablog.com

simple-minds-think-alike.hatenablog.com

参考情報

【Golang】GitHubのOSSをフォークしてPull Requestを送ってみた

pdfcpu という golang で書かれたコマンドラインツールを使って、複数のPDFファイルを1つにまとめるという作業をたまに行っているのですが、ふとOSSだしPRを送ってみようかと思い立ったのでやってみました。

github.com

Pull Requestの内容

まずは簡単なPull Request(以下PR)を送ってみようと思い

  • Go Report Card でスコアを上げられる箇所の反映
  • テストを追加

してみました。

ローカル作業時の注意点

Githubで管理されているGo言語プロジェクトの場合、パッケージをimportとGithubリポジトリ名が入ります。

なので、ローカルで作業する時

すると、パッケージのパスとGoのコード内でimportするパッケージ名にずれが生じるので工夫が必要でした。

# forkしたリポジトリをgo getでローカルにインストール
$ go get github.com/moritamori/pdfcpu
$ cd $GOPATH/src/github.com/moritamori/pdfcpu

# 任意のコードを開いてパッケージ名を確認
$ cat cmd/pdfcpu/cmd.go -n

17 package main
18 
19 import (
20        "errors"
21        "flag"
22        "fmt"
23        "os"
24        "strings"
25    
26        "github.com/pdfcpu/pdfcpu/pkg/pdfcpu"
27    )

パッケージ名のずれを生じさせない工夫

以下の図のように本家である pdfcpu/pdfcpuリポジトリ(upstream)の master ブランチを go get で取得した後、作業ブランチを切るようにしました。

こうするとmaster ブランチに差分が出た際、作業中のブランチに取り込み易いので良いです。 f:id:moritamorie:20210211152209p:plain

PRを送るまでの手順

コミットにGPG署名できるように

GPG署名を求められているOSSプロジェクトだったので設定しました。具体的な手順は別記事にまとめています。

simple-minds-think-alike.hatenablog.com

Github上でForkする

まずは、プロジェクトのコードを自分のリポジトリにforkしてきます。

f:id:moritamorie:20210211141526p:plain

ローカルにコードを取得

本家である pdfcpu/pdfcpugo getでローカルにインストール

$ go get github.com/pdfcpu/pdfcpu

ローカルでremoteの名前をupstreamに変える

originになっているpdfcpu/pdfcpuupstream に変更します。

$ cd $GOPATH/src/github.com/pdfcpu/pdfcpu
$ git remote rename origin upstream

fork した自分のアカウントのリポジトリorigin にする。

$ git remote add origin git@github.com:moritamori/pdfcpu.git

ローカルで修正するブランチを作る

$ git checkout -b awesome-feature
$ git push origin awesome-feature

リモートブランチを確認すると以下のようになっています。

$ git branch -r
origin/awesome-feature
upstream/HEAD -> upstream/master
upstream/master

開発途中で master ブランチの修正を取り込む場合、 upstreamfetch して開発中のブランチにマージします。

$ git fetch upstream
$ git merge upstream/master

PRを送る

PRを作るとContribution License Agreementに同意して欲しいとコメントが表示されるので、リンク先で同意します。 f:id:moritamorie:20210212010625p:plain

ご投稿ありがとうございます。私たちは本当に感謝しています。多くのオープンソースプロジェクトと同様に、あなたの貢献を受け入れる前に、コントリビューターライセンス契約書に署名していただくようお願いしています。

リンクの遷移先では、以下のような画面になっていました。

f:id:moritamorie:20210213021012p:plain

ライセンスの同意している間にCIが終わりCheckがpassしていたらやることはないので、メンテナの方のレスポンスを待ちました。 f:id:moritamorie:20210212022124p:plain

メンテナの方からのレスポンス

大したPRでもないのであっさりマージされました。

f:id:moritamorie:20210213021235p:plain

今度はもう少し大きめの機能の追加にチャレンジしてみたいと思います。

参考記事

Linuxマシンでgit(github)のコミットにGPG署名をする

コミットに署名が求められるOSSにPRを送りたくて、Linuxマシンのローカル環境で gpg 署名付きコミットできるようにしてみたので、手順を残しておきます。

貢献したいOSSのREADME.mdやCONTRIBUTING.md等に sign your commit のように記載がある場合にgpg 署名付きコミットできるように設定します。

gpg 署名付きコミットの場合、Github上では以下のように Verified というバッジが表示されます。

f:id:moritamorie:20210211183540p:plain

前提

  • Ubuntu 18.04 LTS Desktop
  • GPA 0.9.10

インストール

gpa((GnuPG)インタフェース)とseahorse(GNOMEフロントエンド)をインストールします

$ sudo apt-get install gpa seahorse

GPGキーの生成

Githubの以下のドキュメントに従って、GPGキーを生成しました。

docs.github.com

$  gpg --full-generate-key
gpg (GnuPG) 2.2.4; Copyright (C) 2017 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

ご希望の鍵の種類を選択してください:
   (1) RSA と RSA (デフォルト)
   (2) DSA と Elgamal
   (3) DSA (署名のみ)
   (4) RSA (署名のみ)
あなたの選択は?
=> そのままEnter(RSA と RSA (デフォルト))

RSA 鍵は 1024 から 4096 ビットの長さで可能です。
鍵長は? (3072) 
=> 4096

鍵の有効期限を指定してください。
         0 = 鍵は無期限
      <n>  = 鍵は n 日間で期限切れ
      <n>w = 鍵は n 週間で期限切れ
      <n>m = 鍵は n か月間で期限切れ
      <n>y = 鍵は n 年間で期限切れ
鍵の有効期間は? (0)
=> 設定したい有効期限を入力、"1y"1年になる

GnuPGはあなたの鍵を識別するためにユーザIDを構成する必要があります。

本名: 【Githubアカウント名:moritamori】
電子メール・アドレス: 【Githubに登録しているメールアドレス】
コメント:【なし】

完了するので gpg で作成した private key リストを確認。

$ gpg --list-secret-keys --keyid-format LONG
uid                 [  究極  ] moritamori <xxxxxxxxxxxxxxxxxx@gmail.com>
ssb   rsa4096/<GPG-ID> 2021-02-11 [E]

確認した<GPG-ID>の箇所を控えて、以下を実行し、クリップボードに値をコピー。

$ gpg --armor --export <GPG-ID> | xsel --clipboard --input

xselmacpbcopy のようにパイプで渡される出力をクリップボードにコピーできるアプリ。

もし、インストールしていなければ入れておきます。 sudo apt-get install xsel

Githubに貼り付ける前にクリップボードの値が以下のようなテキストになっていることを確認。

-----BEGIN PGP PUBLIC KEY BLOCK-----
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
-----END PGP PUBLIC KEY BLOCK-----

GPG キーをGithubに登録

Githubの以下のドキュメントに従ってGPG キーを登録します。 docs.github.com

f:id:moritamorie:20210211180930p:plain

f:id:moritamorie:20210211181150p:plain

GPG署名を有効化

ローカル環境で、上記で生成した<GPG-ID>をgit configで設定。

$ git config --global commit.gpgsign true
$ git config --global user.signingkey <GPG-ID>

以降は、 git commit すれば自動的にコミットが著名されます。

GPG鍵を削除する時

gpg --list-secret-keys でuidを確認します。

$ gpg --list-secret-keys --keyid-format LONG
uid                 [  究極  ] moritamori <xxxxxxxxxxx@gmail.com>
ssb   rsa4096/xxxxxxxxxxxxxxx 2021-02-11 [E]

gpg --delete-secret-keysで削除

$ gpg --delete-secret-keys moritamori

参考資料

【Golang】cobraで作ったコマンドラインツール(CLI)にフラグを追加する (pflag)

cobra の中のフラグの実装はspf13/pflagという cobra と同じ方が作られている別のライブラリが使われています。このライブラリは標準の flag パッケージと似ているので、使ったことある方は使用感に違和感は感じないかと思います。

以下の記事で作った簡単なコマンドラインツールを元にフラグを追加していきます。 simple-minds-think-alike.hatenablog.com

前提

以下のバージョンで確認しています。

フラグの分類

フラグは以下の2つに分類されます。

  • ローカルフラグ
    • 概要:特定のコマンドだけに反映されます。ルートコマンドに追加したフラグならルートコマンドにのみ、サブコマンドに追加された場合はサブコマンドのみ反映される。
    • コード例: rootCmd.Flags().StringP("name", "n", "", "Your name")
    • コード例の挙動: フラグname がルートコマンドに追加される。
    • cobra内部の動き: Flags()を介してフラグを追加すると lflags にフラグが追加される
  • 永続的フラグ
    • 概要:ルートコマンドとサブコマンドの両方に反映されます。
    • コード例: rootCmd.PersistentFlags().StringP("name", "n", "", "Your name")
    • コード例の挙動: フラグname がルートコマンドとサブコマンドの両方に追加される。
    • cobra内部の動き: PersistentFlags()を介してフラグを追加すると pflags にフラグが追加される

今回は、ローカルフラグを使ってルートコマンドに--nameという文字列型のフラグを追加してみます。

フラグの追加

ルートコマンドcmd/root.goの2箇所に処理を追加します。

  • CLIを初期化する処理(init()内)でフラグを定義
  • CLIを実際の処理 (Run()関数内) でフラグを参照
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "os"
)

var rootCmd = &cobra.Command{
    Use:   "helloWorldApp",
    Short: "A brief description of your application",
    Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application.`,
    Run: func(cmd *cobra.Command, args []string) { 
        // ②CLIを実際の処理 (Run()関数内) でフラグを参照
        // nameフラグが渡されていればそれを表示
        name, _ := cmd.Flags().GetString("name")
        if name != "" {
            fmt.Printf("hello world! - %s san!\n", name)
        } else {
            fmt.Println("hello world!")
        }
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

func init() {
    // ①CLIを初期化する処理(init()内)でフラグを定義
    // 第1引数: フラグ名、第2引数: 省略したフラグ名
    // 第3引数: デフォルト値、第4引数: フラグの説明 
    rootCmd.Flags().StringP("name", "n", "", "Your name")
}

実行

追加したフラグを指定する場合と指定しない両方の場合で、コマンドを実行してみます。

# フラグを指定しないで実行した場合
$ go run main.go
hello world!

# フラグを指定した場合
$ go run main.go --name pikotaro
hello world! - pikotaro san!

# 省略した名前でフラグを指定した場合
$ go run main.go -n pikotaro
hello world! - pikotaro san!

nameフラグを指定した場合のみ名前が出力されていて、フラグの有無でプログラムの挙動が変わることを確認できました。

ヘルプを確認

また、ヘルプを表示すると Flags の箇所に追加したフラグに関する記述が追加される事も確認できます。

$ go run main.go -h

Usage:
  helloWorldApp [flags]
  helloWorldApp [command]

Available Commands:
  calc        A brief description of your command
  help        Help about any command

Flags:
  -h, --help          help for helloWorldApp
  -n, --name string   Your name

また、ルートコマンドにローカルフラグを追加したため、 calc サブコマンドにはフラグは追加されていません。

$ go run main.go calc -h

Usage:
  helloWorldApp calc [flags]

Flags:
  -h, --help   help for calc

別の方法でフラグを追加する

Command.StringP() 関数で、文字列のフラグを追加できましたが、他にもフラグを追加する方法があります。

省略したフラグ名なしでフラグを追加

Command.String() 関数を使うと省略したフラグ名なしでフラグを追加できます。

// 第1引数: フラグ名、第2引数: デフォルト値、第3引数: フラグの説明 
cmd.Flags().String("name", "", "Your name")

ヘルプを表示すると以下のように表示され、省略した名前ではフラグを指定できなくなっています。

Flags:
  -h, --help          help for helloWorldApp
      --name string   Your name

変数を指定してフラグを追加

Command.StringVarP()Command.StringVar() 関数を指定すると変数を指定してフラグを追加できます。

変数を指定することで、 cmd.Flags().GetString("name")のようにフラグの値を取得するための処理を書かなくても、値を参照できます。

StringVarPは省略したフラグ名を指定する関数、 StringVarは省略したフラグ名を指定しない関数です。

// 文字列型の変数を定義
var name string

// 第1引数: 変数ポインタ、第2引数: フラグ名、、第3引数: 省略したフラグ名
// 第4引数: デフォルト値、第5引数: フラグの説明 
cmd.Flags().StringVarP(&name, "name", "n", "", "Your name")

// 第1引数: 変数ポインタ、第2引数: フラグ名
// 第3引数: デフォルト値、第4引数: フラグの説明 
cmd.Flags().StringVar(&name, "name", "", "Your name")

// フラグnameの値を標準出力
fmt.Println(name)

図にまとめてみると

それぞれの関数は以下のような位置づけになります。

f:id:moritamorie:20210207182140p:plain

別の種類のフラグを追加する

ここまでが文字列型のフラグの追加方法の共有ですが、同様の方法で他の型のフラグを追加できます。BoolInt のような基本的な型の他にCountIPDuration といった様々な型のフラグを追加可能です。

f:id:moritamorie:20210207182330p:plain

参考資料

【Golang】cobraでコマンドラインツール(CLI)を作る

Go言語でコマンドラインツール(CLI)を作る際cobraというライブラリを使うと、ヘルプや設定ファイルの読み込みなど一般的な機能を持ったCLIを簡単に作れるようになっていて便利です。

といった様々なツールがcobraを使って作られています。

cobra.dev

cobraにはコマンドラインジェネレーターというのがあり、CLIのボイラープレートを生成することで比較的簡単にCLIを作れるようになっているので、その実装方法を紹介してみたいと思います。

前提

以下のバージョンで確認しています。

インストール

まず、cobraのジェネレーターをインストールします。

$ go get github.com/spf13/cobra/cobra

このコマンドラインジェネレーターは initadd という2つのサブコマンドがあります。

  • cobra init: 空のボイラープレートを生成する
  • cobra add: サブコマンドを追加

順番に実行してみていきます。

CLIのボイラープレートを生成する

設定ファイル使わないシンプルなCLIを作りたいので、 --viper=false を指定して cobra init でボイラープレートを生成します。

$ mkdir -p helloWorldApp && cd helloWorldApp
$ cobra init --pkg-name github.com/moritamori/helloWorldApp --viper=false
Your Cobra application is ready at
/home/takashi/go/src/github.com/helloWorldApp

tree でファイル構成を見てみると以下のようになっています。

$ tree
.
├── LICENSE
├── cmd
│   └── root.go
└── main.go

main()関数があるmain.go、ルートコマンドの実装cmd/root.go の2つが生成されています。

それぞれのコードを見ていきます。載せるコード量が多くなってしまうので、コメントの部分を除いています。

main.goを開いてみる

main()関数の中から単純にcmdパッケージの中の Execute() 関数を実行しているだけです。 Excute() 関数はルートコマンドにのみあります。

package main

import "github.com/moritamori/helloWorldApp/cmd"

func main() {
       cmd.Execute()
}

cmd/root.goを開いてみる

ルートコマンドのファイルです。基本的にジェネレータのボイラープレートのままですが、コード内にあるcobra.CommandRun の処理が動くようにコメントアウトを外して、 Hello world! という文字列の標準出力を追加しています。

実際には、このcmdパッケージの中にコマンドラインの処理を書いていきます。

package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "os"
)

var rootCmd = &cobra.Command{
    Use:   "helloWorldApp",
    Short: "A brief description of your application",
    Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    Run: func(cmd *cobra.Command, args []string) { 
        fmt.Println("hello world!")
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

func init() {
    rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

実行してみる

実行するとHello world!が表示されます。

$ go run main.go
hello world!

ちなみに、Run の部分のコメントを外さずに以下のようにして再度実行すると、 Long に登録しているHelpのメッセージが標準出力に表示されます。

var rootCmd = &cobra.Command{
    Use:   "helloWorldApp",
    Short: "A brief description of your application",
    Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    // Run: func(cmd *cobra.Command, args []string) { },
}
$ go run main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

この挙動は以下のロジックの中で決まっていて

ので、Runが定義されていない状態だと、ヘルプのメッセージが表示されるという動きになります。

サブコマンドを追加

次にcalkコマンドを追加してみます。

$ cobra add calc

再度、treeを実行しファイル構成を見てみると、新しく cmd/calc.go が追加されていることが分かります。

$ tree
.
├── LICENSE
├── cmd
│   ├── calc.go
│   └── root.go
└── main.go

cmd/calc.goを開いてみる

生成されたファイルを見てみます。

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var calcCmd = &cobra.Command{
    Use:   "calc",
    Short: "A brief description of your command",
    Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("calc called")
    },
}

func init() {
    rootCmd.AddCommand(calcCmd)
}

ルートコマンドのサブコマンドの違いは

  • Excute()関数
    • ルートコマンドだけにある
  • init()関数
    • ルートコマンド
      • フラグの設定
    • サブコマンド
      • ルートコマンドにサブコマンドを追加

という2点です。

では、サブコマンドを実行してみます。

$ go run main.go calc
calc called

サブコマンドのRunが実行されたことが分かります。

バイナリを作って実行するには

プロジェクトのディレクトリでgo install するとソースコードをビルドし、バイナリが $GOPATH/bin に出来ます。

$ go install

$GOPATH/bin にパスが通っていることを確認し、バイナリを実行するとgo run で実行した時と同じ実行結果になることを確認できます。

$ helloWorldApp
hello world!
$ helloWorldApp calc
calc called

$ which helloWorldApp
/home/takashi/go/bin/helloWorldApp

cobra initのフラグをファイルで指定

上記のサンプルで cobra init 実行時に --viper=false を指定することで、設定ファイル関連の処理がルートコマンドに入らないようにしました。

などは、毎回フラグを付けるのは煩雑なので .cobra.yaml ファイルを用意すると便利です。

デフォルトでは、ホームディレクトリ( $HOME/.cobra.yaml )に配置すると自動的に読み込まれるようになっています。

author: Takashi Morita
year: 2021
license:
  header: This file is part of CLI application foo.
  text: |
    {{ .copyright }}

    This is my license. There are many like it, but this one is mine.
    My license is my best friend. It is my life. I must master it as I must
    master my life.
useViper: false

Githubでコード管理したい場合など、他のディレクトリに配置したい場合もあるかと思います。 --config フラグを指定することで、ホームディレクトリ以外の場所に .cobra.yaml を設置できます。

$ cobra init --pkg-name github.com/moritamori/helloWorldApp2 --config ./.cobra.yaml

コマンドライン引数を追加するには

cobra.CommandArgs を指定すると引数を渡せるようになります。詳しくは公式のREADMEをご参照ください。

var rootCmd = &cobra.Command{
    〜〜〜
    // 最低1つのコマンドライン引数を受け取る
    Args: cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println(args[0])
    },
}
$ go run main.go arg_string
arg_string

フラグを追加するには

別の記事にまとめてみたので、よろしければこちらもご参照ください。

simple-minds-think-alike.hatenablog.com

参考資料

【Rails】sqlcommenterのデモアプリを試してみた

先日2021/1/29にGoogle Cloudのブログでリリースが発表されたSqlcommenterのRuby on Railsデモアプリを触ってみたので、試した手順と感想を書いてみたいと思います。

cloud.google.com

sqlcommenterとは

sqlcommenter の文章を引用すると

sqlcommenter is a suite of middlewares/plugins that enable your ORMs to augment SQL statements before execution, with comments containing information about the code that caused its execution. This helps in easily correlating slow performance with source code and giving insights into backend database performance.

sqlcommenterはミドルウェア/プラグインのスイートで、ORMがSQL文を実行する前に、実行の原因となったコードに関する情報を含むコメントを追加することを可能にします。これにより、遅いパフォーマンスをソースコードと容易に関連付けることができ、バックエンドのデータベースのパフォーマンスについての洞察を得ることができます。

というプロジェクトのようです。

Railsアプリケーションの場合、ActiveRecord::Modelを使ってSQLクエリを実行された際、アプリケーションログからSQLクエリ(SELECT文等)を確認することはできますが、それがどこのコントローラのどこのアクションのコードから実行されたか等の情報は分かりませんでした。 sqlcommenter を使用することでSQLクエリにコメントとして情報を付加することができます。

Rails以外にも、djangoやFlask、Spring等様々な言語のフレームワークに対応しています。(参考)

sqlcommenterがサポートするDB

FAQページによると、2021年2月4日時点でサポート対象のデータベースは

の5つのようです。

デモアプリの概要

デモアプリは以下に公開されています。 github.com

これはRuby on RailsAPIアプリケーションで、以下のAPIが2つあるだけのシンプルなアプリケーションです。

  • GET /posts
    • postsテーブルの一覧を返す
  • POST /posts
    • postsテーブルにレコードを追加する

データベースは sqlite3 を使用していて、1つのテーブルのみ( posts テーブル)使用します。

コントローラとroutesは以下のようになっています。

class PostsController < ApplicationController
  def index
    render json: Post.all
  end

  def create
    title = params[:title].to_s.strip
    head :bad_request if title.empty?
    render json: Post.create!(title: title)
  end
end
Rails.application.routes.draw do
 resources :posts, only: %i[index create]
end

セットアップ

以下のデモプロジェクトのREADMEに記載されている手順を実行しています。

まずは、sqlcommenter をgit cloneし、デモアプリに移動します。

$ git clone git@github.com:google/sqlcommenter.git
$ cd sqlcommenter/
$ cd ruby/sqlcommenter-ruby/sqlcommenter_rails_demo/

次にmarginalia をgit cloneし、ローカルでformatting ブランチをチェックアウトします。

$ git clone git@github.com:glebm/marginalia.git ../marginalia
$ git -C ../marginalia checkout formatting

READMEを読むと、marginalia は以下のようにSQLクエリにコメントを追加できるGemのようです。また marginaliabasecamp のプロジェクトですが、未マージのPRがマージされるまでは上記の fork したコードを使用する必要があるようです。

Account Load (0.3ms)  SELECT `accounts`.* FROM `accounts` 
WHERE `accounts`.`queenbee_id` = 1234567890 
LIMIT 1 
/*application:BCX,controller:project_imports,action:show*/

デモアプリでは使用するgemはGemfileに記載されています。どうやら現時点(2021/2/4時点)では rubygemsにリリースされていない ようなので、以下のようにソースコードのパスを指定するようです。

gem 'sqlcommenter_rails', path: '../sqlcommenter_rails'
gem 'marginalia', path: '../marginalia'
gem 'marginalia-opencensus', path: '../marginalia-opencensus'

bin/setupを実行し、Gemのインストールとテーブル作成をします。

$ bin/setup
== Installing dependencies ==
〜〜〜
The Gemfiles dependencies are satisfied

== Preparing database ==
== 20190608153219 CreatePosts: migrating ===============
-- create_table(:posts)
   -> 0.0021s
== 20190608153219 CreatePosts: migrated (0.0022s) =======


== Removing old logs and tempfiles ==

== Restarting application server ==

アプリケーションの起動

rails serverを起動してみます。

$ bin/rails s

デモアプリには以下の2つのエンドポイントがあるようなので、それぞれcurlでリクエストを送ってみます。

  • GET /posts
  • POST /posts

GET /posts

curl/posts にGETリクエストを送ってみます。

ログに出力されるSQLクエリに、コメントで情報が付加されどのコントローラー、アクションで実行されているか把握できます。

$ curl localhost:3000/posts

Post Load (0.3ms)  SELECT "posts".* FROM "posts"
/*action='index' application='SqlcommenterRailsDemo', controller='posts', db_driver='ActiveRecord::ConnectionAdapters::SQLite3Adapter', framework='rails_v6.0.3.4', route='/posts', traceparent='00-5fc1dcbb8e80456572df7bfc5ef35ad5-a0b5c78c11c98239-01'*/

POST /posts

$ curl -X POST localhost:3000/posts -d 'title=my-post'

INSERT INTO "posts" ("title", "created_at", "updated_at") VALUES (?, ?, ?)
/*action='create', application='SqlcommenterRailsDemo', controller='posts', db_driver='ActiveRecord::ConnectionAdapters::SQLite3Adapter', framework='rails_v6.0.3.4', route='/posts' traceparent='00-e89035e34eb8c73d809ef10b89f25eff-7d8ae48f42e75014-01'*/

コメントに追加する情報

sqlcommenterのページによると、デフォルトでコメントに追加される情報は

  • action (アクション名)
  • application (アプリケーション名)
  • controller (コントローラー名)
  • db_driver (DBドライバ)
  • framework (フレームワーク)
  • routes (ルーツ)

の6つですが、イニシャライザーを追加することで情報を変更できます。

# アクション、アプリケーション名、ネームスペース付きコントローラ名、ホスト名、ジョブ名、ファイル及びライン行数を出力
Marginalia::Comment.components = [ :action, :application, :controller_with_namespace, :hostname, :job, :line]

デモアプリのPOST /posts で試した場合、出力結果が以下のように変わります。

INSERT INTO "posts" ("title", "created_at", "updated_at") VALUES (?, ?, ?) 
/*action='create',application='SqlcommenterRailsDemo',controller_with_namespace='PostsController',hostname='takashi',line='/app/controllers/posts_controller.rb:25:in `create\''*/

Opencensusの対応状況

今回試してはいませんが、READMEによるとデモアプリで使われている sqlcommenter_rails Gemは、デフォルトの設定で OpenCensus traceをMarginaliaコメントの最後につけてくれるので、任意のExporterを使って分析バックエンドに連携できます。

具体的に上記のSQLクエリの実行結果では traceparent='00-5fc1dcbb8e80456572df7bfc5ef35ad5-a0b5c78c11c98239-01' の部分が OpenCensus trace に該当しそうです。

RubyにおけるOpencensusの対応状況(2021年2月現在)に関しては、Pre-Alpha ということなので、これからの発展に期待したいです。

使ってみた感想

クエリを実行しているコードの場所を特定するには良さそうと思いました。規模が大きめのプロジェクトに関しては Datadog などが既に導入されていてスロークエリを追える状況になっているかと思いますので、そういったプロジェクトには追加では入れる必要はなさそうな気がします。

また、実際にスロークエリを修正する際には、例えばMySQLの場合、キャッシュがない状態でのパフォーマンスをNO_SQL_CACHEつけたクエリをSQLクライアントから実行してみたり、EXPLAINで実行計画を見てみて、スロークエリの対応を決めるという流れになりそうです。

Railsプロジェクトであれば、bulletを使うことでN+1を回避することができるので、このような他のスロークエリ対策と併せて使うと良さそうです。

参考資料