技術メモ

技術メモ

ラフなメモ

private repositoryにあるGo Modulesをgo getする場合の設定

何をしたいか

プライベートリポジトリのモジュールを go get したい

どうするか

パーソナルアクセストークンを発行して、go get の際にトークンを用いてアクセスできるように git の設定をします。

git config --global url."https://${access_token}:x-oauth-basic@github.com/<myorg>/".insteadOf "https://github.com/<myorg>/"

--glocal に設定しないと go get の際に参照ができないため注意が必要です。また設定する際は、複数のユーザーないしはOrganizationのプライベートリポジトリを参照する場合は <myorg> のようにOrganization単位で config しておくのが良いでしょう。上記を実行すると ~/.gitconfig に設定が追加されたことが確認できます。

go-cmpを使う理由とTipsの紹介

はじめに

これは Go 4 Advent Calendar 2020 8日目の記事です。

Goのテストにおいて、構造体を含めて型の値を比較したいという場合は往々にしてあります。ロジックの結果はなんらかの値として作用することが多いですから、型の値を比較したい、というのは自然です。私は型の値が等価であるかどうか判定するために、go-cmp というライブラリを使うことが多いです。

github.com

しかしGoにおける等価性の仕様は決まっていますし、標準ライブラリの reflect パッケージにも DeepEqual という deeply equal, かどうか判定するメソッドがあります。そこで本記事ではなぜわざわざ go-cmp を使っているのかという理由と、go-cmp を使ったときにどのようにして使うか?という go-cmp の使う上でよく使う以下のTipsを提供したいと思います。

go-cmpを使う理由

私は大きく以下の3つの理由から go-cmp を使うケースが多いです。

  • 値にDiffがあった場合に見やすい
  • 比較対象を柔軟に制御しやすい
  • テストの書き方は標準的な書き方によせたい

基本的には go-cmp を用いた比較は reflect.DeepEqual の上記互換と考えています。reflect.DeepEqual の比較方法にふれる前に、以下のような構造体の値を == の等価演算子を用いて比較するテストを考えてみます。

  • main_test.go
package main

import "testing"

type User struct {
    UserID   string
    UserName string
}

func TestTom(t *testing.T) {
    tom := User{
        UserID:   "0001",
        UserName: "Tom",
    }

    tom2 := User{
        UserID:   "0001",
        UserName: "Tom",
    }

    if tom != tom2 {
        t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2)
    }
}
=== RUN   TestTom
--- PASS: TestTom (0.00s)
PASS

https://play.golang.org/p/WIvR4SGFJ87

上記のテストはPASSします。== の等価演算子を用いた比較については Comparison operators に記載があります。つまり構造体の値は、構造体のすべてのフィールドが比較可能(つまり map 型や slice 型, func 型の値を含んでいないということです)の場合に比較できます。構造体のブランクフィールドでないフィールドが等しい場合に等価とみなされます。

slice 型を == で比較するとどうなるでしょうか?構造体の中に slice 型を含んでいる値を == で比較した場合はコンパイルエラーになります。

https://play.golang.org/p/Wn2MMNAxoTT

上記のGo Playgroundの例にあるような slice 型を含む場合や map 型の値、ポインタが参照する値などを比較したい場面は多くあります。その場合に標準ライブラリの reflect.DeepEqual を用いると deeply equal, に判定することができます。slice 型の場合に限れば、スライスの要素の値が deeply equal, であれば等しいとされます。[]string であれば、stringslice の値が要素の位置まで含めて同じ場合に等価と判定でき、多くの場面でこの deeply equal, の等価判定が役に立ちます。

つまり reflect.DeepEqual を使った以下のテストはPASSします。

func TestTom(t *testing.T) {
    tom := User{
        UserID:    "0001",
        UserName:  "Tom",
        Languages: []string{"Java", "Go"},
    }

    tom2 := User{
        UserID:    "0001",
        UserName:  "Tom",
        Languages: []string{"Java", "Go"},
    }

    if !reflect.DeepEqual(tom, tom2) {
        t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2)
    }
}
=== RUN   TestSlice
--- PASS: TestSlice (0.00s)
PASS

https://play.golang.org/p/HnX_HO-s9Y3

さて、go-cmp を使った場合ですが、reflect.DeepEqual を使った場合と同様にテストはPASSします。

func TestTom(t *testing.T) {
    // ...

    if diff := cmp.Diff(tom, tom2); diff != "" {
        t.Errorf("User value is mismatch (-tom +tom2):\n%s", diff)
    }
}
=== RUN   TestTom
--- PASS: TestTom (0.00s)
PASS

https://play.golang.org/p/whGERlULOXt

  • 値にDiffがあった場合に見やすい

go-cmp を使う理由の1つとして値にDiffがあった場合に reflect.DeepEqual よりもDiffがわかりやすい(個人差あり)ということがあります。仮にスライスの値に差があった場合を考えてみます。

  • reflect.DeepEqual の場合
package main

import (
    "reflect"
    "testing"
)

type User struct {
    UserID    string
    UserName  string
    Languages []string
}

