LoginSignup
0
0

Go+Gin+GORM開発環境で食品管理APIを作る~クリーンアーキテクチャ編~

Posted at

はじめに

GO開発環境におけるクリーンアーキテクチャについて、実際にこちらの記事で作成したAPIの構成を見直してみました。

クリーンアーキテクチャとは

こちらに詳しい説明があります。

複雑なことを言っているように見えますが、複雑です。

ざっくり、

  • ソフトウェアはレイヤーに分割して責任を分界する
  • ソフトウェアは抽象し、依存する
  • 制御の流れと依存関係は逆転する

というポイントを抑えつつ、さっそくソースをがっつり修正していきます。

クリーンアーキテクチャの実現

ソース修正の前に、クリーンアーキテクチャ実現に向けて依存性の注入(DI:Dependency Injection)を行うため、今回はwire(DI用のツール)を用いています。

開発環境を構築する際に以下のコマンドでインストールをしておきましょう

$ go install github.com/google/wire/cmd/wire@latest

ディレクトリ構成

最終的には次のようになります。

.
├── Dockerfile
├── docker-compose.yml
├── domain
│   └── repository
│       └── cooking_repository.go
├── go.mod
├── go.sum
├── handler
│   ├── api_route.go
│   ├── rest
│   │   └── cooking_handler.go
│   ├── wire.go
│   └── wire_gen.go
├── library
│   └── database
│       └── database.go
├── main.go
├── tmp
└── usecase
    └── cooking_usecase.go

実行条件

Golang v1.22.3
Gin v1.10.0
GORM v1.25.10

air v1.52.0
wire v0.6.0

MySQL 8.0.29

ソースコード

処理の中身は93%くらいこちらの記事と同じですが、

  • ディレクトリ構成を変えている
  • クリーンアーキテクチャ実現のための依存性の注入を行っている

このため、様々な変更が入っています。

さて、これまでならmain → api_route → cooking_handler → cooking_usecase → cooking_repositoryというように制御の流れに沿って説明をしてきましたが (してないかもしれないけれど) 、クリーンアーキテクチャでは 制御の流れと依存関係は逆転するため、逆順でソースコードを見ていきます。

ライブラリ

今回ライブラリに再配置したデータベース接続の処理は、リポジトリに渡す(若干不正確な説明)ため、先に記載します。

./library/database/database.go
package database

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func Connect() *gorm.DB {
    //DB接続
    dsn := "root@tcp(mysql:3306)/local_db?charset=utf8mb4&parseTime=True&loc=Local"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

    if err != nil {
        panic("failed to connect database")
    }
    return db
}

Init処理は不要になったので削除しました。

リポジトリ

過去記事ではmodelとして扱っていた部分になります。

./domain/repository/cooking_repository.go
package repository

import (
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
)

type Ingredient struct {
    Id int
    IngredientName   string
    Foods []Food `gorm:"many2many:ingredients_foods;"`
}

type Food struct {
    Id int
    FoodName string
    Ingredients []Ingredient `gorm:"many2many:ingredients_foods;"`
}

type IngredientsFoods struct {
    IngredientId int `gorm:"primaryKey"`
    FoodId int `gorm:"primaryKey"`
}

type CookingRepository interface {
    GetAllTheFoodRepository() ([]Food, error)
    GetOneFoodRepository(food_name string, preload_flag bool) (Food, error)
    RegisterForFoodRepository(food_name string, ingredients []string) error
    UpdateFoodRepository(food Food, ingredients []string) error
    DeleteFoodRepository(food Food) error
}

func NewCookingRepository(db *gorm.DB) CookingRepository {
    return &cookingRepositoryImpl{
        db: db,
    }
}

type cookingRepositoryImpl struct {
    db *gorm.DB
}

func (r *cookingRepositoryImpl) GetAllTheFoodRepository() ([]Food, error) {
    foods := []Food{}
    err := r.db.Debug().
        // Preload("Ingredients").
        Find(&foods).Error
    return foods, err
}

func (r *cookingRepositoryImpl) GetOneFoodRepository(food_name string, preload_flag bool) (Food, error) {
    food := Food{}
    queryDB := r.db.Debug()
    if preload_flag {
        queryDB = queryDB.Preload("Ingredients")
    }
    err := queryDB.Where("foods.food_name = ?", food_name).First(&food).Error

    return food, err
}

