メインコンテンツまでスキップ

プロジェクト構造化

この章では、プロジェクトをどのように構造化するかを紹介します。

はじめに

ここまでで、Go-Sail を使ってシンプルなログインサービスを構築し、設定読み込み、ホットリロード、分散トレーシングといった機能も備えました。しかし気づいたかもしれませんが、現状のコードはほとんど全て main.go ファイルに集約されてしまっています。これは間違いや非プロフェッショナルというわけではなく、あくまでチュートリアルをわかりやすく伝えるための構成です。しかし実際の現場では、コードベースの可読性・保守性・健全な進化のためにも、プロジェクトの構造化が重要となります。

ファイル分割

まず巨大化した main.go ファイルを小さな単位に分割します。業界標準のMVCパラダイムに従い、Goでよく「ハンドラ関数」と呼ばれるコントローラーとデータモデルにコードベースを分割します。API専用サービスであれば「ビュー」モジュールは不要です。加えて「ルーティング」「サービス」「設定」などの新しいモジュールも導入しています。

ハンドラー

handlers.go
package main

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

func Login(c *gin.Context) {
LoginSvc(c)
}

func ThirdPartyNotify(c *gin.Context) {
ThirdPartyNotifySvc(c)
}

ミドルウェア

middlewares.go
package main

import (
"github.com/gin-gonic/gin"
sailConstants "github.com/keepchen/go-sail/v3/constants"
"github.com/keepchen/go-sail/v3/sail"
)

...

// ValidateToken ユーザートークンの検証
func ValidateToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token != "this-is-a-valid-token" {
sail.Response(c).Wrap(sailConstants.ErrAuthorizationTokenInvalid, nil).Send()
return
}
c.Next()
}
}

...

モデル

models.go
package main

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

ルーティング

routes.go
package main

import (
"github.com/gin-gonic/gin"
sailMiddleware "github.com/keepchen/go-sail/v3/http/middleware"
)

func RegisterRoutes(ginEngine *gin.Engine) {
ginEngine.Use(sailMiddleware.DetectUserAgentLanguage())

ginEngine.POST("/login", Login)
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
ginEngine.POST("/third-party/notify", ThirdPartyNotify)
}

サービス

services.go
package main

import (
"fmt"
"net/http"

"go.uber.org/zap"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
sailConstants "github.com/keepchen/go-sail/v3/constants"
)

func LoginSvc(c *gin.Context) {
var loginRequest LoginRequest
if err := c.ShouldBind(&loginRequest); err != nil {
sail.LogTrace(c).Warn("リクエストパラメータのバインドに失敗", zap.Error(err))
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

if code, err := loginRequest.Validator(); err != nil {
sail.Response(c).Wrap(code, nil, err.Error()).Send()
return
}

var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// ユーザーが存在しない
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}
// パスワード不一致
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

headers := map[string]string{
"X-Request-Id": sail.LogTrace(c).RequestID(),
}
sail.Utils().HttpClient().SendRequest("POST", "https://....", nil, headers)

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
}

func ThirdPartyNotifySvc(c *gin.Context) {
c.JSON(http.StatusOK, "SUCCESS")
}

エラー定義

errors.go
package main

import (
"fmt"
"sync"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type ErrorCode int

func (v ErrorCode) Int() int {
return int(v)
}

const (
ErrUserNotExist ErrorCode = 1000
ErrUserAlreadyExist ErrorCode = 1001
ErrUsernameAndPasswordNotMatch ErrorCode = 1002
)

var codeMsgMap = sailConstants.MMBox{
//en
sailConstants.LanguageEnglish: {
ErrUserNotExist: "User not exist",
ErrUserAlreadyExist: "User already exist",
ErrUsernameAndPasswordNotMatch: "Username and password not match",
},
//zh-CN
sailConstants.LanguageChinesePRC: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//ja
sailConstants.LanguageJapanese: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//その他言語
}

var once sync.Once

func init() {
once.Do(func() {
time.AfterFunc(time.Second*2, func() {
for language, msgMap := range codeMsgMap {
for code, msg := range msgMap {
sail.Code().Register(language.String(), code.Int(), msg)
}
}
})
})
}

型定義

types.go
package main

import (
"fmt"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type LoginRequest struct {
Username string `json:"username" form:"username" query:"username"`
Password string `json:"password" form:"password" query:"password"`
}

func (v LoginRequest) Validator() (sailConstants.ICodeType, error) {
if len(v.Username) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("ユーザー名を入力してください")
}

if len(v.Password) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("パスワードを入力してください")
}

return sailConstants.ErrorNone, nil
}