func TestTom(t *testing.T) {
    tom := User{
        UserID:    "0001",
        UserName:  "Tom",
        Languages: []string{"Java", "Go"},
    }

    tom2 := User{
        UserID:    "0001",
        UserName:  "Tom",
        Languages: []string{"Ruby", "Go"},
    }

    if !reflect.DeepEqual(tom, tom2) {
        t.Errorf("User tom is mismatch, tom=%v, tom2=%v", tom, tom2)
    }
}

Diffの表示は以下のようになります。つまり開発者が t.Errorf で表示している文字列です。

=== RUN   TestTom
    prog.go:28: User tom is mismatch, tom={0001 Tom [Java Go]}, tom2={0001 Tom [Ruby Go]}
--- FAIL: TestTom (0.00s)
FAIL

https://play.golang.org/p/yEg8hLy_bhY

go-cmp を使った場合は以下のようになります。

package main

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

type User struct {
    UserID    string
    UserName  string
    Languages []string
}

func TestTom(t *testing.T) {
 
    // ...

    if diff := cmp.Diff(tom, tom2); diff != "" {
        t.Errorf("User value is mismatch (-tom +tom2):\n%s", diff)
    }
}

以下のように構造体の値でDiffがあったもののみが -+ で表示されます、Diffがあった値が直感的に分かるようになっています。構造体のフィールドが複雑になるほど、Diffが見やすいことが役に立ちます。

=== RUN   TestTom
    prog.go:29: User value is mismatch (-tom +tom2):
          main.User{
            UserID:   "0001",
            UserName: "Tom",
            Languages: []string{
        -       "Java",
        +       "Ruby",
                "Go",
            },
          }
--- FAIL: TestTom (0.00s)
FAIL

https://play.golang.org/p/58FuMU6IUMm

また nil スライスと空スライスの違いがあった場合に reflect.DeepEqual では異なったものとしてみなされますが、出力する文字列だけではわかりにくい時もあります。

go-cmp だと以下のように出力されるため nil スライスと空スライスで値に差がある場合もわかりやすいです。

        -   Languages: []string{},
        +   Languages: nil,
  • 比較対象を柔軟に制御しやすい

構造体の中で、あるフィールドは比較対象から除きたい場合などがあります。このような場合に go-cmp のオプションを用いることで比較対象から簡単に除くことができます。この方法については以下の go-cmp のTips の項目の中で紹介します。

  • テストの書き方は標準的な書き方によせたい

GoのFAQ(Where is my favorite helper function for testing?)にもあるようにGoはテストのフレームワークを提供していません。とはいえ、一方でやはりチームによっては stretchr/testify などのフレームワークを使ったほうが生産性が上がる、などの意見もあります。どのようなライブラリを使うかはプロジェクトメンバの構成や状況によって変わるので一概に何が良い、と決めることはできません。

それでも私個人としてはテストはなるべく標準的な書き方によせていきたい、と考えています。go-cmpreflect.DeeqEqual の代用程度として使うことができるため、全体的な書き方は標準的な書き方のままで大丈夫、という点も go-cmp を使う理由の一つです。

go-cmpのTips

前置きが長くなりましたが、go-cmp を使ったときのTipsを紹介します。なお go-cmp のバージョンは2020/12/08現在最新の v0.5.4 を使っています。

unexportedなフィールドが存在する場合に比較する

go-cmp のドキュメントにもあるように go-cmpreflect.DeepEqual とは異なり、デフォルトではunexportedなフィールドは比較対象になりません。というかオプションを何も指定しないとドキュメントに記載されているように panic になります。

package main

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

func TestX(t *testing.T) {

    type X struct {
        numUnExport int
        NumExport   int
    }

    num1 := X{100, -1}
    num2 := X{999, -1}

    if diff := cmp.Diff(num1, num2); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}
=== RUN   TestX
--- FAIL: TestX (0.00s)
panic: cannot handle unexported field at {main.X}.numUnExport:
    "main".X
consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported [recovered]
    panic: cannot handle unexported field at {main.X}.numUnExport:
    "main".X
consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported

goroutine 18 [running]:
testing.tRunner.func1.1(0x566380, 0xc00010a2f0)
    /usr/local/go-faketime/src/testing/testing.go:1072 +0x30d
testing.tRunner.func1(0xc000106300)
    /usr/local/go-faketime/src/testing/testing.go:1075 +0x41a
panic(0x566380, 0xc00010a2f0)
    /usr/local/go-faketime/src/runtime/panic.go:969 +0x1b9
