🦁

Goとクリーンアーキテクチャでバックエンド構築

2024/02/21に公開

概要

今年Goを使用する機会があり、
以前、とある開発でバックエンドを設計したときのことを思い出すために記述しています。
主にGoでバックエンド構築したときのソフトウェア構造の話です。
開発時期: 2020年〜

ソース概要

Goとクリーンアーキテクチャで、バックエンドのテンプレートを用意しました。
https://github.com/opqrshun/go-clean-architecture-template

以下を用意しています。(詳細は後述)

  • Docker
  • 基本的なデータモデルのCRUD
  • ロギング
  • エラーハンドリング
  • DB OR マッパー
  • 単体テスト

Goパッケージのリファレンス

Clone

git clone https://github.com/opqrshun/go-clean-architecture-template

使用ライブラリ

Gin

  • httprouterをベースとするHTTPフレームワーク
  • 各リクエストは、channel (スレッド処理)として実行される。
  • validatorは、組み込まれているgo-playground/validatorを使用。

Gorm

Zap

  • ロガーパッケージ

コーディング規約

インデント

  • インデントはタブ一つ
  • コミットする前にソースコードのフォーマットを行う。

命名規則

基本

参考ドキュメント
https://go.dev/talks/2014/names.slide#1

単体テストコーディング規約

  • ファイル名は、{テスト対象の名前}_test.go
  • usecase.goのテストをする場合、同じ階層にusecase_test.goを配置する。

コメント規約

そのままDocコメントになるので、なるべく文章で書く。

参考
https://golang.org/doc/effective_go#commentary

ディレクトリ構造

  • Goの場合、ファイル名はスネークケースにする。
  • ディレクトリ名とパッケージ名と同一にする。
  • パッケージ名は省略した単語を連結で良い。(Goの慣例)

参考
https://github.com/golang-standards/project-layout/tree/master

ソフトウェアアーキテクチャ

※図を記述中

ソフトウェア階層

コントローラー (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タグのリファレンス

エラーハンドリング

参考ドキュメント
https://go.dev/blog/error-handling-and-go

方針

errorsパッケージ

https://github.com/opqrshun/go-clean-architecture-template/tree/master/pkg/errors

アプリ固有エラーを定義する。
DBアクエス、APIアクセスなど、I/O処理の際は必ずエラーを判定する。
以下、レポジトリ層でDBアクセスを行い、エラーが発生した場合の手順を示す。

  1. エラーのタイプを判定
  2. エラーをラッピングし、戻り値として返す。
return errors.Wrapf(err, "database error, method: Store, ID : %d",  m.GetID()).DatabaseError()
  • 第一引数に発生したerrを渡す

  • 第二引数にロギング用のメッセージ

    • %dは、第3引数のm.GetID() が整数型としてフォーマットされる。

DatabaseError() を実行すると以下のErrorがセットされる

	databaseError = Event{
		"database\_error", // エラーコード
		errorLevel, // errorレベル
		http.StatusInternalServerError, // 500 
		"Something went wrong. please try again", //クライアントに返すメッセージ。
	}
  1. 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リファレンスを参照
https://pkg.go.dev/go.uber.org/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

テスト

  1. 部分的にテストする

※repositoryのテスト

godotenv -f .env.local go test internal/repository/database/\* -count=1 -cover -v
  1. 全体をテストする
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にデプロイする。

https://docs.github.com/ja/actions/use-cases-and-examples/deploying/deploying-to-amazon-elastic-container-service

参考ドキュメント

https://golang.org/doc/effective_go
https://go.dev/wiki/CodeReviewComments
https://blog.golang.org/godoc

終わりに

コード量が多くなってくるとクリーンアーキテクチャを使用したとしても煩雑になってしまいました。
次にGoでバックエンド構築する際は、生成系AIも使用しつつ開発しやすい環境になるよう工夫したいと思います。

Discussion