Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASEプロダクトチームブログ

BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート

お久しぶりです、BASEでサーバーサイドエンジニアをやっている、東口(@hgsgtk)です。BASE BANKというBASEの子会社にて金融事業の立ち上げを行っています。今回は、BASE BANKで行っているGo言語でのチーム開発について書こうと思います。

なお、このエントリーの内容について、2018年11月25日に開催されたGo Conference 2018 AutumnにてLT登壇してきました。

f:id:khigashigashi:20181126081744p:plain

発表資料については次のURLで公開しています。

https://go-talks.appspot.com/github.com/higasgt/gocon-lt/architecture.slide#1

今回、LTをCFPで2つ提出して、偶然2つ採択いただいたので、併せてDocker開発環境の整備についても次のスライドで発表させていただきました。

https://go-talks.appspot.com/github.com/higasgt/gocon-lt/realize.slide#1

Go製のApplicationをDocker上で開発する際に環境整備が喫緊の課題としてある方がいらっしゃればこちらの資料も併せてご覧いただくと参考にしていただけるかもしれません。

社内のGo言語の使用状況

今年の5月23日、「Go言語勉強会を始めたよ」といったエントリーを投稿していました。

devblog.thebase.in

その際には、基本的にBASEのプロダクトはほとんどがPHPで構成されており、各開発者が一時的に使用するスクリプトをGoで書くケースがあるよといった使用状況でした。
現在は、新規機能をGo製APIとして実装・運用するプロダクト機能が出てきており、実際に運用開始しているものもあります。今回紹介する事例についても運用開始に向けて進めているものになります。

レイヤ構造模索の旅路

Go言語で運用を前提としたサービスを実装する場合に最初に頭を悩ませる、レイヤ構造実現のためのプロセスについてです。世の中には、Clean Architectureなどレイヤ構造についての解説記事が多数ありますが、結局は自分たちの要件にあったものを実現していく必要に迫られます。それをどう探っていったかという少し泥臭い話をしようかと思います。
なお、注記しておきますが、今回紹介する構成が「本当に正しいLayered Architectureである」などといった概念を正確に実現できているかという点に関しては保証できませんので予めご了承ください。

なぜレイヤ構造が必要だったか

様々必要・不要意見があるかと思いますが、筆者の環境では次のような理由にて必要だと感じておりました。

  • チームの開発成果物に統一的な構造をもたせて、どこで何が書いてあるかわかりやすくしたい
  • 運用後のプロダクト改善を見越して、ビジネスロジックを入出力から分離した変更容易な設計としたい

運用を前提としない単純なスクリプトなどであれば、コードベースにレイヤ構造をもたせるといった検討は不要かもしれません。
しかし、今回は実際に運用しながらプロダクトとしてブラッシュアップしていく必要があるものであったため、上記の理由からレイヤ構造を考えていきました。

どのように進めたか

構造の概念図を仮定義

まず、最初に概念的な構成を図として定義するところからはじめました。実際に次のような簡易的な概念図を作成しています。 f:id:khigashigashi:20181126084405p:plain

Layered Architectureを参考にしつつ、単一な依存方向となる構成とし、HTTPなどアプリケーションの外側の処理とビジネスロジックを分離する当初の目論見を形にできそうな構成を作成しています。

作っては壊す

上記の図を書いたからと言って実際に実装に落とし込み、かつ機能をそこに乗せてみないと本当に自分たちにとって必要なものだったのかわかりません。そのため、概念図をチームに説明した上で、作っては壊していくことを宣言していました。

スキルを上げていく

チームが扱えるレベルを上げていかないことには、変更容易な設計となる成果物は難しいと考えます。そのため、その時点のチームで、ちょっとずつ背伸びしていく方針としました。そのため、後述しますが最初はシンプルな構成から仮定義した地点に近づけていく作業をしています。

実装を進めるにあたって

ユニットテストに対する意味付け

今回、進めるにあたってユニットテストに対して次の意味付けをしていました。

設計に対するフィードバック材料

ユニットテストを書くことによって、「疎結合な実装になっているか」といったコード設計に対するフィードバックがある程度得られると考えます。そのため、「実装の肌感を増やすためのユニットテスト」という意味合いをもたせました。

大胆なリファクタリング

「作っては壊す」と上で書いたとおり、都度都度大胆なリファクタリングが発生しうります。大胆にコードベースをいじるためにユニットテストを書いています。(しかし、これは同時に筆者に大きく反省を残す瞬間が後ほど現れます。)

パッケージの使用

Go言語を実際に扱うに当たり、標準パッケージでどれだけやるのかという議論はよく耳にするかと思います。筆者のケースでは、Go言語自体を覚えれば書ける状態にしたかったため、なるべく標準パッケージを用いる方針にしています。
後続のエンジニアに対する配慮という意味合いもありますが、同時に自分たちが、インターフェースの使い方など「Goらしく」実装するために必要な技法をプロジェクトを通じてある程度体得している状態としたかったためです。
そのため、最初は愚直に書いていき、理解度に合わせて順次便利になるライブラリ・機構を導入していっています。

実際のレイヤ構造

初版:Simple Model-Controller

一番最初の構造です。非常にシンプルなcontrollerとmodelしかない構造です。

├── model
└── controller

この際は、GoのAPI開発にまずチーム全体で慣れるというところから始めたい意図があり、最小限の構成となっています。 model内には、データベースに対する技術的実装なども含まれており、インターフェースの使い方はまだこれからです。

第二版:Model-Controller + DIP

次に移動した構造です。repositoryとdatastoreという2つのパッケージが増えています。

├── domain
│   ├── model
│   └── repository // +
├── infrastructure
│   └── datastore // +
└── controller

