Go言語を使って1年間が経った
伊藤 瑛
はじめに
これはRecruit Engineers Advent Calendar2日目の記事です。
昨年書いた記事より、1年が経ちました。
Go言語を用いて開発を行ったプロジェクトも無事にリリースを迎えることができました。ほぼ問題は発生しておらず、安定的な稼働を実現できています。
今回の記事では、1年前の記事を踏まえ、Goを用いてWebシステムのバックエンドを開発する上での振り返りを行いたいと思います。
全体アーキテクチャについて
リクルートテクノロジーズではバックエンドとフロントエンドの疎結合を促進させ、より柔軟な設計と開発効率の向上を意図して、 Backends For Frontends(BFF) と呼ばれる層を設けています。
BFFについてはこの資料やこの連載が詳しいのですが、代表的なユースケースだと、
- APIのAggregation(いわゆるAPI Gateway)
- Viewのレンダリング
- Session管理
などの責務を担っています。このレイヤを挟むことで、フロントエンドの開発に対して有利になるだけでなく、バックエンドはSessionなどのステートフルな情報を管理せずに、シンプルな実装が可能になります。
今回の案件でもBFFを採用し、バックエンドはステートレスなJSONのAPIとして開発を行いました。従って、Goで開発する範囲は以下の通りです。
- ビジネスロジックの実装
- データ永続化(DBアクセス等)
- 外部システムとのAPI連携
- バッチ処理
全体のアーキテクチャを図示します。
ProxyやSession Storeなどの構成物は記載していませんが、大まかには上記のような構成です。今回はライフサイクルが違う複数のWebシステムを提供する必要があったので、BFFは複数設置しています。
APIのアーキテクチャ
今回の開発ではAPIは3層のレイヤーで構成しました。このレイヤー構成は、リクルートのJava版の標準開発のガイドラインに沿ったものになっており、Goでもそれを継承した設計を行いました。
以下にその実装と責務をまとめます。
- Controller
- Service
- 使用ライブラリは特になし。interfaceとして定義し、実装は公開しない。
- Repositoryに依存。
- 責務
- ビジネスロジックの実装。
- repositoryの呼び出し。
- Repository
- gorpを(主に)使用。interfaceとして定義し、実装は公開しない。
- 依存レイヤはなし。
- 責務
- 永続化層(主にDB)への読み書き。
これ以外にも、実際には数々のutilityをまとめた support
というパッケージや、レイヤ間のデータ受け渡し用の構造体をまとめたパッケージもあります。
また、これは余談ですが、バッチを開発する際にも似たような構成はとられ、特にservice
やrepository
のレイヤのコードはバッチの開発でも再利用されています。
この構成自体は上位レイヤが一つ下の下位レイヤのinterface
に依存するという典型的なパターンです。前回の記事で紹介したように、DIの力を借りることにより、各レイヤは依存の解決を気にすることなく実装を行うことができます。
テスト方針について
この構成のメリットとして、testabilityの担保が挙げられます。
具体的に疑似コードで話します。ユーザのパスワード認証の擬似コードとなっています。
レポジトリのコードです。User DBから、email
で検索してレコードを返却するという擬似コードになっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
type UserRepository interface { FindUserForEmail(email string) (*model.User, error) } func NewUserRepository() UserRepository { return &userRepository{} } type userRepository struct{} func (*userRepository)FindUserForEmail(email string) (*model.User, error) { var u model.User // 擬似コードです if err := db.SelectOne(&u, "SELECT * FROM User WHERE email = $1", email); err == sql.ErrNoRows { return nil, nil // ヒットしなかったらnilを返す } else if err != nil { return nil, err } return &u, nil } |
次にservice
のコードです。repository
からemail
経由でユーザを取得し、パスワードを比較します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
type UserService interface { Verify(email string, password string) (bool, error) } // repositoryに依存。diconがsetする。 func NewUserService(repo UserRepository) UserService { return &userService{repo: repo} } type userService struct { repo UserRepository } func (s *userService) Verify(email string, password string) (bool, error) { s, err := s.repo.FindUserForEmail(email) if err != nil { return false, err } if s == nil { return false, errors.New("user not found") // userが見つからなかった場合 } if err := bcrypt.CompareHashAndPassword([]byte(s.PasswordHash), []byte(password)); err != nil { return false, errors.New("password unmatched") // passwordが一致しない場合 } return true, nil } |
最後にcontroller
のコードです。このcontroller
はclosureを使って定義されており、予め依存となるservice
をDIコンテナから取得するように実装してあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func AuthorizeUserController() return func(e echo.Context) error { dicon := di.DefaultContainer() // DI Containerを持ってくる userService, _ := dicon.UserService() // DI ContainerからUser Serviceを引き出す return func(e echo.Context) error { u := new(User) if err = c.Bind(u); err != nil { return err } ok, err := userService.Verify(u.Email, u.Password) if err != nil { /** 以下省略 **/ } } |
さて、ここで、service
のレイヤのテストを考えてみます。
service
はrepository
に依存しているので、repository
のモックを作り、service
に渡すパターンを作ってみます。
repository
のモック(正確にはスタブですが)は以下のように実装できます。
1 2 3 4 5 6 7 8 9 10 |
type repositoryMock struct{} func (*repositoryMock) FindUserForEmail(email string) (*model.User, error) { pass, _ := bcrypt.GenerateFromPassword([]byte("password123!"), 10) return &model.User{ Email: "test@example.com", PasswordHash: string(pass), }, nil } |
これで固定のユーザを返すrepository
が実装できました。
test codeは以下の通りになります。
1 2 3 4 5 6 7 8 9 10 11 |
func TestUserService_Verify(t *testing.T) { s := &userService{repo: &repositoryMock{}} ok, err := s.Verify("test@example.com", "password123!") if err != nil { t.Fatal(err) } if !ok { t.Error("should be valid user") } } |
実行してみます。(PATH等はよしなに読み替えてください)
1 2 3 4 5 6 |
% go test -v . === RUN TestUserService_Verify --- PASS: TestUserService_Verify (0.11s) PASS ok github.com/akito0107/playground 0.113s |
TestがPassしました。少なくとも正常系については正しく実装できていることが確認できました。
このように、依存があるモジュールでもmock等を簡単に差し込むことができ、容易にtestを実装することが可能になっています。
上記のようなパターンを用いて、各レイヤのテストは以下のように行いました。
- Controller
- Serviceのmockを差し込んでテストを行う
- Service
- Repositoryのmockを差し込んでテストを行う
- Repository
- mockは用いずに本物のDBを使う
これらの方針を開発前にドキュメント化し、チーム内で確認をするという作業を行ったため、特にテストに関しては非常にスムーズに実装を進めることができました。
Test Double自動生成
上記の基本方針に加え、(昨年の記事の末尾でも少し触れましたが)Testに用いるmock / stubを自動生成する機能を開発しました。
具体的な使用方法はGithubの方を参照していただきたいのですが、ここではどういったコードが自動生成されるのかを紹介したいと思います。
dicon
のgenerate-mock
の機能を使うと、DIで解決される依存のコンポーネントすべてのmockが自動で生成されます。今回の場合だと、repository
とservice
のmockが以下のように生成されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
type UserRepositoryMock struct { FindUserForEmailMock func(a0 string) (*model.User, error) } func NewUserRepositoryMock() *UserRepositoryMock { return &UserRepositoryMock{} } func (mk *UserRepositoryMock) FindUserForEmail(a0 string) (*model.User, error) { return mk.FindUserForEmailMock(a0) } type UserServiceMock struct { VerifyMock func(a0 string, a1 string) (bool, error) } func NewUserServiceMock() *UserServiceMock { return &UserServiceMock{} } func (mk *UserServiceMock) Verify(a0 string, a1 string) (bool, error) { return mk.VerifyMock(a0, a1) } |
interface
名+Mock
のstructが生成されています。このstruct
はinterface
の関数名
+Mock
のメンバ変数の関数を持っており、それぞれの実装メソッドではこの関数を呼んでいます。
このmock
を使うときは、メンバ変数(としての関数)を差し替え、任意の挙動を実装します。例えば、上で実装したmockと同じ挙動をさせる場合には以下のようになります。
1 2 3 4 5 6 7 8 9 10 |
m := mock.NewUserRepositoryMock() // 関数を差し替えして任意の挙動を実装する m.FindUserForEmailMock = func(a0 string) (user *model.User, e error) { pass, _ := bcrypt.GenerateFromPassword([]byte("password123!"), 10) return &model.User{ Email: "test@example.com", PasswordHash: string(pass), }, nil } |
mockの挙動を変えるたびにいちいち構造体を宣言し直さないと行けない上の方式より、かなりeasyになったかと思います。
また、以下のようにmock関数のスコープ内で*testing.T
を使うことができ、特別なライブラリなどを使わなくても、関数の入力値のチェックなどができるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 |
func TestUserService_Verify2(t *testing.T) { m := mock.NewUserRepositoryMock() m.FindUserForEmailMock = func(a0 string) (user *model.User, e error) { if a0 != "test@example.com" { t.Errorf("args must be test@example.com but %s", a0) } return nil, nil } s := &userService{repo: m} s.Verify("test@example.com", "password123!") } |
goのmockingではgolang/mockなどの有名なライブラリがありますが、私達はdicon
のDIと組み合わせの問題もあり、こういった方式を採用しています。
ちなみに、dicon
に依存しない形で、このmockingの機能だけを取り出したツールimpast/mockerがあります。興味がある方はこちらも参照してみてください。
DI + test doubleの支援ツールの組み合わせにより、高いソースコードの品質を保ちつつも効率よく開発を行うことができました。
まとめと今後について
この記事では1年前に開発したgoのDIコンテナのライブラリ、dicon
が実際の開発でどう使われたのかを紹介しました。
また、リクルートテクノロジーズにおけるGoを用いたwebシステム開発で、テストがどのように行われてるのかも合わせて紹介いたしました。
今後もリクルートテクノロジーズではGoによる開発を勧めていきます。現在行おうとしている取り組みを簡単にだけ紹介すると、
- DBやSQLから自動でコードを生成してくれるツール、xoの導入
- Swagger(OpenAPI2.0)からのコード自動生成
1に関しては、リクルートテクノロジーズの開発ではそれなりに複雑なSQLを記述することが多く、そのmappingの簡略化を意図して導入しようとしています。
2については、おそらくアドベントカレンダー2の方で紹介できると思いますが、リクルートテクノロジーズで開発しているConsumer Driven Contractのツール、agreedからOpenAPI準拠のjson/yamlを生成するツールを開発しています。
このjson/yamlからサーバサイドのGoのコードを自動生成するという取り組みも行っています。
以上となります、ありがとうございました!
- アイキャッチ画像はこちらからお借りしました。