🦔

GolangとClean ArchitectureでAPIサーバーを構築してみた

2022/04/27に公開

目的

どうも、@yanteraです。
Goで実際にClean Architectureで開発する場合どのような構築をすれば、開発しやすいのか設計してみました。
実践を想定して構築を考えています。APIドキュメントの代わりにsawaggerを実装しています。

使用するツール

  • docker
  • echo(air)
  • gorm
  • swagger
  • goa

ディレクトリ構成

├─ docker/
    ├─ api
        └─ environments
            ├─ production/
            ├─ development/
            └─ test/
    ├─ document
    └─ mysql 
├─ documents/
    └─ design/
├─ server/
    ├─ adapters/
        ├─ controllers/
        ├─ gateways/
        └─ presenter/
    ├─ infrastructure/
        ├─ routes/
        └─ database/
    ├─ usecases/
    ├─ entities/
    └─ main.go
└─ docker-compose.development.yml

環境について

docker-composeでコンテナを管理しています。コンテナの内容としては以下になります。

  • API server
    • Clean Architectureを使用してAPIサーバーを構築します
      • echo + air + gormを採用
        • developmentでは開発効率向上の為にairを採用
  • Database
    • データの永続化を行います
      • MySQL8.0を採用
  • Swagger UI
    • SwaggerのUIを構築します
  • Swagger Editor
    • openapi.yamlを作成・更新します
      • goaを採用

詳細についてはdocker-compose.development.ymlやdockerディレクトリを確認してください。初期データ用のsqlファイルも作成しています。
https://github.com/yantera/clean-architecture-for-go

Clean Architectureについて

今回はクリーンアーキテクチャに沿ってどのようなディレクトリ構成でどのような役割を振っているかのみ説明しています。クリーンアーキテクチャについてはご自身で調べてください。

Adapters

このディレクトリでは外部と通信する為の処理を実装しています。

  • Controllers
    • Infrastructuresで受けたリクエストを受け取ります。
      • リクエストを受け取った後、interactorに処理を投げるための初期化も行います。
search.go
package guest

import (
	gateway "api/server/adapters/gateways"
	usecase "api/server/usecases/guest"
	"github.com/labstack/echo/v4"
	"strconv"
)

type GuestController struct {
	Interactor usecase.GuestInteractor
}

func InitGuestController(sqlHandler gateway.SQLHandler) *GuestController {
	return &GuestController{
		Interactor: usecase.GuestInteractor{
			GuestPort: &gateway.GuestRepository{
				SQLHandler: sqlHandler,
			},
		},
	}
}

func (controller *GuestController) Show(c echo.Context) (err error) {
	id, _ := strconv.Atoi(c.Param("id"))
	guest, err := controller.Interactor.GuestByID(id)
	if err != nil {
		c.JSON(500, err)
		return
	}
	c.JSON(200, guest)
	return
}

func (controller *GuestController) Index(c echo.Context) (err error) {
	guests, err := controller.Interactor.Guests()
	if err != nil {
		c.JSON(500, err)
		return
	}
	c.JSON(200, guests)
	return
  • Gateways
    • 外部ツールと繋ぐためのinterfaceになります。
      • 基本的にはinterfaceの定義とinterfaceを介して外部のメソッドを実行する処理を定義します。
guest_repository.go
package gateways

import (
	"api/server/entities"
)

type GuestRepository struct {
	SQLHandler
}

func (repo *GuestRepository) FindByID(id int) (guest entities.Guest, err error) {
	if err = repo.Find(&guest, id).Error; err != nil {
		return
	}
	return
}

func (repo *GuestRepository) FindAll() (guests entities.Guests, err error) {
	if err = repo.Find(&guests).Error; err != nil {
		return
	}
	return
}
sqlhandler.go
package gateways

import (
	"gorm.io/gorm"
)

type SQLHandler interface {
	First(interface{}, ...interface{}) *gorm.DB
	Last(interface{}, ...interface{}) *gorm.DB
	Take(interface{}, ...interface{}) *gorm.DB
	Find(interface{}, ...interface{}) *gorm.DB
}
  • Presenter
    • 外部にアウトプットする際のデータをフォーマットします。
      • 定義はしていますが今回は未使用です。

Infrastructures

このディレクトリではアプリケーション外部へ通信するための処理を実装します。

  • Routes
    • ルーティングを行います。

Routesはルーティングが増えると可読性が悪くなるので、ディレクトリを切ってファイル分割出来るようにしています。やり過ぎだと思う方はroute.goだけで良いと思います。

├─ infrastructure/
    ├─ routes/
        └─ guest.go
    └─ route.go
route.go
package infrastructure

import (
	"api/server/infrastructure/routes"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func Run() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(middleware.CORS())

	routes.InitGuest(e)

	e.Logger.Fatal(e.Start(":1323"))
}
guest.go
package routes

import (
	controller "api/server/adapters/controllers/guest"
	"api/server/infrastructure/database"
	"github.com/labstack/echo/v4"
)

func InitGuest(e *echo.Echo) {
	guestController := controller.InitGuestController(database.InitSQLHandler())

	e.GET("/guests/:id", func(c echo.Context) error { return guestController.Show(c) })
	e.GET("/guests", func(c echo.Context) error { return guestController.Index(c) })
}
  • Database
    • データベースへアクセスするための初期設定を行います。
sql_handler.go
package database

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"os"
	"time"

	"api/server/adapters/gateways"
)