設定

config.go
package main

import (
"gopkg.in/yaml.v2"

"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"

var conf config.Config

func ParseConfig() {
parseFn := func(content []byte, viaWatch bool) {
if viaWatch {
//設定ファイルがリロードされた際の処理
}
fmt.Println("config content: ", string(content))
yaml.Unmarshal(content, &conf)
}
sail.Config(parseFn).ViaFile("./go-sail.config.local.yaml").Parse(parseFn)
}

メイン

main.go
package main

import (
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/http/api"
)

var (
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User{})
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}

sail.GetLogger("schedule").Info("logging something...")
}
)

func main() {
ParseConfig()

options := &api.Option{
ForceHttpCode200: true,
DetectAcceptLanguage: true,
}
sail.WakeupHttp("go-sail", &conf).
SetupApiOption(options).
Hook(RegisterRoutes, nil, afterFunc).Launch()
}

上記のように、元々の main.go からファイルを分割しました。しかしこの段階では、全てのコードが依然として main パッケージにあり、同じディレクトリに収まっています。現実的にはこれだけでは不十分で、さらに細分化するのが一般的です。

ディレクトリ構成

実際のエンジニアリングプロジェクトでは、ディレクトリはモジュール単位で整理され、Goパッケージもそれぞれ異なります。先ほどの分割例をベースに、さらに一歩進めていきます。

モジュール初期化

まずパッケージ管理のため、go mod コマンドを使用します。本チュートリアルでは下記のコマンドで tutorials という名前で初期化します。

go mod init tutorials

ハンドラー

http/handlers/user.go
package handlers

import (
"tutorials/http/services"

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

func Login(c *gin.Context) {
services.LoginSvc(c)
}
http/handlers/third-party.go
package handlers

import (
"tutorials/http/services"

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

func ThirdPartyNotify(c *gin.Context) {
services.ThirdPartyNotifySvc(c)
}

ミドルウェア

http/middlewares/authorization.go
package middlewares

import (
"github.com/gin-gonic/gin"
sailConstants "github.com/keepchen/go-sail/v3/constants"
"github.com/keepchen/go-sail/v3/sail"
)

// ValidateToken ユーザートークンの検証
func ValidateToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token != "this-is-a-valid-token" {
sail.Response(c).Wrap(sailConstants.ErrAuthorizationTokenInvalid, nil).Send()
return
}

c.Next()
}
}

モデル

pkg/models/user.go
package models

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

ルーティング

http/routes/routes.go
package routes

import (
"tutorials/http/middlewares"

"github.com/gin-gonic/gin"
sailMiddleware "github.com/keepchen/go-sail/v3/http/middleware"
)