github.com/google/go-cmp/cmp.validator.apply(0xc000144280, 0x565c80, 0xc0001001d0, 0xa2, 0x565c80, 0xc0001001e0, 0xa2)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/options.go:244 +0x68f
github.com/google/go-cmp/cmp.(*state).tryOptions(0xc000144280, 0x5cb000, 0x565c80, 0x565c80, 0xc0001001d0, 0xa2, 0x565c80, 0xc0001001e0, 0xa2, 0xca20)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:303 +0x132
github.com/google/go-cmp/cmp.(*state).compareAny(0xc000144280, 0x5c9aa0, 0xc00017c000)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:258 +0x2ef
github.com/google/go-cmp/cmp.(*state).compareStruct(0xc000144280, 0x5cb000, 0x5788a0, 0x5788a0, 0xc0001001d0, 0x99, 0x5788a0, 0xc0001001e0, 0x99)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:427 +0x685
github.com/google/go-cmp/cmp.(*state).compareAny(0xc000144280, 0x5c9920, 0xc000108680)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:286 +0xe35
github.com/google/go-cmp/cmp.Diff(0x5788a0, 0xc0001001d0, 0x5788a0, 0xc0001001e0, 0x0, 0x0, 0x0, 0x4f44e4, 0x65ea30)
    /tmp/gopath381553566/pkg/mod/github.com/google/go-cmp@v0.5.4/cmp/compare.go:119 +0xab
main.TestX(0xc000106300)
    /tmp/sandbox897774203/prog.go:19 +0xc5
testing.tRunner(0xc000106300, 0x5a0348)
    /usr/local/go-faketime/src/testing/testing.go:1123 +0xef
created by testing.(*T).Run
    /usr/local/go-faketime/src/testing/testing.go:1168 +0x2b3

https://play.golang.org/p/15Yu_aD4ahO

unexportedなフィールドをテストの比較対象にするときは cmp.AllowUnexported を用います。差分があった場合はDiffが表示されます。

package main

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

func TestX(t *testing.T) {

    type X struct {
        numUnExport int
        NumExport   int
    }

    num1 := X{100, -1}
    num2 := X{999, -1}

    opt := cmp.AllowUnexported(X{})

    if diff := cmp.Diff(num1, num2, opt); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}
=== RUN   TestX
    prog.go:22: X value is mismatch (-num1 +num2):  main.X{
        -   numUnExport: 100,
        +   numUnExport: 999,
            NumExport:   -1,
          }
        
--- FAIL: TestX (0.00s)
FAIL

https://play.golang.org/p/L9P8p7ASLyo

unexportedなフィールドが存在する場合に無視する

unexportedなフィールドを比較対象から除くときは cmpopts.IgnoreUnexported を使います。

package main

import (
    "testing"

    "github.com/google/go-cmp/cmp"
    "github.com/google/go-cmp/cmp/cmpopts"
)

func TestX(t *testing.T) {

    type X struct {
        numUnExport int
        NumExport   int
    }

    num1 := X{100, -1}
    num2 := X{999, -1}

    opt := cmpopts.IgnoreUnexported(X{})

    if diff := cmp.Diff(num1, num2, opt); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}

比較対象から除外されて、テストがPASSするようになったことが分かります。

=== RUN   TestX
--- PASS: TestX (0.00s)
PASS

https://play.golang.org/p/Rfh4aVjHGNt

構造体のあるフィールドを比較対象から除く

時刻など構造体のうち一部のフィールドは比較対象から除きたい場合があります。一部のフィールドを比較対象から除外したい場合は cmpopts.IgnoreFields を使います。除外したいフィールドを文字列で列挙することで比較対象から除くことができます。

以下の場合は X 構造体の CreateAtUpdateAt の時刻に関するフィールドを比較対象から除外しています。

func TestX(t *testing.T) {

    type X struct {
        NumExport int
        CreateAt  time.Time
        UpdateAt  time.Time
    }

    num1 := X{-1, time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()}
    num2 := X{-1, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()}

    opt := cmpopts.IgnoreFields(X{}, "CreateAt", "UpdateAt")

    if diff := cmp.Diff(num1, num2, opt); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}
=== RUN   TestX
--- PASS: TestX (0.00s)
PASS

https://play.golang.org/p/9Geaky978t_J

構造体の中に構造体のスライスが含まれていて、そのスライスに含まれる構造体の一部のフィールドを比較対象から除外することも cmpopts.IgnoreFields を指定することで除外可能です。

func TestX(t *testing.T) {

    type Item struct {
        HashKey   string
        CreatedAt time.Time
    }
    type X struct {
        Items []Item
    }

    x1 := X{[]Item{
        {HashKey: "aaa", CreatedAt: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)},
        {HashKey: "bbb", CreatedAt: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)},
    }}
    x2 := X{[]Item{
        {HashKey: "aaa", CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)},
        {HashKey: "bbb", CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)},
    }}

    opt := cmpopts.IgnoreFields(Item{}, "CreatedAt")

    if diff := cmp.Diff(x1, x2, opt); diff != "" {
        t.Errorf("X value is mismatch (-x1 +x2):%s\n", diff)
    }
}
=== RUN   TestX
--- PASS: TestX (0.00s)
PASS

https://play.golang.org/p/KCtP21m6ioc

複数のオプションを指定する

複数の条件を cmp.Diff のオプションに渡したい場合があります。例えば

  • unexportedなフィールドを比較対象から除外
  • 構造体の時刻に関するフィールドを比較対象から除外

といった上記の両方をオプションとして指定したい場合です。このような場合は cmp.Diff のオプションに cmp.Option のスライスを渡すことで複数の条件をオプションとして指定できます。