type SQLHandler struct {
	db *gorm.DB
}

func InitSQLHandler() gateways.SQLHandler {
	user := os.Getenv("DB_USER")
	pass := os.Getenv("DB_PASSWORD")
	containerName := os.Getenv("DB_CONTAINER_NAME")
	port := os.Getenv("DB_PORT")
	dbName := os.Getenv("DB_NAME")

	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
		logger.Config{
			SlowThreshold:             time.Second, // Slow SQL threshold
			LogLevel:                  logger.Info, // Log level
			IgnoreRecordNotFoundError: true,        // Ignore ErrRecordNotFound error for logger
			Colorful:                  false,       // Disable color
		},
	)

	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, pass, containerName, port, dbName)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: newLogger,
	})
	if err != nil {
		panic(err)
	}
	sqlHandler := new(SQLHandler)
	sqlHandler.db = db
	return sqlHandler
}

func (handler *SQLHandler) First(out interface{}, where ...interface{}) *gorm.DB {
	return handler.db.First(out, where...)
}

func (handler *SQLHandler) Take(out interface{}, where ...interface{}) *gorm.DB {
	return handler.db.Take(out, where...)
}

func (handler *SQLHandler) Last(out interface{}, where ...interface{}) *gorm.DB {
	return handler.db.Last(out, where...)
}

func (handler *SQLHandler) Find(out interface{}, where ...interface{}) *gorm.DB {
	return handler.db.Find(out, where...)
}

Usecases

  • EntitiesやGatewaysを呼ぶ処理を実装します。
    • 個人的には所謂サービスと似たような役割だと思っています。

あえてEntity毎にディレクトリを分けています。すべてのUsecaseを呼ぶのではなく、必要なUsecaseのみ呼べるようにしたいからこのように作成しています。

usecases/
    └─ guest
        ├─ interactor.go
        └─ port.go
interactor.go
package guest

import (
	"api/server/entities"
)

type GuestInteractor struct {
	GuestPort GuestPort
}

func (interactor *GuestInteractor) GuestByID(id int) (guest entities.Guest, err error) {
	guest, err = interactor.GuestPort.FindByID(id)
	return
}

func (interactor *GuestInteractor) Guests() (guests entities.Guests, err error) {
	guests, err = interactor.GuestPort.FindAll()
	return
}
port.go
package guest

import (
	"api/server/entities"
)

type GuestPort interface {
	FindByID(id int) (entities.Guest, error)
	FindAll() (entities.Guests, error)
}

Entities

  • ドメインロジックを実装します。
    • 色んな会社のドメインがあるので一概に言えませんが、強いて言うなら何のデータを作成・更新・読込み・削除をしたいのか考えてください。
guest.go
package entities

type Guests []Guest

type Guest struct {
	ID            int    `json:"id" param:"id"`
	FirstName     string `json:"first_name"`
	FirstNameKana string `json:"first_name_kana"`
	LastName      string `json:"last_name"`
	LastNameKana  string `json:"last_name_kana"`
	Gender        int    `json:"gender"`
	Birthday      string `json:"birthday"`
	Tel           int    `json:"tel"`
	Email         string `json:"email"`
}

最後に

ここまで読んで下さりありがとうございます。
あくまで今回の実装は考えの一つだと思っています。良いアイディアや改善点があるなと思う方は意見を頂けますと幸いです。

今回の構成はこのページにあるので、興味がある方は是非試してみてください。
https://github.com/yantera/clean-architecture-for-go

GitHubで編集を提案

Discussion