func (r *cookingRepositoryImpl) RegisterForFoodRepository(food_name string, ingredients []string) (error) {
    food := Food{FoodName: food_name}
    // トランザクション処理
    err := r.db.Transaction(func(tx *gorm.DB) error {
        // 食品登録
        t_err := error(nil)
        if t_err = tx.Debug().Create(&food).Error; t_err != nil {
            return t_err
        }
        // 食材登録
        db_ingredients := []Ingredient{}

        if db_ingredients, t_err = UpsertIngredients(tx, ingredients); t_err != nil {
            return t_err
        }

        // 中間テーブルの登録
        tx.Debug().Model(&food).Association("Ingredients").Append(db_ingredients)

        return nil
    })
    return err
}

func (r *cookingRepositoryImpl) UpdateFoodRepository(food Food, ingredients []string) (error) {
    // トランザクション処理
    err := r.db.Transaction(func(tx *gorm.DB) error {

        // 食材登録・更新
        db_ingredients, t_err := UpsertIngredients(tx, ingredients);
        if t_err != nil {
            return t_err
        }
        // 中間テーブルの登録
        tx.Debug().Model(&food).Association("Ingredients").Replace(db_ingredients)
        return nil;
    })
    return err
}

func (r *cookingRepositoryImpl) DeleteFoodRepository(food Food) (error) {
    err := r.db.Transaction(func(tx *gorm.DB) error {
        // 中間テーブルと食品を削除
        if t_err := tx.Debug().Select(clause.Associations).Delete(&food).Error; t_err != nil {
            return t_err
        }
        return nil
    })
    return err
}

// 食材の登録(既に存在していれば上書き)
func UpsertIngredients(tx *gorm.DB, ingredients []string) (db_ingredients []Ingredient, err error) {
    for _, ingredient := range ingredients {
        // 食材が未登録であれば追加
        db_ingredient := Ingredient{}
        tx.Debug().Where("ingredient_name = ?", ingredient).First(&db_ingredient)

        db_ingredient.IngredientName = ingredient

        // 食材テーブルにUpsert
        if err = tx.Debug().Save(&db_ingredient).Error; err != nil {
            return
        }
        db_ingredients = append(db_ingredients, db_ingredient)
    }
    return
}

依存性の注入を行うため、次のインターフェイスと構造体と依存性注入用の処理を実装しています。

// インターフェイス
type CookingRepository interface {
    GetAllTheFoodRepository() ([]Food, error)
    GetOneFoodRepository(food_name string, preload_flag bool) (Food, error)
    RegisterForFoodRepository(food_name string, ingredients []string) error
    UpdateFoodRepository(food Food, ingredients []string) error
    DeleteFoodRepository(food Food) error
}

// 依存性注入用の処理。DBの接続情報を受け取る。
func NewCookingRepository(db *gorm.DB) CookingRepository {
    return &cookingRepositoryImpl{
        db: db,
    }
}

type cookingRepositoryImpl struct {
    db *gorm.DB
}

各処理ではインターフェイスを宣言しています。

func (r *cookingRepositoryImpl) GetAllTheFoodRepository() ([]Food, error) {
    /*処理*/
}

ユースケース

過去記事ではserviceとして扱っていた部分になります。

./usecase/cooking_usercase.go
package usecase

import (
    "gorm.io/gorm"
    "errors"
    "fmt"
    "go_project/domain/repository"
)

type FoodsResponse struct {
    FoodName string
    IngredientList []string
}

type CookingUsecase interface {
    GetFoodsUsecase() ([]string, error)
    GetOneFoodUsecase(food_name string, preload_flag bool) (food FoodsResponse, err error)
    RegisterForFoodUsecase(food_name string, ingredients []string) (food FoodsResponse, err error)
    UpdateFoodUsecase(food_name string, ingredients []string) (food FoodsResponse, err error)
    DeleteFoodUsecase(food_name string) (err error)
}

func NewCookingUsecase(repo repository.CookingRepository) CookingUsecase {
    return &cookingUsecaseImpl{
        repo: repo,
    }
}

type cookingUsecaseImpl struct {
    repo repository.CookingRepository
}

