4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

サイバーエージェント24卒内定者Advent Calendar 2023

Day 9

Golangを用いたゲームAPIの開発におけるアーキテクチャ

Last updated at Posted at 2023-12-08

はじめに

この記事はサイバーエージェント24卒内定者 Advent Calendarの9日目です。

こんにちは、都内在住の大学生をしているDainaです!普段はゲームの開発や研究をしています。
今回はDDDで構築されたGolangのゲームAPIに自動生成を組み合わせたアーキテクチャについて解説します!

本記事で扱うプロジェクトはゲーム開発におけるのサーバー負荷と戦う工夫でも解説しているため、興味のある方は是非ご覧ください!

プロジェクト構成

  • 言語:Golang
  • フレームワーク: Echo
  • DB: MySQL
  • ORM: gorm
  • キャッシュ:go-cache

※重要なディレクトリのみ記載

.
├─ api
|  ├─ di: wire
|  └─ presentation
|     ├─ controller: ハンドリングを記述する
|     ├─ middleware
|     ├─ request: 自動生成
|     ├─ response: 自動生成
|     └─ router: ルーティングを記述する
├─ docs
|  ├─ api: yamlを記述するとrequest, responseが自動生成される
|  ├─ entity: yamlを記述するとentity, repository, daoが自動生成される
│  └─ enum: yamlを記述するとenumが自動生成される
├─ domain
|  ├─ entity: 自動生成
|  ├─ enum: 自動生成
|  ├─ repository: 自動生成
│  └─ service: ここにロジックを記述する
└─ infra
   └─ dao: 自動生成
  • 本記事で扱うコード

依存関係と開発時のポイント

image.png

本記事で解説するアーキテクチャは一般的なレイヤードアーキテクチャをベースにしたDDDです。アーキテクチャを組む際には下記を意識しています。

  • レイヤーの責務を意識した依存関係
  • DBとDomainの責務を考える
  • Service同士の依存関係は場合によっては許容する
  • 自動生成できる部分は任せる

レイヤーの責務を意識した依存関係

DDDをやる上で初心者が陥りやすいポイントに、脳死で上層から下層に処理を記述してしまう点が挙げられます。

例えば...

  • Controller:ハンドリングする層
  • Service:複雑な処理を記述する層
  • Repository, Dao:DBとのやりとりをする層

のように捉えてしまい、肝心なドメインロジックでServiceだけ肥大化してしまう例が挙げられます。そういった思想で組まれたアーキテクチャなら問題ないのですが...

本記事で扱うプロジェクトでは単一の構造体だけで済むロジックはできるだけEntityロジック(model)やprivateメソッドに分散させて、 ServiceのPublicメソッドはあくまでロジックの抽象化を行う層と定義しています。

DBとDomainの責務を考える

ゲームのサーバーサイドでは1台のサーバーに複数のDBが接続されています。これらのDBとドメインロジックが正しい依存関係になっているかを考えて実装する必要があります。

  • User DB:ユーザーデータや更新系のステータスを格納する
  • Master DB:アイテムやキャラ情報などのゲーム内要素を格納する
  • Admin DB:管理用DB

Service同士の依存関係は場合によっては許容する

これは賛否分かれる内容ですが、本記事で扱うプロジェクトではService同士の依存関係を許容しています。この判断にはデメリットも存在するため注意が必要です。

メリット
  • アイテム取得や報酬受け取りなどの頻出する共通ロジックを最小限のコードで実装できる
  • 頻出する共通ロジックの変更に強い
    • itemやactionなどの頻出する共通ロジックに変更があると、関連するServiceロジックも変更が反映されます。一方でロジックが適切に分断されていないと、逆に変更の影響範囲が大きくなるだけでデメリットにもなります。
  • DBのテーブル群をServiceを起点に分断できる
    • item, item_boxはitemパッケージへ
    • itemから他のマスターを呼びたいときは、呼びたいマスターのServiceロジックから取得する
    • ロジカルでわかりやすい依存関係になる
