Goとクリーンアーキテクチャでバックエンド構築
概要
今年Goを使用する機会があり、
以前、とある開発でバックエンドを設計したときのことを思い出すために記述しています。
主にGoでバックエンド構築したときのソフトウェア構造の話です。
開発時期: 2020年〜
ソース概要
Goとクリーンアーキテクチャで、バックエンドのテンプレートを用意しました。
以下を用意しています。(詳細は後述)
- Docker
- 基本的なデータモデルのCRUD
- ロギング
- エラーハンドリング
- DB OR マッパー
- 単体テスト
Goパッケージのリファレンス
- Goパッケージとして公開しています。全体の構造、メソッド定義が確認できます。
https://pkg.go.dev/github.com/opqrshun/go-clean-architecture-template
Clone
git clone https://github.com/opqrshun/go-clean-architecture-template
使用ライブラリ
Gin
- httprouterをベースとするHTTPフレームワーク
- 各リクエストは、channel (スレッド処理)として実行される。
- validatorは、組み込まれているgo-playground/validatorを使用。
Gorm
- Go製 SQL ORマッパー
- Gormのエラーハンドリング
- structとDBテーブルとマッピングする際は、メタ情報リファレンスを参照する
Zap
- ロガーパッケージ
コーディング規約
インデント
- インデントはタブ一つ
- コミットする前にソースコードのフォーマットを行う。
gofmt -w ./*
- https://pkg.go.dev/cmd/gofmt
命名規則
基本
参考ドキュメント
単体テストコーディング規約
- ファイル名は、
{テスト対象の名前}_test.go
- usecase.goのテストをする場合、同じ階層にusecase_test.goを配置する。
コメント規約
そのままDocコメントになるので、なるべく文章で書く。
参考
ディレクトリ構造
- Goの場合、ファイル名はスネークケースにする。
- ディレクトリ名とパッケージ名と同一にする。
- パッケージ名は省略した単語を連結で良い。(Goの慣例)
参考
ソフトウェアアーキテクチャ
※図を記述中
ソフトウェア階層
コントローラー (Controller)
リクエストデータのバリデード
dto := model.EntityDTO{}
if err := c.Bind(&dto); err != nil {
controller.RespondInvalidRequest(c, err)
return
}
Usecaseに処理を移譲
- errががある場合、
RespondWithError
に処理を移譲し、エラーステータスでレスポンス - 成功した場合、成功ステータスでレスポンス
response, err := controller.usecase.Store(dto)
if err != nil {
controller.RespondWithError(c, err)
return
}
c.JSON(http.StatusCreated, response)
全体のコード
ユースケース (Usecase)
DTOをモデルに変換する。
p := model.ToEntity(dto)
- pointer型であることに注意
DBアクセスするため、Repositoryにデータモデルを渡し、処理を移譲する。
id, err := usecase.Repository.Store(p)
全体のコード
Infrastructure
- 外部システム(DBや外部API)アクセス用のパッケージを配置
- 外部システムがや複雑なデータモデルを持つ場合、破壊対策レイヤーを用意する。
(UsecaseとModel周りに外部アプリケーションの要素が入らないようにする。)
※破損対策レイヤーとは
マイクロサービスでの破壊対策レイヤー
https://docs.microsoft.com/ja-jp/azure/architecture/patterns/anti-corruption-layer
リポジトリ (Repository)
(repo *Repository) Store(m Base) メソッドについて
-
m Base
はEntityなどのデータモデルのインターフェース - Repository structは、repository.Enity structに埋め込み継承される。
//Store
func (repo \*Repository) Store(m Base) (int, error) {
if err := repo.db.Create(m).Error; err != nil {
return 0, errors.Wrapf(err, "database error, method: Store, ID : %d", m.GetID()).DatabaseError()
}
return m.GetID(), nil
}
エラーハンドリングを必ず行う。
errors.Wrapf(err, "database error, method: Store, ID : %d", m.GetID()).DatabaseError()
※errorsの使い方は、エラーハンドリング参照。
FindByIDメソッド と FindFullByIDメソッド の違いについて。
違いは、ERモデルでいうところのモデル(Enitity)に付随する詳細(Attribute)を取得するかどうか。
例
(repo *Entity) FindByID
- Entityのみ取得。
//FindByID
func (repo \*Entity) FindByID(id int) (model.Entity, error) {
-
(repo *Entity) FindFullByID
メソッドを実行- EnitityとEnitityに付随する詳細(Attribute)を一緒に取得
-
FindByID
より負荷がかかる。
//get parent
func (repo \*Entity) FindFullByID(id int) (model.Enitity, error) {
FindByID
を使用した場合、Attributes
パラメータはnil
// Entity - A single entity.
type Entity struct {
Base
Body string \`json:"body,omitempty"\`
Attributes \[\]Attribute \`json:"attributes,omitempty"\`
}
破損対策レイヤーを使用する場合
概要
-
コアモデルを外部モデルにマッピング
- mapperのサブパッケージを使用する。
-
外部処理を行うメソッドに移譲する。
- serviceのサブパッケージを使用する。
-
外部モデルをコアモデルにマッピング
- mapperのサブパッケージを使用する。
extapi/user.go
// Process - 破損対策レイヤーのサンプルを示す。
// コアモデル(アプリケーション固有)と外部モデル(レガシーアプリケーションのモデル)のマッピングを行う。外部モデルの複雑な要素はアプリケーション内部に入れない。
// 外部用のモデルを生成し、レガシーアプリケーション関連の処理を行うメソッドに移譲する。
func (h \*Auth) Process(coreModel model.Base) (model.Base, error) {
//.コアモデルを外部モデルにマッピング
var u mapper.User
// 処理の移譲
\_, err := h.service.Complex(&u)
if err != nil {
return model.Base{}, BuildAPIError(err, "")
}
//.外部モデルをコアモデルにマッピング
var resultCoreModel model.Base
return resultCoreModel, nil
}
API呼び出し
httpパッケージを使用する。
https://pkg.go.dev/net/http
全体のコード
HTTPルーター
認証ミドルウェア
-
リクエストされたAccessKeyのバリデート。
-
認可を行っても良い。
-
結果をContextに保持し、次のミドルウェアに連携する。
リクエストデータのバリデート
-
Controller層で行う。
-
外部からinputされたデータは必ずバリデートする。
バリデート指針
-
リクエストデータをDTOモデルにバインドさせることで、バリデートする。
-
DTOはバリデーション情報をstruct tagとして定義しておく。
-
バインドに失敗した場合、400ステータスのレスポンス。
以下は、func (controller *Attribute) Create(c Context) のコード
dto := model.AttributeDTO{}
if err := c.Bind(&dto); err != nil {
controller.RespondInvalidRequest(c, err)
return
}
DTOで、バインド情報をstruct tag で定義`json:"body,omitempty" binding:"required"`
type AttributeDTO struct {
ID int
Body string \`json:"body,omitempty" binding:"required"\`
EntityID int
}
string `json:"body,omitempty" binding:"required"`
-
required
を指定することで、必須データとして扱う。ない場合、errを発生 - また、string型としてバインドできない場合、errを発生。
validationタグのリファレンス
エラーハンドリング
参考ドキュメント
方針
errorsパッケージ
https://github.com/opqrshun/go-clean-architecture-template/tree/master/pkg/errors
アプリ固有エラーを定義する。
DBアクエス、APIアクセスなど、I/O処理の際は必ずエラーを判定する。
以下、レポジトリ層でDBアクセスを行い、エラーが発生した場合の手順を示す。
- エラーのタイプを判定
- エラーをラッピングし、戻り値として返す。
return errors.Wrapf(err, "database error, method: Store, ID : %d", m.GetID()).DatabaseError()
-
第一引数に発生したerrを渡す
-
第二引数にロギング用のメッセージ
- %dは、第3引数の
m.GetID()
が整数型としてフォーマットされる。
- %dは、第3引数の
DatabaseError()
を実行すると以下のErrorがセットされる
databaseError = Event{
"database\_error", // エラーコード
errorLevel, // errorレベル
http.StatusInternalServerError, // 500
"Something went wrong. please try again", //クライアントに返すメッセージ。
}
- Controllerで、エラーを戻り値として受け取った場合、クライアント側にError用のレスポンスをする
-
ResponseWithError
メソッドに処理を移譲
response, err := controller.usecase.Store(dto)
if err != nil {
controller.ResponseWithError(c, err)
return
}
c.JSON(http.StatusCreated, response)
RespondWithErrorのコード
ラップされた子のエラーメッセージ取り出し。
// If the error is wrapped, get the error message of the child.
var nextMsg string
if next := errors.Unwrap(err); next != nil {
nextMsg = next.Error()
}
定義したアプリケーション固有のエラーコード、エラーメッセージをクライアントにレスポンスする。
c.JSON(aerr.HttpStatus(), H{
"code": aerr.Code(),
"message": aerr.DisplayMessage(),
})
RespondWithError
全体のコード
各エラーメソッド
errors.Errorf("")
https://github.com/opqrshun/go-clean-architecture-template/blob/master/pkg/errors/errors.go
アプリ固有エラーを処理する。
例えば、リクエストデータのバリデーションに失敗したときの、エラー処理。
func (controller \*Controller) RespondInvalidRequest(c Context,err error) {
controller.RespondWithError(c, errors.Errorf("invalid request, err: %v", err).InvalidRequest())
}
- %v で、errオブジェクトがメッセージにフォーマットされる。
-
InvalidRequest
により、Http 400ステータスをセットする。
ロギング
リファレンス
loggerパッケージのzapリファレンスを参照
インターフェース
type Logger interface {
Errorf(format string, args ...interface{})
Errorw(msg string, keysAndValues ...interface{})
}
ロギング方針
- Controller層で、エラーハンドリングする際にロギングする。
- 各階層で生成したエラーオブジェクト (err) はControllerに戻し、
RespondWithError
に渡す。 -
RespondWithError
内では以下のロギングコードが実行されている。
controller.Logger.Errorw("method: RespondWithError", "code", aerr.Code(), "msg", aerr.Error(), "childErr", nextMsg)
ロギング内容
- method: ロギング場所
- code : エラーコード
- msg: 定義したアプリケーション固有のエラーメッセージ
- エラー発生の場所や、Entity IDなどの情報を含む。
- nextMsg : アプリ固有エラーがラッピングした子のエラーのエラーメッセージ。DBエラーの場合、もともと発生した外部パッケージ依存のエラーメッセージ。
ビルド
トップ階層で以下実行
docker-compose --env-file .env.docker -p gobackend up -d
- goサーバー、MySQL, PHPMyAdminのDockerコンテナが起動。
DBのマイグレーションを実行
mysql -h 127.0.0.1 -P 3338 -u gobackend -pgobackend -D gobackend < schema/base.sql
phpmyadminでDBがセットアップされたことを確認
http://localhost:9008/
Docker構成
Goイメージ
-
Dockerイメージを最小限にするため、マルチステージビルドを定義
-
Buildステージ
FROM golang:latest AS build
- Goビルド用ステージ
- ビルド後、アプリケーションBinaryをRuntimeコンテナにコピー
-
Runtimeステージ
FROM scratch AS runtime
- アプリケーションBinaryを実行する最小限の環境
- Dockerリポジトリに登録する最終的なイメージとなる。
Dockerファイル
単体テスト
ローカルテスト
準備
環境変数
DBに依存しているため、godotenvで環境変数を読み込む必要がある。
まずgodotenvのインストール
go get github.com/joho/godotenv/cmd/godotenv
テスト
- 部分的にテストする
※repositoryのテスト
godotenv -f .env.local go test internal/repository/database/\* -count=1 -cover -v
- 全体をテストする
godotenv -f .env.local go test -v ./...
Docker内でテスト
docker exec gobackend\_go\_1 go test -v ./...
モックを使ったテスト
mockgenをインストール
go install github.com/golang/mock/mockgen@v1.6.0
mockgenの使い方
-
-source
でモック生成元のインターフェースファイルを指定する。 -
-destination
でモックファイル作成先を指定する。
mockgen -source=internal/usecase/i\_entity\_repository.go \\
-destination test/mock\_usecase/mock\_entity\_repository.go
モックを使用する
MockをNewする
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := mock.NewMockRepository(ctrl)
EXPECT()でメソッドをモックする。
m.EXPECT().FindByID(1).Return(expectedEntity, nil)
- メソッドのモック。
FindByID
メソッドに1を引数で渡すと、expectedEntity
を返すモック。 - EXPECTでMock対象のメソッドに予め返す値を設定しておく。
- Mock対象のメソッドが呼ばれなかったら、エラーとなる。
- 呼ばれないメソッドは、Mockしなくても良い。
全体のコード
CICD
Github Actionで、ECSにデプロイする。
-
task-definition-dev.json
- 開発環境用ECSタスク定義ファイル
- task-definition-prod.json
参考ドキュメント
終わりに
コード量が多くなってくるとクリーンアーキテクチャを使用したとしても煩雑になってしまいました。
次にGoでバックエンド構築する際は、生成系AIも使用しつつ開発しやすい環境になるよう工夫したいと思います。
Discussion