func (s *cookingUsecaseImpl) GetFoodsUsecase() ([]string, error) {
    // 全件取得
    datas, err := s.repo.GetAllTheFoodRepository()
    if err != nil {
        return nil, fmt.Errorf("予期せぬエラーが発生しました。")
    }
    var foods []string
    for _, data := range datas {
        foods = append(foods, data.FoodName)
    }
    return foods, nil
}

func (s *cookingUsecaseImpl) GetOneFoodUsecase(food_name string, preload_flag bool) (FoodsResponse, error) {

    food := FoodsResponse{}

    // データ取得
    data, err := s.repo.GetOneFoodRepository(food_name, preload_flag)
    if err != nil {
        if err.Error() == "record not found" {
            err = fmt.Errorf("データが見つかりませんでした。")
            return food, err
        }
    }
    // データの整形
    food.FoodName = data.FoodName
    var ingredient_list []string
    for _, ingredient := range data.Ingredients {
        ingredient_list = append(ingredient_list, ingredient.IngredientName)
    }
    food.IngredientList = ingredient_list

    return food, nil
}

func (s *cookingUsecaseImpl) RegisterForFoodUsecase(food_name string, ingredients []string) (FoodsResponse, error) {

    food := FoodsResponse{}

    // 存在確認
    data, err := s.repo.GetOneFoodRepository(food_name, false);
    if data.Id != 0 {
        err = fmt.Errorf("その食品は既に登録されています。")
        return food, err
    }
    // レコード0件以外はエラーとする
    if !errors.Is(err, gorm.ErrRecordNotFound) {
        return food, err
    }

    // 食品、食材の登録
    if err = s.repo.RegisterForFoodRepository(food_name, ingredients); err != nil {
        return food, err
    }
    // 登録結果を再取得
    food, err = s.GetOneFoodUsecase(food_name, true)
    return food, err
}

func (s *cookingUsecaseImpl) UpdateFoodUsecase(food_name string, ingredients []string) (FoodsResponse, error) {

    food := FoodsResponse{}

    // 存在確認
    data, err := s.repo.GetOneFoodRepository(food_name, false);
    if err != nil {
        // 未登録の場合はエラー制御する
        if errors.Is(err, gorm.ErrRecordNotFound) {
            err = fmt.Errorf("その食品は登録されていません。")
            return food, err
        }
        return food, err
    }
    // 食品、食材の更新
    if err = s.repo.UpdateFoodRepository(data, ingredients); err != nil {
        return food, err
    }
    // 更新結果を再取得
    food, err = s.GetOneFoodUsecase(food_name, true)

    return food, err
}

func (s *cookingUsecaseImpl) DeleteFoodUsecase(food_name string) (error) {
    // 存在確認
    data, err := s.repo.GetOneFoodRepository(food_name, false);
    if err != nil {
        // 未登録の場合はエラー制御する
        if errors.Is(err, gorm.ErrRecordNotFound) {
            err = fmt.Errorf("その食品は登録されていません。")
            return err
        }
        return err
    }
    // 食品削除
    if err = s.repo.DeleteFoodRepository(data); err != nil {
        return err
    }
    return err
}

ユースケースの修正点もリポジトリとほぼ同じですね

// インターフェイス
type CookingUsecase interface {
    GetFoodsUsecase() ([]string, error)
    GetOneFoodUsecase(food_name string, preload_flag bool) (food FoodsResponse, err error)
    RegisterForFoodUsecase(food_name string, ingredients []string) (food FoodsResponse, err error)
    UpdateFoodUsecase(food_name string, ingredients []string) (food FoodsResponse, err error)
    DeleteFoodUsecase(food_name string) (err error)
}

// 依存性の注入の処理。リポジトリの情報を受け取る
func NewCookingUsecase(repo repository.CookingRepository) CookingUsecase {
    return &cookingUsecaseImpl{
        repo: repo,
    }
}

type cookingUsecaseImpl struct {
    repo repository.CookingRepository
}

依存性注入部分の処理に注目です。リポジトリのときはDB情報を受け取っていましたが、ユースケースではリポジトリ情報を受け取っています。これが制御の流れと依存関係の逆転ですね。
(つまり次に説明するハンドラーはユースケースの情報を受け取るということ)

こちらもインターフェイスに処理を定義していますので、処理側にインターフェイスを宣言します。