func TestX(t *testing.T) {

    type X struct {
        NumExport   int
        numUnExport int
        CreateAt    time.Time
        UpdateAt    time.Time
    }

    num1 := X{100, -1, time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()}
    num2 := X{999, -111, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()}

    opts := []cmp.Option{
        cmpopts.IgnoreUnexported(X{}),
        cmpopts.IgnoreFields(X{}, "CreateAt", "UpdateAt"),
    }

    if diff := cmp.Diff(num1, num2, opts...); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}

時刻に関する項目の差分は比較対象から無視され、またunxeportedなフィールドも無視されていることがわかります。

=== RUN   TestX
    prog.go:29: X value is mismatch (-num1 +num2):  main.X{
        -   NumExport: 100,
        +   NumExport: 999,
            ... // 3 ignored fields
          }
        
--- FAIL: TestX (0.00s)
FAIL

https://play.golang.org/p/uGFUSLEFwZF

スライスの要素の順序を無視する

構造体の中にスライスを含む場合に、スライスに含まれる値が一致していることを確認し、順序までは一致していなくてもOKとする場合です。通常はスライスの順序まで一致していることが求められます。

func TestX(t *testing.T) {

    type X struct {
        Numbers []int
    }

    num1 := X{[]int{1, 2, 3, 4, 5}}
    num2 := X{[]int{5, 4, 3, 2, 1}}

    if diff := cmp.Diff(num1, num2); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}

スライスの順序が一致していない場合はDiffとして差分があるものとして検知されます。

=== RUN   TestX
    prog.go:19: X value is mismatch (-num1 +num2):  main.X{
            Numbers: []int{
        -       1, 2, 3, 4, 5,
        +       5, 4, 3, 2, 1,
            },
          }
        
--- FAIL: TestX (0.00s)
FAIL

多くの場合は順序も含めて一致することを確認するとして問題ないですが、一部の状況ではスライスの中身が一致していればOKとみなしたい場合があるでしょう。その場合はスライスの中身をソートするようなオプションを指定するとうまくいきます。

func TestX(t *testing.T) {

    type X struct {
        Numbers []int
    }

    num1 := X{[]int{1, 2, 3, 4, 5}}
    num2 := X{[]int{5, 4, 3, 2, 1}}

    opt := cmpopts.SortSlices(func(i, j int) bool {
        return i < j
    })

    if diff := cmp.Diff(num1, num2, opt); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}
=== RUN   TestX
--- PASS: TestX (0.00s)
PASS

https://play.golang.org/p/hLrpfqiSlii

ソートの条件は任意に実装できるため、例えば独自の構造体のスライスの順序をソートすることも可能です。例えば

type X struct {
    HashKey string
    SortKey string
}

という構造体のスライスについての順序をソートするには、以下のような実装例が考えられます。

func TestX(t *testing.T) {
    x1 := []X{
        {HashKey: "AAA", SortKey: "AAA"},
        {HashKey: "BBB", SortKey: "BBB"},
        {HashKey: "BBB", SortKey: "CCC"},
    }
    x2 := []X{
        {HashKey: "BBB", SortKey: "CCC"},
        {HashKey: "AAA", SortKey: "AAA"},
        {HashKey: "BBB", SortKey: "BBB"},
    }

    opt := cmpopts.SortSlices(func(i, j X) bool {
        // ハッシュキーとソートキーの昇順でソート
        return i.HashKey < j.HashKey || (i.HashKey == j.HashKey && i.SortKey < j.SortKey)
    })

    if diff := cmp.Diff(x1, x2, opt); diff != "" {
        t.Errorf("X value is mismatch (-x1 +x2):%s\n", diff)
    }
}
=== RUN   Test
--- PASS: Test (0.00s)
PASS

https://play.golang.org/p/8uUD5XKLYXs

空スライスとnilスライスの違いを無視する

テスト上は空スライスとnilスライスの違いを明確にしてテストすることで十分な場合がありますが、cmpopts.EquateEmpty() をオプションに用いることで無視することもできます。

func TestX(t *testing.T) {

    type X struct {
        Numbers []int
    }

    num1 := X{[]int{}}
    num2 := X{nil}

    opt := cmpopts.EquateEmpty()

    if diff := cmp.Diff(num1, num2, opt); diff != "" {
        t.Errorf("X value is mismatch (-num1 +num2):%s\n", diff)
    }
}
=== RUN   TestX
--- PASS: TestX (0.00s)
PASS

https://play.golang.org/p/jdchcmmR5DF

その他

その他 float64 型の差分を加味して、一定の差分以内であれば等価とみなすような cmpopts.EquateApproxmath.NaN()math.NaN() の比較を等価とみなせるような cmpopts.EquateNaNs()time に関する一定の差分を等価とみなすような cmpopts.EquateApproxTime など様々なオプションがあります。

本記事に記載したものに比べるとユースケースは少ないかもしれませんが、様々なオプションが指定できるようになっています。詳しくは Documentationutil_test.go単体テストケースに記載されている内容を見るとよいでしょう。

まとめ

