【Golang】共有された変数を平行的に安全するには(単一のゴルーチンに閉じ込める、相互排他)

プログラミング言語Go」オンライン読書会で学んだ第9章「共有された変数による平行性」に関して共有したいと思います。

並行的に安全(concurrency-safe)とは

プログラミング言語Go」の一部を引用すると

二つ以上のゴルーチンから同期を加えることなく呼び出された場合であっても正しく動作を続けるのであれば、その関数は並行的に安全(concurrency-safe)です。

という記載があります。

例えば

  • 月予算
  • 週予算
  • 今月中に使った金額

という3つの変数を扱うデータ構造を実装することを考えてみます。

このデータ構造は

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

という条件を満たす必要があり、複数のゴルーチンでこのデータ構造に操作を加えてもこれらの条件が必ず満たせれば並行的に安全と言えます。

実装

以下のような実装にしたとすると、これは並行的に安全ではありません。

type CostManager struct {
    MonthlyBudget uint
    WeeklyBudget  uint
    Spent         uint
}

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget // ①
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    return cm.WeeklyBudget <= cm.Spent
}

並行的に安全ではない理由

複数のゴルーチンから同期を加えることなく操作した場合、変数が必要な条件を満たさなくなるので、並行的に安全ではありません。

1つのゴルーチンが SetBudget(週予算と月予算を設定)を実行した時にプログラム中の①の時点まで実行した時に、他のゴルーチンが予算に達したかをチェックする関数を実行した時に必要な機能を満たさなくなります。

具体的には、最初の状態が

  • 週予算: 100
  • 月予算: 300
  • 今月中に使った金額: 300

の時に別々のゴルーチンから

  • SetBudget(400, 1000) (週予算: 400、月予算: 1,000を設定)を実行し、プログラム中の①の時点(週予算を設定)まで実行
  • 予算に達したかチェックする関数を実行

すると以下の図の状態になり、「今月中に使った金額が月予算に達していれば、週予算も達している」という要件を満たしていません。

f:id:moritamorie:20210312233329p:plain

対処方法

この問題に対処する方法を2つあります。

  • 変数を単一のゴルーチンに閉じ込める
  • 相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

変数を単一のゴルーチンに閉じ込める

1つ目の方法は、以下のように変数を特定のゴルーチンからしかアクセスさせなくすることで、安全にするというシンプルな方法です。

func SomeFunc() {
    // 月予算: 1,000、週予算: 100、今月使った金額: 0で初期化
    cm := CostManager{
        MonthlyBudget: 1000,
        WeeklyBudget:  100,
        Spent:         0,
    }
    // 変数 cm はこのゴルーチン内からしかアクセスさせない。
}

func main() {
    go SomeFunc()
}

また、類似の方法として、単一のゴルーチンの代わりに、パイプラインの中にアクセスする変数を閉じ込めるという方法に関しても言及されていました。パイプラインは以下のGo blogの記事で紹介されている手法です。

相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

もう1つの方法は、相互排他を用いて不変式を維持する方法です。

不変式とは

不変式とは、常に真である条件または関係です。

上記のデータ構造が持つ特有の不変式としては

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

があります。

相互排他とは

不変式を維持する方法として、相互排他( sync.Mutex: ミューテックス )が提供されています。

プログラミング言語Goの一部を引用すると

ミューテックスの目的は、共有された変数のある種の不変式がプログラム実行中の重要な時点で維持されるのを保証することです。

という記載があります。

ミューテックスロックの使い方と効果

ミューテックスのロックを使うことによって、以下のように不変式を維持することができます。

var mu sync.Mutex

func SomeFunc() {
    # ①不変式が維持された状態
    mu.Lock()
    # ②一時的に不変式が破られた状態
    mu.Unlock()
    # ③不変式が維持された状態
}

複数のゴルーチンからデータ構造にアクセスできなくなり、ロックする時・ロックを解除した時に不変式が維持された状態を保証することができます。

月予算・週予算サンプルの例

最初の週予算と月予算のプログラムにミューテックスを導入するには、それぞれの関数の最初に

mu.Lock()
defer mu.Unlock()

を追加します。既にロックを獲得しているゴルーチンが存在する場合、他のゴルーチンはロックの解除待ちになります。

var mu sync.Mutex

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    mu.Lock()
    defer mu.Unlock()
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    mu.Lock()
    defer mu.Unlock()
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.WeeklyBudget <= cm.Spent
}

ミューテーションロックが再入可能(re-retrant)ではない