この部分
func (s *cookingUsecaseImpl) GetFoodsUsecase() ([]string, error) {/*処理*/}

ハンドラー

過去記事ではcontrollerとして扱っていた部分になります。

./handler/rest/cooking_handler.go
package rest

import (
    "github.com/gin-gonic/gin"
    "net/http"
    "go_project/usecase"
)

// 食品登録APIのリクエスト
type RegisterForFoodRequest struct {
    Food string `json:"food_name" binding:"required"`
    Ingredients []string `json:"ingredients_name" binding:"required,min=1,dive,gte=1"`
}

// 食品更新APIのリクエスト
type UpdateFoodRequest struct {
    Ingredients []string `json:"ingredients_name"`
}

type CookingHandler interface {
    GetOneFoodHandler(c *gin.Context)
    GetFoodsHandler(c *gin.Context)
    RegisterForFoodHandler(c *gin.Context)
    UpdateFoodHandler(c *gin.Context)
    DeleteFoodHandler(c *gin.Context)
}

func NewCookingHandler(usecase usecase.CookingUsecase) CookingHandler {
    return &cookingHandlerImpl{
        usecase: usecase,
    }
}

type cookingHandlerImpl struct {
    usecase usecase.CookingUsecase
}

// 食品取得
func (h *cookingHandlerImpl) GetOneFoodHandler(c *gin.Context) {
    food_name := c.Param("foodname")

    // データ取得
    data, err := h.usecase.GetOneFoodUsecase(food_name, true);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}

func (h *cookingHandlerImpl) GetFoodsHandler(c *gin.Context) {

    // データ取得
    datas, err := h.usecase.GetFoodsUsecase();
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "datas" : datas,
    })
}


func (h *cookingHandlerImpl) RegisterForFoodHandler(c *gin.Context) {

    // リクエストの解析
    var json RegisterForFoodRequest
    if err := c.ShouldBindJSON(&json); err != nil {
        MakeErrorJson(c, err)
        return
    }

    // データ登録
    data,err := h.usecase.RegisterForFoodUsecase(json.Food, json.Ingredients);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}


// 食品更新
func (h *cookingHandlerImpl) UpdateFoodHandler(c *gin.Context) {
    food_name := c.Param("foodname")

    // リクエストの解析
    var json UpdateFoodRequest
    if err := c.ShouldBindJSON(&json); err != nil {
        MakeErrorJson(c, err)
        return
    }

    // データ更新
    data,err := h.usecase.UpdateFoodUsecase(food_name, json.Ingredients);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}

// 食品削除
func (h *cookingHandlerImpl) DeleteFoodHandler(c *gin.Context) {
    food_name := c.Param("foodname")

    // データ削除
    err := h.usecase.DeleteFoodUsecase(food_name);
    if err != nil {
        MakeErrorJson(c, err)
        return
    }
    c.JSON(http.StatusOK, gin.H{})
}

// エラー時に返却するJSON
func MakeErrorJson(c *gin.Context, err error) {
    c.JSON(http.StatusBadRequest, gin.H{
        "error" : err.Error(),
    })
}

先ほどユースケースのところで記載しましたが、ハンドラーの依存性の注入用処理はユースケースを受け取ります。

// インターフェイス
type CookingHandler interface {
    GetOneFoodHandler(c *gin.Context)
    GetFoodsHandler(c *gin.Context)
    RegisterForFoodHandler(c *gin.Context)
    UpdateFoodHandler(c *gin.Context)
    DeleteFoodHandler(c *gin.Context)
}

// 依存性の注入。ユースケース情報を受け取る
func NewCookingHandler(usecase usecase.CookingUsecase) CookingHandler {
    return &cookingHandlerImpl{
        usecase: usecase,
    }
}

type cookingHandlerImpl struct {
    usecase usecase.CookingUsecase
}

こちらもインターフェイスに処理を定義していますので、処理側にインターフェイスを宣言します。

この部分
func (h *cookingHandlerImpl) GetOneFoodHandler(c *gin.Context) {/*処理*/}

DI用ツールの準備と生成

ハンドラー、ユースケース、リポジトリで依存性の注入に関する実装が終わりましたら、次は wire.go を書いていきます。
もしまだ、wireをインストールしていなければ、次のコマンドで事前にwireをインストールしておきましょう。