go-cmp の使う理由やよく使うオプションのTipsを紹介しました。go-cmpreflect.DeppEqual の代替として使うことができ、テストの書き方自体は標準の書き方を使うことができます。テスト結果のDiffが見やすくなるだけでも生産性は上がると思いますので、まだ使ったことがない方は導入を検討してみてはいかがでしょうか。

条件付きのロジックをTempleteMethodパターンで置換する

コードが複雑になる要因の一つとしてif文やswitch文による条件付きロジックがあります。以下のような給与を計算する実装例を考えてみます。(employeeType が文字列、という話はおいておきます)

switch 文があると呼び出し側の実装が複雑になります。switch 文を呼び出し元の実装からリファクタリングします。

  • main.go
package main

import "fmt"

func main() {
    employeeType := "engineer"

    var amount int
    switch employeeType {
    case "engineer":
        amount = 1
    case "salesman":
        amount = 10
    default:
        amount = -1
    }

    fmt.Println("amount", amount)
}

TemplateMethodパターン

条件付きのロジックをSteragyパターンで置換する の記事ではStrategyパターンを使って呼び出し側の構造体にアルゴリズムを実装することができました。今回はTemplateMethodパターンで実装します。

Strategyパターンと本質的には同様で、TemplateMethodパターンでは抽象クラスやインターフェースを使って実装します。呼び出し元の main.go はStrategyパターンと変更はありません。 - model.go

package main

type Employee interface {
    payAmount() int
}

func NewEmployee(employeeType string) Employee {
    switch employeeType {
    case "engineer":
        return Engineer{}
    case "salesman":
        return Salesman{}
    default:
        return dummy{}
    }
}

type Engineer struct{}

func (e Engineer) payAmount() int {
    return 1
}

type Salesman struct{}

func (s Salesman) payAmount() int {
    return 10
}

type dummy struct{}

func (d dummy) payAmount() int {
    return -1
}
  • main.go
package main

import "fmt"

func main() {
    e := NewEmployee("engineer")
    fmt.Println("amount", e.payAmount())
}
  • 出力結果
$ go run .
amount 1

呼び出し元からは Employee インターフェースのメソッドが呼び出されます。実装の詳細は Employee インターフェースを実装している各構造体のメソッドです。本例では Engineer 構造体のメソッドとなります。

条件付きのロジックをStrategyパターンで置換する

コードが複雑になる要因の一つとしてif文やswitch文による条件付きロジックがあります。以下のような給与を計算する実装例を考えてみます。(employeeType が文字列、という話はおいておきます)

switch 文があると呼び出し側の実装が複雑になります。switch 文を呼び出し元の実装からリファクタリングします。

  • main.go
package main

import "fmt"

func main() {
    employeeType := "engineer"

    var amount int
    switch employeeType {
    case "engineer":
        amount = 1
    case "salesman":
        amount = 10
    default:
        amount = -1
    }

    fmt.Println("amount", amount)
}

Strategyパターン

呼び出し側のロジックを、適切なクラスのメソッドとして実装して、呼び出し側の実装をシンプルにします。デザインパターンのStrategyパターンを導入して、strcut のメソッドとしてアルゴリズムを実装します。Strategyパターンを導入するとアルゴリズムを任意に差し替えることができます。employeeType が追加になった場合には、呼び出し元の実装には変更は不要です。変更の範囲を局所化できます。今回の場合は employeeTypeengineersalesman の2種類の計算ロジックがあれば十分です。

実装上のポイントは、アルゴリズムを実装した構造体のメソッドに移譲して呼び出すということです。NewEmployee の値を例えばJavaのSpringを使っている場合はDIコンテナから取得する、といったことも可能です。Goの場合はDIコンテナはないので諦めましょう。*1

  • model.go
package main

func NewEmployee(employeeType string) Employee {
    switch employeeType {
    case "engineer":
        return Employee{strategy: EngineerStrategy{}}
    case "salesman":
        return Employee{strategy: SalesmanStrategy{}}
    default:
        return Employee{}
    }
}

type Employee struct {
    strategy EmployeeStrategy
}

func (e Employee) payAmount() int {
    if e.strategy == nil {
        return -1
    }
    return e.strategy.payAmount()
}

type EmployeeStrategy interface {
    payAmount() int
}

type EngineerStrategy struct{}

func (es EngineerStrategy) payAmount() int {
    return 1
}

type SalesmanStrategy struct{}

func (ss SalesmanStrategy) payAmount() int {
    return 10
}
  • main.go
package main

import "fmt"

func main() {
    e := NewEmployee("engineer")
    fmt.Println("amount", e.payAmount())
}
  • 出力結果
$ go run .
amount 1

Employee 構造体は呼び出し元から呼び出されますが、アルゴリズムの詳細は Employee 構造体のフィールドに含まれる EmployeeStrategy インターフェースの payAmount メソッドに移譲しています。main.go ではインターフェースのメソッドを呼び出しています。NewEmployee のコンストラクタの引数によって振る舞いを差し替えることができました。

*1:google/wireは必要な依存関係のコードを生成してくれますが、SpringのDIコンテナのように実行時にインスタンスを取得するものではありません

IDのモックを作る