再入可能(re-retrant)とは以下のようにmu.Lock()、mu.Unlock()入れ子の状態にできることを指しますが、実行時にエラー(fatal error: all goroutines are asleep - deadlock)になります。

mu.Lock()
// 処理①
mu.Lock()
// 処理②
mu.Unlock()
// 処理③
mu.Unlock()

プログラミング言語Goの一部を引用すると

再入可能なミューテックスは他のゴルーチンが共有された変数へアクセスしないことを保証するでしょうが、それらの変数の追加の不変式を保護することはできません。

という記載があり、このような背景から明示的に再入できない仕様にしているものと考えられます。

参考資料

【Golang】apitest でEchoを使ったREST APIのテストを書く

apitestとは

GoのAPIテスティング用のライブラリの1つです。 github.com

apitestは

という特徴があり、個人的に好きでAPIのテスト書くのに使っています。

難点としては

  • websocket のテストは書けない(2021年3月時点)
    • その場合 httpexpect 等他のライブラリが候補にあがりそう。
  • サードパーティのライブラリのため都度バージョンアップが必要
    • 標準パッケージだけで賄いたい場合はnet/http/httptest使うのが良さそう

というところかと思います。

apitest の紹介のためにサンプルコードを書いてみました。どのようにテストを書けるのか紹介していきたいと思います。

サンプルコード

作ったサンプルはEchoで作った以下のREST APIを持つアプリケーションです

  • 書籍一覧 (GetIndex - パス: /books、メソッド: GET)
  • 書籍詳細 (GetDetail - パス: /books/1、メソッド: GET)
  • 書籍登録 (Post - パス: /books/、メソッド: POST)
  • 書籍登録 (Put - パス: /books/1、メソッド: PUT)

このEchoのサンプルは、apitest の以下のexamplesを元に作りました。

コードのリンク

コードの全ては紹介しきれないので、この記事中にはAPIの実装コード・テストコードの一部を載せてます。コード全体をみたい方はリンクを載せておきますので、よろしければご参照ください。

書籍一覧 (GetIndex)

一覧のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindAll()で書籍の一覧を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した一覧を返す

という実装です。

func (bh *BookHandler) GetIndex(c echo.Context) error {
    bks, err := bh.bookRepo.FindAll()

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    rl := &resultLists{Books: bks}
    return c.JSON(http.StatusOK, rl)
}
テストコード

bh.bookRepo.FindAll()で取得する書籍一覧をスタブにしています。 apitestを使ったテストコードでは、パス/booksにGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindAll() ([]model.Book, error) {
    bks := []model.Book{}
    t, _ := time.Parse("2006-01-02", "2021-01-01")

    bk1 := model.Book{Title: "Go言語の本", Author: "誰か"}
    bk1.ID = 1
    bk1.CreatedAt = t
    bk1.UpdatedAt = t
    bks = append(bks, bk1)

    b2 := model.Book{Title: "Go言語の本2", Author: "誰か2"}
    b2.ID = 2
    b2.CreatedAt = t
    b2.UpdatedAt = t
    bks = append(bks, b2)

    return bks, nil
}

func TestGetIndex(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books", h.GetIndex)

    apitest.New().
        Handler(e).
        Get("/books").
        Expect(t).
        Body(`
          {
              "Books": [
                  {
                      "ID": 1,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本",
                      "Author": "誰か"
                  },
                  {
                      "ID": 2,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本2",
                      "Author": "誰か2"
                  }
              ]
          }
      `).
        Status(http.StatusOK).
        End()
}

Handler()には、http.Handlerのインタフェース実装を渡します。

書籍詳細 (GetDetail)

詳細情報のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindByID()で書籍の詳細情報を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した詳細情報を返す

という実装です。