func RegisterRoutes(ginEngine *gin.Engine) {
ginEngine.Use(sailMiddleware.DetectUserAgentLanguage())

ginEngine.POST("/login", Login)
userGroup := ginEngine.Group("/user").Use(middlewares.ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
ginEngine.POST("/third-party/notify", ThirdPartyNotify)
}

サービス

http/services/user.go
package services

import (
"net/http"

"tutorials/http/types"
"tutorials/pkg/common"
"tutorials/pkg/models"

"go.uber.org/zap"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
sailConstants "github.com/keepchen/go-sail/v3/constants"
)

func LoginSvc(c *gin.Context) {
var loginRequest types.LoginRequest
if err := c.ShouldBind(&loginRequest); err != nil {
sail.LogTrace(c).Warn("リクエストパラメータのバインドに失敗", zap.Error(err))
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

if code, err := loginRequest.Validator(); err != nil {
sail.Response(c).Wrap(code, nil, err.Error()).Send()
return
}

var user User
sail.GetDBR().Where(&models.User{Username: loginRequest.Username}).First(&user)
// ユーザーが存在しない
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(common.ErrUserNotExist, nil).Send()
return
}
// パスワード不一致
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

headers := map[string]string{
"X-Request-Id": sail.LogTrace(c).RequestID(),
}
sail.Utils().HttpClient().SendRequest("POST", "https://....", nil, headers)

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
}
http/services/third-party.go
package services

import (
"net/http"

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

func ThirdPartyNotifySvc(c *gin.Context) {
c.JSON(http.StatusOK, "SUCCESS")
}

エラー定義

pkg/common/errors.go
package common

import (
"fmt"
"sync"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type ErrorCode int

func (v ErrorCode) Int() int {
return int(v)
}

const (
ErrUserNotExist ErrorCode = 1000
ErrUserAlreadyExist ErrorCode = 1001
ErrUsernameAndPasswordNotMatch ErrorCode = 1002
)

var codeMsgMap = sailConstants.MMBox{
//en
sailConstants.LanguageEnglish: {
ErrUserNotExist: "User not exist",
ErrUserAlreadyExist: "User already exist",
ErrUsernameAndPasswordNotMatch: "Username and password not match",
},
//zh-CN
sailConstants.LanguageChinesePRC: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//ja
sailConstants.LanguageJapanese: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//その他言語
}

var once sync.Once

func init() {
once.Do(func() {
time.AfterFunc(time.Second*2, func() {
for language, msgMap := range codeMsgMap {
for code, msg := range msgMap {
sail.Code().Register(language.String(), code.Int(), msg)
}
}
})
})
}

型定義

http/types/user.go
package types

import (
"fmt"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type LoginRequest struct {
Username string `json:"username" form:"username" query:"username"`
Password string `json:"password" form:"password" query:"password"`
}

func (v LoginRequest) Validator() (sailConstants.ICodeType, error) {
if len(v.Username) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("ユーザー名を入力してください")
}

if len(v.Password) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("パスワードを入力してください")
}

return sailConstants.ErrorNone, nil
}

設定

config/config.go
package config

import (
"gopkg.in/yaml.v2"

"github.com/keepchen/go-sail/v3/sail"
sailConfig "github.com/keepchen/go-sail/v3/sail/config"

var conf sailConfig.Config

func ParseConfig() {
parseFn := func(content []byte, viaWatch bool) {
if viaWatch {
//設定ファイルがリロードされた際の処理
}
fmt.Println("config content: ", string(content))
yaml.Unmarshal(content, &conf)
}
sail.Config(parseFn).ViaFile("./go-sail.config.local.yaml").Parse(parseFn)
}

func Get() *sailConfig.Config {
return &conf
}

メイン

main.go
package main

import (
"tutorials/config"
"tutorials/http/routes"
"tutorials/pkg/models"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/http/api"
)

var (
afterFunc = func() {
sail.GetDBW().AutoMigrate(&models.User{})
var user User
sail.GetDBW().Where(&models.User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}

sail.GetLogger("schedule").Info("logging something...")
}
)

func main() {
config.ParseConfig()

options := &api.Option{
ForceHttpCode200: true,
DetectAcceptLanguage: true,
}
sail.WakeupHttp("go-sail", config.Get()).
SetupApiOption(options).
Hook(routes.RegisterRoutes, nil, afterFunc).Launch()
}

上記のファイル構成を整えたら、下記コマンドで依存管理を行います。

go mod tidy

このような構造化の最適化によって、それぞれのファイルの役割が明確になり、全体が整理されました。これにより、今後の機能拡張も非常に容易になります。

整理後のプロジェクト全体のディレクトリ構成は次のようになります:

tutorials/
├── config
│   └── config.go
├── main.go
├── http
│   ├── handlers
│   │   ├── user.go
│   │   └── third-party.go
│   ├── middlewares
│   │   └── authorization.go
│   ├── routes
│   │   └── routes.go
│   ├── services
│   │   ├── user.go
│   │   └── third-party.go
│   ├── types
│   │   └── user.go
└── pkg
   ├── common
   │ └── errors.go
└── models
└── user.go
ヒント

Go-Sailはプロジェクト構造を厳密に縛るものではありません。構造設計はユーザー次第、そして唯一の「最適解」がある訳でもありません。あなたに最も適した形こそがベストです。