IDを生成する必要があるときにUUIDやxidを用いることがあります。テストの結果にIDが含まれてしまう場合にIDの生成をモックする方法を紹介します。本記事では rs/xid を用いてIDを生成することにします。ポイントは関数型の変数にすることです。もちろんインターフェースのメソッドすることでモックすることもできます。

  • a.go
package a

import (
    "time"

    "github.com/rs/xid"
)

var generateID = generateIDFunc

func generateIDFunc(t time.Time) string {
    return xid.NewWithTime(t).String()
}
  • a_test.go
package a

import (
    "testing"
    "time"
)

func TestGenerateID(t *testing.T) {
    generateID = func(t time.Time) string {
        return "test-id"
    }
    got := generateID(time.Now())
    want := "test-id"
    if got != want {
        t.Errorf("wrong id, got=%v, want=%v", got, want)
    }
}
$ go test -v
=== RUN   TestGenerateID
--- PASS: TestGenerateID (0.00s)
PASS
ok      github.com/d-tsuji/go-sandbox/a 0.177s

簡単ですが、このようにしてランダムな値をモックすることができます。

Goで構造体をDeepCopyする

はじめに

Goで構造体のDeepCopyする方法の紹介です。Goのsliceやmapは値への参照を保持しているため、単純に値をコピーするだけではDeepCopyできないことはよく知られています。以下は間違った実装例です。

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4}
    t := s
    fmt.Printf("[s] address: %p, values: %v\n", s, s)
    fmt.Printf("[t] address: %p, values: %v\n", t, t)
    // Output:
    //[s] address: 0xc00009e140, values: [1 2 3 4]
    //[t] address: 0xc00009e140, values: [1 2 3 4]

    // 参照先のアドレスの値を変更
    s[0] = 999

    fmt.Printf("[s] address: %p, values: %v\n", s, s)
    fmt.Printf("[t] address: %p, values: %v\n", t, t)
    // Output:
    //[s] address: 0xc00009e140, values: [999 2 3 4]
    //[t] address: 0xc00009e140, values: [999 2 3 4]
}

sliceのDeepCopyは Go Slices: usage and internals にあるように組み込み関数である copy を用いるのが正しい実装になります。(アドレスは実行環境によって変わります)

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4}
    t := make([]int, len(s))
    copy(t, s)
    fmt.Printf("[s] address: %p, values: %v\n", s, s)
    fmt.Printf("[t] address: %p, values: %v\n", t, t)
    // Output:
    // [s] address: 0xc00000c3c0, values: [1 2 3 4]
    // [t] address: 0xc00000c3e0, values: [1 2 3 4]

    // 参照先のアドレスの値を変更
    s[0] = 999

    fmt.Printf("[s] address: %p, values: %v\n", s, s)
    fmt.Printf("[t] address: %p, values: %v\n", t, t)
    // Output:
    // [s] address: 0xc00000c3c0, values: [999 2 3 4]
    // [t] address: 0xc00000c3e0, values: [1 2 3 4]
}

構造体のDeepCopy

リフレクションを用いてGoでDeepCopyするライブラリは以下のようなライブラリがあります。

リフレクションを用いたDeepCopyは本記事では立ち入りませんので、紹介だけにとどめておきます。

go generateによるメソッド生成

本記事では go generate によって構造体のメソッドを生成してDeepCopyする方法を紹介します。例として以下のような myStruct をDeepCopyすることにします。

  • mystruct.go
type myStruct struct {
    A int
    B string
    C []string
    D map[string]string
    E map[string][]string
}

以下のライブラリを用いてDeepCopyするメソッドを生成します。

ライブラリをインストールします。

$ go get github.com/globusdigital/deep-copy

メソッドを生成します。

$ deep-copy -o mystruct_deepcopy.go --type myStruct .

するとコマンドで指定したファイルが生成されます。メソッドの実装は、もとの構造体の値をまずはそのままコピーしています。その後、sliceやmapのフィールドの値に関しては、make をして新しいメモリアドレスを確保し、組み込みのcopy関数を用いてDeepCopyしています。

  • mystruct_deepcopy.go
// generated by deep-copy -o mystruct_deepcopy.go --type myStruct .; DO NOT EDIT.

package main

// DeepCopy generates a deep copy of myStruct
func (o myStruct) DeepCopy() myStruct {
    var cp myStruct = o
    if o.C != nil {
        cp.C = make([]string, len(o.C))
        copy(cp.C, o.C)
    }
    if o.D != nil {
        cp.D = make(map[string]string, len(o.D))
        for k, v := range o.D {
            cp.D[k] = v
        }
    }
    if o.E != nil {
        cp.E = make(map[string][]string, len(o.E))
        for k, v := range o.E {
            var cpv []string
            if v != nil {
                cpv = make([]string, len(v))
                copy(cpv, v)
            }
            cp.E[k] = cpv
        }
    }
    return cp
}

テストして構造体を比較します。テストには google/go-cmp を利用します。

  • mystruct_test.go
package main

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