ここで、model内にべた書きされていた、データベースに技術的実装をdatastoreパッケージとよんでいる箇所に移動し、repositoryという抽象レイヤを挟む構成となりました。実装イメージとしては次のようになります。

package repository

type UserRepository interface {
    Save(userID int) error
}
package datastore

type UserStore struct {
    DB *sql.DB
}

func (s *UserStore) Save(userID int) error {
    // 処理
}

ここにて、インターフェースの扱い方について習熟するため、ユニットテストでも自前でモック実装を作成してもらい実装の流れを掴むということをしていました。

第三版:Layered Architecture + DIP

├── config
├── domain
│   ├── model
│   └── repository
├── infrastructure
│   ├── datastore
│   └── router
├── interface
│   ├── controller
│   └── middleware
└── service // + layered architectureのapplication layerに該当

ここで、最初の概念図の構成要素がほとんど用意された状態になります。単一な依存方向となるようなLayered Architectureの構成とし、レイヤ間をより疎結合にするためドメインレイヤにインフラストラクチャが依存する抽象を設置しています。
また、処理の複雑化に伴いアプリケーションレイヤに相当する(※1)serviceパッケージを作成しています。これはトランザクションを扱うことが喫緊の課題として取り組みましたが後に記述する反省点の一つとなりました。

※1 MVC + Serviceと捉えた場合はServiceレイヤと同等とも言えるかも知れません。

現在:Layered Architecture + DIP

2018年11月26日現在の構成です。

├── common // + 全レイヤー共通で利用する機能群
│   ├── uuidgen
│   └── validation
├── config
├── domain
│   ├── model
│   └── repository
├── infrastructure
│   ├── clock
│   ├── datastore
│   ├── logger
│   └── router
├── interface
│   ├── controller
│   └── middleware
├── service
└── testutil // + テストヘルパー群

全レイヤ共通で利用するUUID生成などをcommonディレクトリとして集約し、テストヘルパー群をtestutil packageに集約しています。

実践した上での反省点

トランザクションの扱い

トランザクションをどう扱うかを最初から考えておいたほうが良かったと反省しております。トランザクションをどう扱うによって大きくインターフェースが変わってくるかと思います。筆者の例の場合は次のような扱い方にしています。

package service

type SimpleServiceImpl struct {
    DB         repository.DBConnector
    User       repository.UserRepository
}

func (s *SimpleServiceImpl) Run(userID int) error {
    tx, err := s.DB.Begin()
    if err != nil {
        return errors.Wrap(err, "failed to begin transaction")
    }
    if err = s.User.Save(tx, userID); err != nil { // 引数に*Txを渡す
        wrapRollback(tx)
        return errors.Wrap(err, "failed to save user")
    }
    if err = wrapCommit(tx); err != nil {
        wrapRollback(tx)
        return errors.Wrap(err, "failed to commit transaction")
    }
    return nil
}

トランザクションはservice packageで扱います。その際、service package内で生成された*sql.Tx構造体をそれぞれのrepositoryの引数に渡す構造としています。

package repository

type DBHandler interface {
    Execer
    Querier
    Preparer
}

type UserRepository interface {
    Save(db DBHandler, userID int) error
}

その上で、repositoryでは、*sql.DB/Txをwrapした抽象型を作成し利用しています。なお、この構成については、@codehexさんのもう一度テストパターンを整理しよう(WebApp編) - Speaker Deckという資料を大変参考にさせていただいています。
そして、この実装構成にするために、本当に大胆なリファクタリングが必要になり、チーム全員でそれぞれ自身の実装した機能をリファクタリングするという工数が発生してしまいました。
世の中で公開されているレイヤ構造のサンプルコードでなかなかトランザクションを扱っているものは少ない気がしていますが、実際にやるのであれば、最初にトランザクションを扱うコードをサンプルとして作ってみることはおすすめしておきます。

追記:Contextの扱いについて

頂いたリアクションの中で、トランザクションに合わせてcontextの扱いについての話が出ておりましたので追記しておきます。
こちらもトランザクションと同様にメソッドの第一引数にcontext.Contextを渡す実装にしたほうがよいかと思います。
筆者が実際にcontextパッケージを用いた実装を行う際には、@deeeetさんの次の記事を非常に参考にさせていただきました。

テストヘルパーを先回りして用意しておく

ユニットテストを愚直に書いていくという方針は当初理解度を深める上ではよかったのですが、進んでいく中で共通で毎回やるアサーションなどがコピペ作業になっている状態が発生し、「テストを書くのが億劫になる」といった状態が生まれていました。
先回りして共通的なものをテストヘルパーとして提供して開発体験を損ねないということが重要だったなと思います。
実際例として、ResponseHeaderのアサーションなどのヘルパーなどを随時用意していっています。

// Example assertion response header
func AssertResponseHeader(t *testing.T, res *http.Response, code int) {
    t.Helper()
    if code != res.StatusCode {
        t.Errorf("expected status is '%#v',\n but actual is '%#v'", code, res.StatusCode)
    }
    if expected := "application/json; charset=utf-8"; res.Header.Get("Content-Type") != expected {
        t.Errorf("expected Content-Type is '%#v',\n but given '%#v'",
            expected, res.Header.Get("Content-Type"))
    }
}

また、テストに関するTipsは今回のGo Conference 2018 Autumnにて、@timakinさんが発表されていた、Golang API Testing the HARD way - Speaker Deckという資料が大変勉強になるかと思います。

まとめ

今回のエントリでは、最初に構想を決め、実践しながら拡張して構想に近づけていく事例を紹介させていただきました。
すでにGo言語をバリバリ使用している現場の方であれば過程の振り返りに、これから使用を検討されている方にとっては一歩踏み出す参考となれば幸いです。