func (bh *BookHandler) GetDetail(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, err := bh.bookRepo.FindByID(id)

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.FindByID()で取得する書籍詳細をスタブにしています。 apitestを使ったテストコードでは、パス/books/1にGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindByID(id uint64) (model.Book, error) {
    t, _ := time.Parse("2006-01-02", "2021-01-01")
    b := model.Book{Title: "Go言語の本", Author: "誰か"}
    b.ID = 1
    b.CreatedAt = t
    b.UpdatedAt = t
    return b, nil
}

func TestGetDetail(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books/:id", h.GetDetail)

    apitest.New().
        Handler(e).
        Get("/books/1").
        Expect(t).
        Body(`
          {
              "ID": 1,
              "CreatedAt": "2021-01-01T00:00:00Z",
              "UpdatedAt": "2021-01-01T00:00:00Z",
              "DeletedAt": null,
              "Title": "Go言語の本",
              "Author": "誰か"
          }
      `).
        Status(http.StatusOK).
        End()
}

書籍登録 (Post)

登録のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Create でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した登録した書籍情報を返す

という実装です。

func (bh *BookHandler) Post(c echo.Context) error {
    t := c.FormValue("title")
    a := c.FormValue("author")
    b := model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Create(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Create(b *model.Book) error {
    return nil
}

func TestPost(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.POST("/books", h.Post)

    // 正常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "新規書籍名").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

書籍更新 (Put)

更新のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Save でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した更新した書籍情報を返す

という実装です。

func (bh *BookHandler) Put(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, _ := bh.bookRepo.FindByID(id)

    t := c.FormValue("title")
    a := c.FormValue("author")
    b = model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Save(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Save(b *model.Book) error {
    return nil
}

func TestPut(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.PUT("/books/:id", h.Put)

    // 正常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

デバッグの仕方

Debugを使うとHTTPの内容がログ出力され分かりやすくなり、テストの修正に役立てます。

例えば、上記の TestPut のテストコードを以下のように変更し Fail するようにし、併せて Debug を追加します。

   // 正常系
    apitest.New().
        Debug(). ←追加
        Handler(e).
        Put("/books/"). ←ここを存在しないパスに変更
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

テストを実行すると、以下のように

  • Http requestのinbound内容
  • 最終的なHttp Responseの内容

がコンソールに出力され、テストが Fail した原因を見つけやすくなります。

----------> inbound http request
PUT /books/ HTTP/1.1
Host: sut
Content-Type: application/x-www-form-urlencoded

author=%E6%9B%B4%E6%96%B0%E5%BE%8C%E8%91%97%E8%80%85&title=%E6%9B%B4%E6%96%B0%E5%BE%8C%E6%9B%B8%E7%B1%8D%E5%90%8D

<---------- final response
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler
HTTP/1.1 404 Not Found
Connection: close
Content-Type: application/json; charset=UTF-8

{"message":"Not Found"}

Duration: 173.685µs

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:153
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.092s
FAIL

Debugがないと以下のようなシンプルな出力内容で、200が返ることを期待しているが実際には404が返っていることが分かるくらいで情報が少ないです。

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:152
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.077s
FAIL

便利な機能

JSON path

APIから返ってきたJSON全体をテストするサンプルを書きましたが、JSON pathを使うと部分的にテストする際に便利です。

カスタムマッチャ

レスポンスを検証する対象としてBodyCokkieHeader等がありますが、独自のマッチャを作って検証することもできます。

インタセプター

HTTPリクエストを送る前にインタセプターを挟んで、http.Request を元に送る内容を加工できます。

参考記事

上記のAPIのサンプルは各エントリポイントでGormを使ったDBアクセスをしています。以下の記事では go-sqlmock を使ったGorm アプリケーションのテスト方法も書いてますので、もしご興味があれば参考にしてみてください!

simple-minds-think-alike.hatenablog.com

サンプルコード

github.com

参考資料

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces が発生した時の対処

先日、 mysqldumpAWS RDSインスタンス(aurora)に繋いでデータをdumpしようとしたら、見たことがないErrorが発生していたので調べました。

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces

原因

調べてみると、MySQLバージョンのアップグレードの影響で tablespace の情報にアクセスするには PROCESS 権限が必要になったことが原因であるということがわかりました。

dev.mysql.com

対応方法

tablespaceの情報は不要だったので、MySQL公式のページに書いてあった通り--no-tablespacesを付けてmysqldumpを実行したら、エラーが発生しなくなりました。

他の方法としては、dumpするユーザにPROCESS権限をつけて対処することもでき、tablespace を含めてdumpしたい場合には権限をつけると良さそうです。

エラー発生の状況

dumpの方法

以下のようにしてdumpしていました。

$ mysqldump -u [DBユーザー] -p[DBパスワード] -h [DBホスト] [データベース名] > backup.sql

エンジン

以下のMySQL 5.6互換のAuroraでエラーが発生していました。エンジンは 5.6.mysql_aurora.1.23.1docs.aws.amazon.com

参考資料

【Golang】Github actionsでカバレッジを取得しCodecovにアップロードする

Go言語で作ったアプリケーションのGithubリポジトリGithub actionsワークフローを設定し、 codecovカバレッジを送る設定方法を紹介したいと思います。

codecov は、テストのコードカバレッジを取得してくれるツールです。テストスイートを実行した時にソースコードの実行箇所を視覚的に示してくれて、どこに新しいテストを書くべきか分かりやすくなります。

f:id:moritamorie:20210228015138p:plain

  • : テストスイートによってソースコード実行されている箇所
  • : テストスイートによってソースコード部分的に実行されている箇所(具体的には、真偽値が返るところで、 truefalse のどちらかしか返っていない箇所)
  • : テストスイートによってソースコード実行されていない箇所

忙しい時はテストコードを書くのが手抜きになったりするのですが、 codecov を使っているとカバレッジ定量的・視覚的に表現されることによって、状況を把握しやすくなり自然とテストコード追加しようという気持ちになると感じていて、自戒の意味を込めて codecov を設定しています。

coverall と codecovの比較

類似のツールに coveralls がありますが、以下の観点で codecov を使うようにしています。

前提

  • codecovでアカウントを作成済

Codecovにカバレッジを送信し、視覚的に見れるように

Codecovにリポジトリを追加

Codecov にログインして、Repositoriesのタブから「Add New repository」のボタンを押すと、以下のようにGithub上のリポジトリが表示されるので、対象のリポジトリを選択します。 f:id:moritamorie:20210227221937p:plain

プライベートリポジトリの場合は、Settingの画面でActivateします。フリープランの場合、アクティベートできるリポジトリ数は1つのみです。 f:id:moritamorie:20210228013431p:plain

再度、Overviewを開くと、codecovトークンが表示されるので、コピーします。 f:id:moritamorie:20210227222000p:plain

GithubリポジトリでSecretsに追加

Github で対象のリポジトリのSettingsタブ→Secretsと選択し、「New repository secret」ボタンを押してNameに「CODECOV_TOKEN」、Valueにコピーしたトークンを入れて保存します。 f:id:moritamorie:20210227222651p:plain

Github actionsでGoのテストカバレッジをcodecovに送信

以下の yaml ファイルをプロジェクトに追加し、Github リポジトリにpushすると(またはPull Request作成時)、Github actionワークフローが実行されます。

Github actionワークフローの処理の中で、Githubアクションcodecov/codecov-action がSecretsに設定したトークンを使ってコードカバレッジCodecov に送ってくれます。

on: [push, pull_request]
name: Test
jobs:
  test:
    strategy:
      matrix:
        go-version: [1.15.x]
        os: [ubuntu-latest, macos-latest, windows-latest]

    runs-on: ${{ matrix.os }}
    steps:
    - name: Install Go
      uses: actions/setup-go@v2
      with:
        go-version: ${{ matrix.go-version }}

    - name: Checkout code
      uses: actions/checkout@v2

    - name: Test
      run: go test -race -coverprofile="coverage.txt" -covermode=atomic ./...

    - name: upload coverage
      uses: codecov/codecov-action@v1
      with:
        token: ${{ secrets.CODECOV_TOKEN }}

このGithubアクションは https://codecov.io/bashを実行し、ファイルをcodecovに送ってくれます。(参照したコード

この実行される bash の中で、カバレッジレポートだと思われるファイルを探して、マッチしたファイルを対象としてくれます。上記の yaml ファイルでは"coverage.txt"がレポートのファイルです。

Github actionsワークフロー実行後の確認

実行後、Codecovに戻ると以下のように、ソースコード上でテストスイートが実行された箇所が視覚的に見えるようになったり、ファイル毎・プロジェクト全体のカバレッジ等を把握できるようになります。 f:id:moritamorie:20210227230257p:plain

f:id:moritamorie:20210228154357p:plain

便利な機能

Codecov には、よりプロジェクトのカバレッジ状況を把握しやすくしたり、コントロールする機能があります。

Pull RequestにCodecovのレポートをコメントされるように

Codecovは、Github integration機能も提供していて、インストールするとPull Requestに自動的にレポートをコメントしてくれるようになります。

f:id:moritamorie:20210228152047p:plain

f:id:moritamorie:20210228151925p:plain

GithubリポジトリのREADMEにCoverageのバッヂを追加

再度 Codecov に戻り、以下のSettingページを参照し、Badgeのマークダウンをコピーします。

f:id:moritamorie:20210227231033p:plain

README.mdに貼り付け、Githubリポジトリを開くと、以下ののようにカバレッジが表示されたバッヂが付き、クリックするとCodecov の該当のプロジェクトのURLに遷移します。

f:id:moritamorie:20210227231419p:plain

Github上でPRを作った際

Github上でPRを作った際に、一定以上のカバレッジを満たさないといけないという制約を付けたい場合はcodecov.ymlを設定することによって実現可能です。

制約の例としては

などです。

DocsのCommon Configurationsを参照すると、実現したい設定と近いものが見つかるかもしれません。 docs.codecov.io

codecov.ymlの設定は、チーム単位/リポジトリ単位にそれぞれ柔軟に設定できます。

サンプルコード

github.com

参考資料

【Golang】go-sqlmock でGorm を使ったアプリケーションのテストを書く

go-sqlmock を使ったことがなかったので、Gormを使ってデータベースにアクセスするアプリケーションをテストするコードを書いてみました。

go-sqlmock の READMEに

sqlmock is a mock library implementing sql/driver. Which has one and only purpose - to simulate any sql driver behavior in tests, without needing a real database connection. sqlmockはsql/driverを実装したモックライブラリです。目的は一つだけで、実際のデータベース接続を必要とせずに、テストで任意のsqlドライバの動作をシミュレートすることです。

という記載があります。なので、Go標準のdatabase/sql パッケージでSQLクエリを発行する場合でも、Gorm のような ORM フレームワークを使った場合でもDBを使わずにテストも行うことができます。

前提

ディレクトリ/ファイル構成と概要

以下のシンプルな構成のサンプルアプリケーションにテストを追加してみます。

.
├── main.go
├── model
│   └── book.go
└── repository
    ├── book.go
    └── book_test.go

それぞれのファイルでやっていることの概要は

  • main.go
    • リポジトリ(repository/book.go)を介して、書籍(book)のデータを登録。
  • model/book.go
    • 書籍(book)を表す構造体を定義。
  • repository/book.go
    • データベースにアクセスし、SQLを発行して書籍(book)のデータを登録。そして、結果を返す。
  • repository/book_test.go
    • 追加するrepository/book.goのテスト。ここでgo-sqlmockを使ってsqlドライバの動作をシミュレート。

です。

サンプルアプリケーションを動かしてみる

main.go

まずは main.go の内容をみてみます。単純に書籍(book)のcreateを行いエラーが発生しないか確認しているだけです。

package main

import (
    "fmt"

    "github.com/moritamori/gorm-testing/model"
    "github.com/moritamori/gorm-testing/repository"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    // DB接続を開く
    url := "dbname=gormtesting password=mypassword"
    db, err := gorm.Open(postgres.Open(url), &gorm.Config{})
    if err != nil {
        panic(err)
    }

    // リポジトリ(`repository/book.go`)を介して、書籍(book)のデータを登録
    bookRepository := repository.BookRepositoryImpl{DB: db}
    book := &model.Book{
        Title:  "Go言語の本",
        Author: "誰か",
    }
    err := bookRepository.Create(book)

    // エラーが発生しないかチェック
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("success!")
}

※ローカルで実行する際にはpostgresqlgormtestingデータベースとbooksテーブルが存在する必要があります。

model, repository (book.go)

書籍の構造体の定義( model )と実際のDBの処理( repository )を別のパッケージに分けたうえで、BookRepository というインタフェースを介して具体的なDBの処理は行う設計にしています。

こうすることで、パッケージの外から BookRepository を介してDB処理をする際、repositoryの中の実装を意識せずに済みます。

package model

import "gorm.io/gorm"

type Book struct {
    gorm.Model
    Title  string
    Author string
}
package repository

import (
    "gorm.io/gorm"
    "github.com/moritamori/gorm-testing/model"
)

type BookRepositoryImpl struct {
    DB *gorm.DB
}

type BookRepository interface {
    Create(book *model.Book) error
}

func (bookRepo BookRepositoryImpl) Create(book *model.Book) error {
    cx := bookRepo.DB.Create(book)
    return cx.Error
}

実行

実行すると、想定通り動いていることを確認できます。

$ go run main.go
success!

テストを追加

サンプルアプリケーションにテストを追加しました。テストのセットアップ、データベース接続のクローズテストを共通化するために testify を使っています。

package repository

import (
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/moritamori/gorm-testing/model"
    "github.com/stretchr/testify/suite"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

// テストスイートの構造体
type BookRepositoryTestSuite struct {
    suite.Suite
    bookRepository BookRepositoryImpl
    mock           sqlmock.Sqlmock
}

// テストのセットアップ
// (sqlmockをNew、Gormで発行されるクエリがモックに送られるように)
func (suite *BookRepositoryTestSuite) SetupTest() {
    db, mock, _ := sqlmock.New()
    suite.mock = mock
    bookRepository := BookRepositoryImpl{}
    bookRepository.DB, _ = gorm.Open(postgres.New(postgres.Config{
        Conn: db,
    }), &gorm.Config{})
    suite.bookRepository = bookRepository
}

// テスト終了時の処理(データベース接続のクローズ)
func (suite *BookRepositoryTestSuite) TearDownTest() {
    db, _ := suite.bookRepository.DB.DB()
    db.Close()
}

// テストスイートの実行
func TestBookRepositoryTestSuite(t *testing.T) {
    suite.Run(t, new(BookRepositoryTestSuite))
}

// Createのテスト
func (suite *BookRepositoryTestSuite) TestCreate() {
    suite.Run("create a book", func() {
        newId := 1
        rows := sqlmock.NewRows([]string{"id"}).AddRow(newId)
        suite.mock.ExpectBegin()
        suite.mock.ExpectQuery(
            regexp.QuoteMeta(
                `INSERT INTO "books" ("created_at",` +
                    `"updated_at","deleted_at","title",` +
                    `"author") VALUES ($1,$2,$3,$4,$5) ` +
                    `RETURNING "id"`),
        ).WillReturnRows(rows)
        suite.mock.ExpectCommit()
        book := &model.Book{
            Title:  "Go言語の本",
            Author: "誰か",
        }
        err := suite.bookRepository.Create(book)

        if err != nil {
            suite.Fail("Error発生")
        }
        if book.ID != uint(newId) {
            suite.Fail("登録されるべきIDと異なっている")
        }
    })
}

testify を使う利点

このように testify を使うことでテストケースが増えた際、共通の関心事であるテストのセットアップ・終了時の処理のコードが重複しないテストコードを書けるというメリットがあります。

Gorm を使う際の考慮

トランザクションの考慮

Gormcreate/update/delete に関してはデフォルトでトランザクションが有効になるため、トランザクションのテスト(suite.mock.ExpectBegin()suite.mock.ExpectCommit)を書いています。

ExpectQueryExpectExec

また、 postgresql と Gormを一緒に使った注意点がこちらの記事に記載があって、通常Insert文は ExpectExec でクエリをチェックしますが postgresqlを使用する場合ExpectQuery でクエリをチェックしています。

In general, a non-query SQL expectation (e.g Insert/Update) should be defined by mock.ExpectExec, but this is a special case. For some reason, GROM uses QueryRow instead of Exec for thepostgres dialect (for more detail, please consult this issue).

Tip: Use mock.ExpectQuery for the GORM model insert if you’re using PostgreSQL.

という記載があり、参照先のGithub issueにも同様のコメントがあります。

Gorm V2におけるInsertの挙動

上記のissueが古く、Gorm v1だけに該当する話の可能性があったので、念の為Gorm v2のソースコードを用いてcreateするメソッド(CreateWithReturning)デバッグ実行して確認したところ、テーブルスキーマにデフォルト値がある場合QueryContextを実行していたので、ExpectQuery でクエリを指定するとマッチしそうです。

また、どのカラムにデフォルト値が付くか確認したところ、booksテーブルのgorm.Model追加されるIDカラムにデフォルト値が付いていたので、gorm.Model を使っていればExpectQuery で確認するのが良さそうです。

sqlmockを使う利点

sqlmockはデフォルトで期待する結果を厳密な順序で得られることを確認してくれるので、このサンプルでは

  • suite.mock.ExpectBegin()
  • suite.mock.ExpectQuery(regexp.QuoteMeta(INSERT INTO "books" 〜))
  • suite.mock.ExpectCommit()

という順番でモックに送られることを期待します。

テスト実行

実行すると、OKが返ります。

$ go test ./... -v
?       github.com/moritamori/gorm-testing  [no test files]
?       github.com/moritamori/gorm-testing/model    [no test files]
=== RUN   TestBookRepositoryTestSuite
=== RUN   TestBookRepositoryTestSuite/TestCreate
=== RUN   TestBookRepositoryTestSuite/TestCreate/create_a_book
--- PASS: TestBookRepositoryTestSuite (0.00s)
    --- PASS: TestBookRepositoryTestSuite/TestCreate (0.00s)
        --- PASS: TestBookRepositoryTestSuite/TestCreate/create_a_book (0.00s)
PASS
ok      github.com/moritamori/gorm-testing/repository   0.079s

今回のサンプルアプリケーション

コードをGithubにあげているので、もしよかったら参考にしてみてください! github.com

参考資料

【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

参考情報