func Test_newMyStruct(t *testing.T) {
    src := &myStruct{
        A: 1,
        B: "test",
        C: []string{"A", "B", "C"},
        D: map[string]string{
            "A": "Apple",
        },
        E: map[string][]string{
            "fruits": {"Apple", "Banana"},
        },
    }
    tar := src.DeepCopy()

    if diff := cmp.Diff(*src, tar); diff != "" {
        t.Errorf("mystruct mismatch (-want +got):\n%s", diff)
    }

    src.A = 999
    src.B = "XXXXX"
    src.C[0] = "XXXXX"
    src.D["A"] = "XXXXX"
    src.E["fruits"] = []string{"XXXXX"}

    diff := cmp.Diff(*src, tar)
    if diff == "" {
        t.Errorf("expect different mystruct")
    }
    t.Logf("mystruct different (-src +tar):\n%s", diff)

    expect := myStruct{
        A: 1,
        B: "test",
        C: []string{"A", "B", "C"},
        D: map[string]string{
            "A": "Apple",
        },
        E: map[string][]string{
            "fruits": {"Apple", "Banana"},
        },
    }
    if diff := cmp.Diff(expect, tar); diff != "" {
        t.Errorf("mystruct mismatch (-want +got):\n%s", diff)
    }
}

以下のように想定どおりDeepCopyされていることが確認できました。

$ go test -v
=== RUN   Test_newMyStruct
    Test_newMyStruct: mystruct_test.go:37: mystruct different (-src +tar):
          main.myStruct{
        -       A: 999,
        +       A: 1,
        -       B: "XXXXX",
        +       B: "test",
                C: []string{
        -               "XXXXX",
        +               "A",
                        "B",
                        "C",
                },
        -       D: map[string]string{"A": "XXXXX"},
        +       D: map[string]string{"A": "Apple"},
                E: map[string][]string{
                        "fruits": {
        -                       "XXXXX",
        +                       "Apple",
        +                       "Banana",
                        },
                },
          }
--- PASS: Test_newMyStruct (0.00s)
PASS
ok      github.com/d-tsuji/go-sandbox/s 0.190s

まとめ

globusdigital/deep-copy のライブラリを用いて構造体にDeepCopy可能なメソッドを生成することで、構造体のDeepCopyする方法を紹介しました。go generateを用いてメソッドを生成する方法は Generating code で紹介されている stringer をgo generateするアプローチと同様です。リフレクションを用いて動的にDeepCopyする方法よりもGoらしく感じます。

DynamoDB LocalとGoを用いた実装と、CircleCIでのCIの設定方法

本記事ではDynanmoDB Localを用いてローカルでのGoを用いた開発をする方法と、CircleCIを用いてCIを実施する方法を示します。

DynamoDB Localのセットアップ

まずDynamoDBにはDynamoDB Localと言われるエミュレーターがあるので、ローカルでの開発はDynamoDB Localを利用しましょう。

aws_access_key_idaws_secret_access_key はダミーでOKです。何かしらの値は設定する必要があります。

# AWS profile(初回のみ)
aws configure set aws_access_key_id dummy     --profile local
aws configure set aws_secret_access_key dummy --profile local
aws configure set region ap-northeast-1       --profile local

# DynamoDB Localの初回セットアップ
docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local:1.13.1 -jar DynamoDBLocal.jar -sharedDb
  • 停止するとき
docker stop dynamodb

ローカルにコンテナが起動したことがわかります。

$ docker ps
CONTAINER ID        IMAGE                          COMMAND                  CREATED             STATUS              PORTS                    NAMES
76d708f8199c        amazon/dynamodb-local:1.13.1   "java -jar DynamoDBL…"   3 hours ago         Up 3 hours          0.0.0.0:8000->8000/tcp   dynamodb

ユーザの情報を管理する user テーブルを用いることにします。DynamoDB Localを用いるときは、prefixに local を付与します。テーブル定義はこんな感じ。

{
  "TableName": "local_user",
  "KeySchema": [
    {
      "AttributeName": "user_id",
      "KeyType": "HASH"
    }
  ],
  "AttributeDefinitions": [
    {
      "AttributeName": "user_id",
      "AttributeType": "S"
    }
  ],
  "ProvisionedThroughput": {
    "ReadCapacityUnits": 1,
    "WriteCapacityUnits": 1
  }
}

以下のようなアイテムを投入することにします。

{
  "user_id": {
    "S": "001"
  },
  "user_name": {
    "S": "gopher_1"
  }
}

DynamoDB Localを用いたGoのテストの実装

Goの実装です。local_user テーブルは環境変数で定義しておきます。DynamoDB Localを使わない場合は user などというテーブル名を環境変数に定義します。

  • db.go
import (
    "log"
    "os"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/dynamodb"
)

var (
    db *dynamodb.DynamoDB

    userTable string
)

func init() {
    dbEndpoint := os.Getenv("DYNAMO_ENDPOINT")
    region := os.Getenv("AWS_REGION")

    userTable = os.Getenv("DYNAMO_TABLE_USER")
    if userTable == "" {
        log.Fatal(`env variable "DYNAMO_TABLE_USER" is required`)
    }

    sess := session.Must(session.NewSession(&aws.Config{
        Endpoint: aws.String(dbEndpoint),
        Region:   aws.String(region),
    }))
    db = dynamodb.New(sess)
}