デメリット
  • 影響範囲が大きくなることがある
    • メリットに挙げた「頻出する共通ロジックの変更に強い」と表裏一体のデメリット
    • 厳格な設計と見通しが反映されていれば発生しない
  • 直感的ではない
    • 複数のServiceを経由する場合、具体的な処理が書いてあるメソッドまでの道のりが長い

自動生成できる部分は任せる

これがDDDを行う上で最大のメリットだと思います。自動生成を導入することで開発スピードが上がるのはもちろん、定型文の記法をチームで統一することができます。特にDaoは定型分の割に記述量が多くコーダーの個性が出てしまう部分であるため、自動生成を導入することで得られるメリットが大きいです。

本記事で扱うプロジェクトでは下記のようになっています。

自動生成できる部分
  • Dao
  • Repository
  • Entity
  • Enum
  • Request
  • Response
自動生成できない(してない)部分
  • Controller
  • Service
  • DI

実装例

今回はログインボーナスを例に解説していきます。ログインボーナスの詳細についてはゲーム開発におけるログインボーナスAPIの仕様で解説する予定です!
今回はアーキテクチャの流れを意識した解説をしていきます。

1.自動生成用のyamlを記述

下記のyamlを記述するとDao, Repository, Entity, SQLを自動生成できます。

# gocrafter/docs/entity/user/LoginRewardStatus.yaml

name: LoginRewardStatus
package: loginReward
structure:
  ID:
    name: id
    type: int64
    nullable: false
    number: 1
  ShardKey:
    name: shard_key
    type: string
    nullable: false
    number: 2
  AccountID:
    name: account_id
    type: int64
    nullable: false
    number: 3
  LoginRewardModelName:
    name: login_reward_model_Name
    type: string
    nullable: false
    number: 4
  LastReceivedAt:
    name: last_received_at
    type: time.Time
    nullable: false
    number: 5
  CreatedAt:
    name: created_at
    type: time.Time
    nullable: false
    number: 6
  UpdatedAt:
    name: updated_at
    type: time.Time
    nullable: false
    number: 7
primary:
  - ID
index:
  - AccountID
  - LoginRewardModelName
  - AccountID,LoginRewardModelName

下記のyamlを記述するとRequest, Responseを記述することができます。

# gocrafter/docs/api/response/loginReward/LoginRewardStatus.yaml

name: LoginRewardStatus
package: loginReward
structure:
  ID:
    name: id
    type: int64
    nullable: false
    number: 1
  LoginRewardModel:
    name: login_reward_model
    type: LoginRewardModel
    nullable: false
    number: 2
  Items:
    name: items
    type: Items
    nullable: false
    number: 3
  LastReceivedAt:
    name: last_received_at
    type: time.Time
    nullable: false
    number: 4

2.ドメインロジックを記述

本記事で扱うプロジェクトではService同士の依存関係を許容しているため、ドメインロジックでは汎用性と変更容易性を意識して記述していきます。

Entity

※実際には自動生成されるため記述はしません。

DDDでは原則依存関係のない層から記述していきます。EntityではDBのテーブルをGolangの構造体に落とし込んでいます。

// gocrafter/domain/entity/user/loginReward/loginRewardStatus_entity.gen.go

package loginReward

import (
	"time"
)

type LoginRewardStatuses []LoginRewardStatus