インストールコマンド(goプロジェクト内で行う)
$ go install github.com/google/wire/cmd/wire@latest
./handler/wire.go
// +build wireinject

package handler

import (
    "github.com/google/wire"
    "go_project/handler/rest"
    "go_project/usecase"
    "go_project/library/database"
    "go_project/domain/repository"
)

func InitializeCookingHandler() rest.CookingHandler {
    wire.Build(
        database.Connect,
        repository.NewCookingRepository,
        usecase.NewCookingUsecase,
        rest.NewCookingHandler,
    )
    return nil
}

何をしているかというと、CookingHandlerに関する依存性の注入の初期化を行うための情報を記載しています。

その後、以下のコマンドを実行します。

$ cd ./handler
$ wire gen

すると、wire_gen.goが作られると思います。

./handler/wire_gen.go
// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package handler

import (
	"go_project/domain/repository"
	"go_project/handler/rest"
	"go_project/library/database"
	"go_project/usecase"
)

// Injectors from wire.go:

func InitializeCookingHandler() rest.CookingHandler {
	db := database.Connect()
	cookingRepository := repository.NewCookingRepository(db)
	cookingUsecase := usecase.NewCookingUsecase(cookingRepository)
	cookingHandler := rest.NewCookingHandler(cookingUsecase)
	return cookingHandler
}

これでDIの準備は完了です。
最後にルーティングを記載します。

./handler/api_route.go
package handler

import (
    "github.com/gin-gonic/gin"
)

func GetApiRouter() *gin.Engine {
    r := gin.Default()

    // グループ化
    v1 := r.Group("/v1")
    {
        food := v1.Group("/food")
        {
            // wire_gen.goで実装された処理
            rest_cooking := InitializeCookingHandler()

            // 食品全件取得API
            food.GET("/", rest_cooking.GetFoodsHandler)
            // 食品取得API
            food.GET("/:foodname", rest_cooking.GetOneFoodHandler)
            // 食品登録API
            food.POST("/", rest_cooking.RegisterForFoodHandler)
            // 食品更新API
            food.PUT("/:foodname", rest_cooking.UpdateFoodHandler)
            // 食品削除API
            food.DELETE("/:foodname", rest_cooking.DeleteFoodHandler)
        }
    }

    return r
}

wire_gen.goにて実装されたInitializeCookingHandler()を用いることで、cooking_handler.goで実装した各処理が利用可能になりました。
もしこれを用いない場合は、次のような実装が必要になります。

...
import (
	"go_project/domain/repository"
	"go_project/handler/rest"
	"go_project/library/database"
	"go_project/usecase"
)

...

            repo := repository.NewCookingRepository(database.Connect())
            uc := usecase.NewCookingUsecase(repo)
            handler := rest.NewCookingHandler(uc)

            // 食品全件取得API
            food.GET("/", handler.GetFoodsHandler)
...

数が少ないうちは良いですが、開発が大規模になると管理が大変になりますので、ケースバイケースですが、導入してみても良いのかもしれませんね。

main.go

最後にmain.goですが、これはimportとpackegeの指定を変更するだけですね。

main.go
package main

import (
    "go_project/handler"
)

func main() {
    router := handler.GetApiRouter()

    router.Run(":8080")
}

動作確認

エラーがなければ最後に動作確認をしてみましょう。

動作確認はこちらの記事と同様です。

まとめ

いかがだったでしょうか。

以前の構成よりも複雑になっただけでは? という気がしないでもないですが (実際複雑になったと筆者は感じていますが) 、実はこれ、テストを行う際にはかなり有用な構成となっているんです。

「ソフトウェアはレイヤーに分割して責任を分界する」

作成したソースコードはテストしないといけませんよね?

しかし、例えばユースケースのソースコードの試験をしようとしたとき、「ユースケースの処理は内部でリポジトリの処理を呼び、リポジトリの処理はDBアクセスをする」ことになるため、結果的にリポジトリの処理も試験してしまうことになりますよね。

責任を分界するというのは、"ユースケースの実装者にとってリポジトリの実装内容がどうであろうと、リポジトリが期待通りの動作をするのであれば想定通りの結果になる、という前提のもとでユースケースを実装する"、ということです。

具体的には、次の記事でユースケースのテストケースを実装してみようと思いますので、そちらで。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0