DynamoDBからユーザ情報をフェッチするコードはこんな感じです。dynamodbav というタグをstructのフィールドに付与することで、DynamoDBのキーを指定して構造体とマッピングすることができます。

  • user.go
import (
    "context"
    "fmt"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/dynamodb"
    "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

type User struct {
    UserID   string `dynamodbav:"user_id"`
    UserName string `dynamodbav:"user_name"`
}

func FetchUserByID(ctx context.Context, userID string) (*User, error) {
    q := &dynamodb.GetItemInput{
        TableName: aws.String(userTable),
        Key: map[string]*dynamodb.AttributeValue{
            "user_id": {
                S: aws.String(userID),
            },
        },
        ConsistentRead: aws.Bool(true),
    }
    out, err := db.GetItemWithContext(ctx, q)
    if err != nil {
        return nil, fmt.Errorf("dynamodb fetch user: %w", err)
    }

    var user *User
    if err := dynamodbattribute.UnmarshalMap(out.Item, &user); err != nil {
        return nil, fmt.Errorf("dynamodb unmarshal: %w", err)
    }

    return user, nil
}

上記のコードをテストして確認してみましょう。DynamoDB Localにテーブルを作成するコマンドやアイテムを投入するコマンドはexec.Commandを用いてAWS CLIコマンドをGoから実行することにします。なのでローカルの開発マシンからAWS CLIが実行できる必要があります。

  • user_test.go
import (
    "context"
    "os/exec"
    "reflect"
    "strings"
    "testing"
)

func setupDB(t *testing.T) {
    cmds := []string{
        `aws dynamodb --endpoint-url http://localhost:8000 create-table --cli-input-json file://./testdata/local_user.json`,
        `aws dynamodb put-item --endpoint-url http://localhost:8000 --table-name local_user --item file://./testdata/input_user.json`,
    }
    for _, cmd := range cmds {
        args := strings.Split(cmd, " ")
        if err := exec.Command(args[0], args[1:]...).Run(); err != nil {
            t.Logf("setup DynamoDB %v %s", err, cmd)
        }
    }
}

func teardownDB(t *testing.T) {
    cmds := []string{
        `aws dynamodb --endpoint-url http://localhost:8000 delete-table --table local_user`,
    }
    for _, cmd := range cmds {
        args := strings.Split(cmd, " ")
        if err := exec.Command(args[0], args[1:]...).Run(); err != nil {
            t.Logf("teardown DynamoDB %v %s", err, cmd)
        }
    }
}

func TestFetchUserByID(t *testing.T) {
    setupDB(t)
    t.Cleanup(func() { teardownDB(t) })

    tests := []struct {
        name    string
        userID  string
        want    *User
        wantErr bool
    }{
        {
            name:   "normal",
            userID: "001",
            want: &User{
                UserID:   "001",
                UserName: "gopher_1",
            },
            wantErr: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := FetchUserByID(context.TODO(), tt.userID)
            if (err != nil) != tt.wantErr {
                t.Errorf("FetchUserByID() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("FetchUserByID() got = %v, want %v", got, tt.want)
            }
        })
    }
}

さあ、テストを実行してみます。

go test -v
=== RUN   TestFetchUserByID
=== RUN   TestFetchUserByID/normal
--- PASS: TestFetchUserByID (6.01s)
    --- PASS: TestFetchUserByID/normal (0.01s)
PASS
ok      github.com/d-tsuji/sample-circleci      6.231s

すばらしいですね。DynamoDB Localを用いてテストすることができました。

Circle CIの設定

天下り的に config.yml を示します。この設定でCircleCI上でDynamoDB Localを用いたCIを実施することができます。

version: 2

jobs:
  test:
    docker:
      # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/
      - image: circleci/golang:1.14.4
      - image: amazon/dynamodb-local

    working_directory: /go/src/github.com/d-tsuji/sample-circleci

    # Environment values for all container
    environment:
      - GO111MODULE: "on"
    steps:
      - checkout
      - run:
          name: Install AWS CLI
          command: |
            curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
            unzip awscliv2.zip
            sudo ./aws/install
      - run:
          name: Fetch dependencies
          command: go mod download
      - run:
          name: Wait for DynamoDB
          command: |
            for i in `seq 1 10`;
            do
              nc -z localhost 8000 && echo Success && exit 0
              echo -n .
              sleep 1
            done
            echo Failed waiting for DyanmoDB Local && exit 1
      - run:
          name: Run all unit tests
          command: |
            export AWS_REGION=ap-northeast-1
            export AWS_ACCESS_KEY_ID=dummy
            export AWS_SECRET_ACCESS_KEY=dummy
            export DYNAMO_ENDPOINT=http://localhost:8000
            export DYNAMO_TABLE_USER=local_user
            make test

workflows:
  version: 2
  build-and-test:
    jobs:
      - test

サマリ

DynamoDB Localを用いたGoの実装と、CircleCIの設定を示しました。本記事で扱ったコードは以下のリポジトリにコミットしてあります。

https://github.com/d-tsuji/sample-circleci

よいDynamoDB Localライフを。