type LoginRewardStatus struct {
	ID int64 `json:"id"`

	ShardKey string `json:"shard_key"`

	AccountID int64 `json:"account_id"`

	LoginRewardModelName string `json:"login_reward_model_Name"`

	LastReceivedAt time.Time `json:"last_received_at"`

	CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`

	UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}

func (e *LoginRewardStatus) TableName() string {
	return "login_reward_status"
}

単一のEntityで完結する場合は同じパッケージ内に処理を記述します。

// gocrafter/domain/entity/user/loginReward/loginRewardStatus.go

package loginReward

import (
	"time"
)

// HasReceived 報酬を受け取っているか
func (e *LoginRewardStatus) HasReceived(now time.Time, resetHour int) bool {
	resetTime := time.Date(now.Year(), now.Month(), now.Day(), resetHour, 0, 0, 0, now.Location())
	if now.Before(resetTime) {
		return e.LastReceivedAt.Add(24 * time.Hour).Before(now)
	}

	return e.LastReceivedAt.Before(resetTime)
}

Dao

※実際には自動生成されるため記述はしません。

更新系メソッドとindexに指定した組み合わせの参照系のメソッドを自動生成できます。DaoではDBに関連する処理(参照、更新など)を記述します。

// gocrafter/infra/dao/user/loginReward/loginRewardStatus_dao.gen.go

package loginReward

import (
	"gorm.io/gorm"

	"github.com/game-core/gocrafter/config/database"
	"github.com/game-core/gocrafter/domain/entity/user/loginReward"
	loginRewardRepository "github.com/game-core/gocrafter/domain/repository/user/loginReward"
)

type loginRewardStatusDao struct {
	ShardConn *database.ShardConn
}

func NewLoginRewardStatusDao(conn *database.SqlHandler) loginRewardRepository.LoginRewardStatusRepository {
	return &loginRewardStatusDao{
		ShardConn: conn.User,
	}
}

func (d *loginRewardStatusDao) Create(entity *loginReward.LoginRewardStatus, shardKey string, tx *gorm.DB) (*loginReward.LoginRewardStatus, error) {
	var conn *gorm.DB
	if tx != nil {
		conn = tx
	} else {
		conn = d.ShardConn.Shards[shardKey].WriteConn
	}

	res := conn.Model(&loginReward.LoginRewardStatus{}).Create(entity)
	if err := res.Error; err != nil {
		return nil, err
	}

	return entity, nil
}

// 他のメソッドも自動生成される

Repository

※実際には自動生成されるため記述はしません。

Repositoryでは依存関係を逆転させたDaoのインタフェースを記述します。

// gocrafter/domain/repository/user/loginReward/loginRewardStatus_repository.gen.go

//go:generate mockgen -source=./loginRewardStatus_repository.gen.go -destination=./loginRewardStatus_repository_mock.gen.go -package=loginReward
package loginReward

import (
	"github.com/game-core/gocrafter/domain/entity/user/loginReward"
	"gorm.io/gorm"
)

type LoginRewardStatusRepository interface {
	Create(entity *loginReward.LoginRewardStatus, shardKey string, tx *gorm.DB) (*loginReward.LoginRewardStatus, error)

	Delete(entity *loginReward.LoginRewardStatus, shardKey string, tx *gorm.DB) error

	FindByAccountID(AccountID int64, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindByAccountIDAndLoginRewardModelName(AccountID int64, LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindByID(ID int64, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindByLoginRewardModelName(LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindOrNilByAccountID(AccountID int64, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindOrNilByAccountIDAndLoginRewardModelName(AccountID int64, LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindOrNilByID(ID int64, shardKey string) (*loginReward.LoginRewardStatus, error)

	FindOrNilByLoginRewardModelName(LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatus, error)

	List(limit int, shardKey string) (*loginReward.LoginRewardStatuses, error)

	ListByAccountID(AccountID int64, shardKey string) (*loginReward.LoginRewardStatuses, error)

	ListByAccountIDAndLoginRewardModelName(AccountID int64, LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatuses, error)

	ListByLoginRewardModelName(LoginRewardModelName string, shardKey string) (*loginReward.LoginRewardStatuses, error)

	Save(entity *loginReward.LoginRewardStatus, shardKey string, tx *gorm.DB) (*loginReward.LoginRewardStatus, error)
}

Service

最後にServiceロジックを記述してドメインロジックは完成です。publicメソッドではDaoやResponseのセッター等のように、処理を抽象化するために使用します。より具体的な処理はprivateメソッドやEntityロジックに記述していきます。

//go:generate mockgen -source=./loginReward_service.go

// GetLoginRewardModel ログイン報酬モデルを取得する
func (s *loginRewardService) GetLoginRewardModel(req *request.GetLoginRewardModel, now time.Time) (*response.GetLoginRewardModel, error) {
	lrm, lrrs, e, err := s.getLoginRewardModelAndRewardsAndEvent(req.LoginRewardModelName, now)
	if err != nil {
		return nil, err
	}

	rewards, err := response.ToRewards(lrrs)
	if err != nil {
		return nil, err
	}

	return response.ToGetLoginRewardModel(
		200,
		*response.ToLoginRewardModel(
			lrm.ID,
			lrm.Name,
			*response.ToEvent(
				e.ID,
				e.Name,
				e.ResetHour,
				e.RepeatSetting,
				e.RepeatStartAt,
				e.StartAt,
				e.EndAt,
			),
			rewards,
		),
	), nil
}

// ReceiveLoginReward 受け取る
func (s *loginRewardService) ReceiveLoginReward(req *request.ReceiveLoginReward, now time.Time) (*response.ReceiveLoginReward, error) {
	// transaction
	tx, err := s.transactionRepository.Begin(req.ShardKey)
	if err != nil {
		return nil, err
	}
	defer func() {
		if err := s.transactionRepository.CommitOrRollback(tx, err); err != nil {
			log.Panicln(err)
		}
	}()

	lrm, lrrs, e, err := s.getLoginRewardModelAndRewardsAndEvent(req.LoginRewardModelName, now)
	if err != nil {
		return nil, err
	}

	lrs, err := s.loginRewardStatusRepository.FindOrNilByLoginRewardModelName(lrm.Name, req.ShardKey)
	if err != nil {
		return nil, err
	}

	lrs, err = s.receive(lrs, lrrs, e, req, now, tx)
	if err != nil {
		return nil, err
	}

	rewards, err := response.ToRewards(lrrs)
	if err != nil {
		return nil, err
	}

	items, err := response.ToItems(lrrs.GetItems(e.GetDayCount(now)))
	if err != nil {
		return nil, err
	}

	return response.ToReceiveLoginReward(
		200,
		*response.ToLoginRewardStatus(
			lrs.ID,
			*response.ToLoginRewardModel(
				lrm.ID,
				lrm.Name,
				*response.ToEvent(
					e.ID,
					e.Name,
					e.ResetHour,
					e.RepeatSetting,
					e.RepeatStartAt,
					e.StartAt,
					e.EndAt,
				),
				rewards,
			),
			items,
			lrs.LastReceivedAt,
		),
	), nil
}

3.アプリケーションロジックを記述

アプリケーションロジックでは主にRouterとControllerでAPIのハンドリングを行います。また、DIもアプリケーションロジックに記述します。

Router

パスのハンドリングを記述します。

// game-core/gocrafter/api/presentation/router/router.go

package router

import (
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	echoSwagger "github.com/swaggo/echo-swagger"
	_ "gorm.io/driver/mysql"

	"github.com/game-core/gocrafter/api/di"
	_ "github.com/game-core/gocrafter/docs/swagger/api"
	"github.com/game-core/gocrafter/log"
)

func Init() {
	// di: wire ./api/di/wire.go
	accountController := di.InitializeAccountController()
	loginRewardController := di.InitializeLoginRewardController()
	accountMiddleware := di.InitializeAccountMiddleware()

	e := echo.New()

	// Swagger
	e.GET("/swagger/*any", echoSwagger.WrapHandler)

	// Log
	e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{Output: log.GenerateApiLog()}))
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// アカウント関連
	account := e.Group("/account")
	account.POST("/register_account", accountController.RegisterAccount())
	account.POST("/login_account", accountController.LoginAccount())

	accountWithToken := e.Group("/account")
	accountWithToken.Use(accountMiddleware.AccountMiddleware)
	accountWithToken.POST("/check_account", accountController.CheckAccount())

	// ログイン報酬関係
	loginReward := e.Group("/login_reward")
	loginReward.Use(accountMiddleware.AccountMiddleware)
	loginReward.POST("/get_login_reward_model", loginRewardController.GetLoginRewardModel())
	loginReward.POST("/receive_login_reward", loginRewardController.ReceiveLoginReward())

	e.Logger.Fatal(e.Start(":8000"))
}

Controller

RequestのバインドとResponseをハンドリングします。また、Swaggerの記述を行いドキュメントを自動生成できます。

// gocrafter/api/presentation/controller/loginReward/loginReward_controller.go

// @tags    LoginReward
// @Summary ログイン報酬モデル取得
// @Accept  json
// @Produce json
// @Param   body body request.GetLoginRewardModel true "ログイン報酬モデル取得"
// @Router  /login_reward/get_login_reward_model [post]
// @Success 200 {object} loginReward.GetLoginRewardModel
// @Failure 500 {object} errorResponse.Error
func (a *loginRewardController) GetLoginRewardModel() echo.HandlerFunc {
	return func(c echo.Context) error {
		req := &request.GetLoginRewardModel{}
		c.Bind(req)

		res, err := a.loginRewardService.GetLoginRewardModel(req, time.Now())
		if err != nil {
			return c.JSON(500, &errorResponse.Error{
				Status:       500,
				ErrorMessage: err.Error(),
			})
		}

		return c.JSON(200, res)
	}
}

// @tags    LoginReward
// @Summary ログイン報酬受け取り
// @Accept  json
// @Produce json
// @Param   body body request.ReceiveLoginReward true "ログイン報酬受け取り"
// @Router  /login_eward/receive_login_reward [post]
// @Success 200 {object} loginReward.ReceiveLoginReward
// @Failure 500 {object} errorResponse.Error
func (a *loginRewardController) ReceiveLoginReward() echo.HandlerFunc {
	return func(c echo.Context) error {
		req := &request.ReceiveLoginReward{}
		c.Bind(req)

		res, err := a.loginRewardService.ReceiveLoginReward(req, time.Now())
		if err != nil {
			return c.JSON(500, &errorResponse.Error{
				Status:       500,
				ErrorMessage: err.Error(),
			})
		}

		return c.JSON(200, res)
	}
}

DI

最後にDIで依存関係を注入します。

// gocrafter/api/di/wire.go

func InitializeLoginRewardService() loginRewardService.LoginRewardService {
	wire.Build(
		database.NewDB,
		loginRewardService.NewLoginRewardService,
		userLoginRewardDao.NewLoginRewardStatusDao,
		masterLoginRewardDao.NewLoginRewardRewardDao,
		masterLoginRewardDao.NewLoginRewardModelDao,
		userDao.NewTransactionDao,
		InitializeEventService,
		InitializeItemService,
	)

	return nil
}

func InitializeEventService() eventService.EventService {
	wire.Build(
		database.NewDB,
		eventService.NewEventService,
		masterEventDao.NewEventDao,
	)

	return nil
}

func InitializeItemService() itemService.ItemService {
	wire.Build(
		database.NewDB,
		itemService.NewItemService,
		masterItemDao.NewItemDao,
		userItemDao.NewItemBoxDao,
		userDao.NewTransactionDao,
	)

	return nil
}

まとめ

今回はアーキテクチャに焦点を当ててAPIの実装方法を解説しました。アーキテクチャは考えれば考えるほど正解がわからなくなり、結局はチームや個人の思想次第なところがあります。だからこそ明確な規則を設けることでプロダクトに適したアーキテクチャを考えるのは、コーディングの中で最も楽しい部分だと思います。

是非、今回紹介したプロジェクトをベースにして、各サービスに適したアーキテクチャにカスタマイズして使用していただけると嬉しいです!

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?