GolangでMVCなAPIサーバを作るときのディレクトリ構成とプロジェクト生成コマンド


Goの軽量Webアプリケーションフレームワーク(Echo, Gin)のディレクトリ構成

Golangの軽量Webアプリケーションフレームワークである、EchoやGinを使うとRuby on Railsのようにディレクトリ構成が特に決まっていないため、どのようにすれば効率的に開発できるのか悩みます。

しかし、最近いくつかGinやEchoを使ってAPIサーバを作成し、なんとなくディレクトリ構成が決まってきたので、共有します。

以下のリポジトリは後述する自動リポジトリ生成コマンドで生成されるプロジェクトの雛形です。
これについて説明をしていきます。

まずディレクトリを木構造で表示すると以下のようになります。

├── README.md
├── config
│   ├── config.go
│   └── environments
│       ├── development.yml
│       └── test.yml
├── controllers
│   ├── common.go
│   ├── health_controller.go
│   └── health_controller_test.go
├── database
│   ├── database.go
│   └── db
├── dockerfiles
│   ├── prod
│   │   └── Dockerfile
│   └── test
│       └── Dockerfile
├── forms
├── main.go
├── middleware
├── models
├── server
│   ├── router.go
│   └── server.go
└── views

main.go


package main

import (
    "flag"

    _ "github.com/jinzhu/gorm/dialects/mysql"
    _ "github.com/jinzhu/gorm/dialects/postgres"
    _ "github.com/jinzhu/gorm/dialects/sqlite"
    "{{ .ProjectPath }}/config"
    "{{ .ProjectPath }}/database"
    "{{ .ProjectPath }}/server"
)

func main() {

    env := flag.String("e", "development", "")
    flag.Parse()

    config.Init(*env)
    database.Init(false)
    defer database.Close()
    if err := server.Init(); err != nil {
        panic(err)
    }
}

まずmain.goですが、これは非常にシンプルになっています。gormというORMを利用しているため、このライブラリで利用するデータベースドライバをインポートしています。

コマンドの引数はeのみとなっています。ここにdevelopmentproductionを設定するとconfig/environments以下の対応する設定ファイルが読み込まれる仕組みになっています。

そのあとでconfigdatabaseserverの初期化を行います。これらについては後述します。

config

次にconfigです。

config.go


package config

import (
    "github.com/spf13/viper"
)

var c *viper.Viper

// Init initializes config
func Init(env string) {
    c = viper.New()
    c.SetConfigFile("yaml")
    c.SetConfigName(env)
    c.AddConfigPath("config/environments/")
    c.AddConfigPath("/run/secrets/")
    if err := c.ReadInConfig(); err != nil {
        panic(err)
    }
}

// GetConfig returns config
func GetConfig() *viper.Viper {
    return c
}

viperというライブラリを用いてファイルから設定を読み込みます。
ローカルの場合はconfig/environments/以下の引数eに対応するyamlファイルを読み込みます。
Dockerを利用する場合はsecretを利用しコンテナの/run/secrets/以下に設定ファイルをマッピングします。
configはシングルトンで実装しGetConfigで至るところから読み出せるようにします。

controllers

次にMVCのCの部分のコントローラです。

health_controller.go


package controllers

import (
    "net/http"

    "github.com/labstack/echo"
)

// HealthController controller for health request
type HealthController struct{}

// NewHealthController is constructer for HealthController
func NewHealthController() *HealthController {
    return new(HealthController)
}

// Index is index route for health
func (hc *HealthController) Index(c echo.Context) error {
    return c.JSON(http.StatusOK, newResponse(
        http.StatusOK,
        http.StatusText(http.StatusOK),
        "OK",
    ))
}

controllersではGinやEchoなどのハンドラを定義します。そのため後述するserver/router.goでインスタンス化されフレームワークの各ルートと各メソッドがマッピングされます。
データベースのUserArticleなどそれぞれのリソースごとにuser_controller.goarticle_controller.goと構造体ごとにファイルを作ります。そして構造体のメソッドにハンドラを定義します。
ハンドラは比較的自由ですが私はIndex,Get,Create,Update,Deleteなどを作成します。1つのハンドラが大きくなりすぎるならば分けてもいいと思います。
返り値はcommon.goで定義された以下の構造体を利用します。

type response struct {
    Status  int         `json:"status"`
    Message string      `json:"message"`
    Result  interface{} `json:"result"`
}

func newResponse(status int, message string, result interface{}) *response {
    return &response{status, message, result}
}

StatusにはステータスコードをMessageにはメッセージを(http.StatusText()の文字列をよく使う)入れ、Resultにリソースを代入します。
リスポンスを統一することでフロントエンドから扱いやすくなります。
テストはuser_controller_test.goのように同じディレクトリに作成します。

database

データベースを管理するモジュールはdatabaseディレクトリに作成します。

database.go

package database

import (
    "github.com/jinzhu/gorm"
    "{{ .ProjectPath }}/config"
)

var d *gorm.DB

// Init initializes database
func Init(isReset bool, models ...interface{}) {
    c := config.GetConfig()
    var err error
    d, err = gorm.Open(c.GetString("db.provider"), c.GetString("db.url"))
    if err != nil {
        panic(err)
    }
    if isReset {
        d.DropTableIfExists(models)
    }
    d.AutoMigrate(models...)
}

// GetDB returns database connection
func GetDB() *gorm.DB {
    return d
}

// Close closes database
func Close() {
    d.Close()
}

こちらもconfig同様シングルトンで定義しています。ドキュメント志向データベースなどのRDB以外を利用する場合はこのままでは利用できませんが抽象化されているためこのモジュールを書き換えることで対応できます。

dockerfiles

Dockerfileはこの中に入れましょう。ディレクトリごとに本番環境用やテスト環境用としてDockerfileを分けます。
各Dockerfileは以下のとおりです。

prod

FROM golang:alpine as build-env

ARG GITHUB_ACCESS_TOKEN

RUN apk add --no-cache git gcc musl-dev
RUN git config --global url."https://${GITHUB_ACCESS_TOKEN}:[email protected]/".insteadOf "https://github.com/"

RUN go get {{ .ProjectPath }}
WORKDIR /go/src/{{ .ProjectPath }}
RUN go build -o /usr/bin/app

FROM alpine
COPY --from=build-env /usr/bin/app /usr/bin/app

マルチステージビルドでイメージの容量を削減します。

test

FROM golang:alpine

RUN apk add --no-cache git gcc musl-dev
ADD . /go/src/{{ .ProjectPath }}
WORKDIR /go/src/{{ .ProjectPath }}

RUN go get

forms

ここではPOSTのBodyなどを構造体で定義します。モデルにそのままマッピングしても良いのですが、モデルとの間にはさむことで処理を抽象化できます。フォームのメソッドでモデルのメソッドを呼び出し間接的にデータベースを操作します。
認可もここで行います。

user_form.go

// UserForm is form for user
type UserForm struct {
    Name        string `json:"name"`
    Description string `json:"description"`
}

// Update updates a user with UserForm
func (uf *UserForm) Update(id uint, idToken *auth.Token) (ret *models.User, err error) {
    user := new(models.User)
    err = user.FindByID(id)
    if err != nil {
        return nil, err
    }

    // authorization
    if idToken.UID != user.UID {
        return nil, errors.New("Unauthorized")
    }
    user.Name = uf.Name
    user.Description = uf.Description
    err = user.Update()
    return user, err
}

middleware

ここではそれぞれのフレームワークで利用できるミドルウェアを定義します。
例えば認証用のミドルウェアを定義します。
以下はfirebase authの認証を行うミドルウェア。

firebase.go

package middleware

import (
    "context"
    "log"
    "net/http"
    "strings"

    firebase "firebase.google.com/go"
    "firebase.google.com/go/auth"
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
    "google.golang.org/api/option"
)

const valName = "FIREBASE_ID_TOKEN"

// FirebaseAuthMiddleware contains methods verifying JWT token
type FirebaseAuthMiddleware struct {
    fbase   *firebase.App
    skipper middleware.Skipper
}

// NewFireBaseAuthMiddleware is middleware authentication with firebase
func NewFireBaseAuthMiddleware(credFilePath string, skipper middleware.Skipper) (*FirebaseAuthMiddleware, error) {
    opt := option.WithCredentialsFile(credFilePath)
    app, err := firebase.NewApp(context.Background(), nil, opt)
    if err != nil {
        return nil, err
    }
    if skipper == nil {
        skipper = middleware.DefaultSkipper
    }
    return &FirebaseAuthMiddleware{
        fbase:   app,
        skipper: skipper,
    }, nil
}

// Verify verifies token
func (f *FirebaseAuthMiddleware) Verify(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if f.skipper(c) {
            return next(c)
        }

        r := c.Request()
        token := strings.Replace(r.Header.Get(echo.HeaderAuthorization), "Bearer ", "", 1)
        if token == "" {
            return c.String(http.StatusUnauthorized, "Bad token")
        }

        client, err := f.fbase.Auth(context.Background())
        if err != nil {
            log.Println(err)
            return c.String(http.StatusUnauthorized, "Bad token")
        }

        authToken, err := client.VerifyIDToken(context.Background(), token)
        if err != nil {
            log.Println(err)
            return c.String(http.StatusUnauthorized, "Bad token")
        }
        c.Set(valName, authToken)
        return next(c)
    }
}

// ExtractClaims extracts claims
func ExtractClaims(c echo.Context) *auth.Token {
    idToken := c.Get(valName)
    if idToken == nil {
        return new(auth.Token)
    }
    return idToken.(*auth.Token)
}

models

MVCのM、modelsではデータベースの操作を行います。
以下のようにデータベースのモデル定義を構造体として定義し、メソッドはデータベースを操作するものやcontrollersformsでリソースに関して共通に呼び出されるものを定義します。

user.go

// User is struct of user
type User struct {
    gorm.Model
    UID          string        `json:"uid" gorm:"unique;not null"`
    Name         string        `json:"name" gorm:"unique;not null"`
    Applications []Application `json:"applications"`
    Description  string        `json:"description"`
}

// Create creates a user
func (u *User) Create() (err error) {
    db := database.GetDB()
    return db.Create(u).Error
}

// FindByID finds a user by id
func (u *User) FindByID(id uint) (err error) {
    db := database.GetDB()
    return db.Where("id = ?", id).First(u).Error
}

server

serverでは各フレームワークのAPIを取り扱ったり、ルートを定義します。

server.go

package server

import "{{ .ProjectPath }}/config"

// Init initialize server
func Init() error {
    c := config.GetConfig()
    r, err := NewRouter()
    if err != nil {
        return err
    }
    r.Logger.Fatal(r.Start(":" + c.GetString("server.port")))
    return nil
}

非常にシンプルですが後述するrouter.goでルートを定義し、configからポート番号を取得しフレームワークのサーバを起動します。

router.go

package server

import (
    "net/http"

    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
    "{{ .ProjectPath }}/config"
    "{{ .ProjectPath }}/controllers"
)

// NewRouter is constructor for router
func NewRouter() (*echo.Echo, error) {
    c := config.GetConfig()
    router := echo.New()
    router.Use(middleware.Logger())
    router.Use(middleware.Recover())
    router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins: c.GetStringSlice("server.cors"),
        AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
    }))

    version := router.Group("/" + c.GetString("server.version"))

    healthController := controllers.NewHealthController()
    version.GET("/health", healthController.Index)

    return router, nil
}

router.goではフレームワークの初期化やミドルウェアを設定したり、controllersをインスタンス化してメソッドに対応するルートを定義します。

views

最後にMVCのV、viewsです。ここはレスポンスのresultとなる構造体を定義しています。

// UserView is view for user
type UserView struct {
    gorm.Model
    UID          string             `json:"uid"`
    Name         string             `json:"name"`
    Applications []*ApplicationView `json:"applications"`
    Description  string             `json:"description"`
}

// NewUserView is constructor for view of users
func NewUserView(user *models.User) *UserView {
    appViews := make([]*ApplicationView, len(user.Applications))
    for i, app := range user.Applications {
        appViews[i] = NewApplicationView(&app)
    }
    view := &UserView{
        UID:          user.UID,
        Name:         user.Name,
        Applications: appViews,
        Description:  user.Description,
    }
    view.Model = user.Model
    return view
}

モデルにはレスポンスとして返したくないアクセストークンなどのフィールドがあったりユーザごとに違うレスポンスを返さなければならない場合などに対応するためにこのviewを通し、必要なフィールドを持った構造体に変換します。

以上が最近良く利用しているディレクトリ構成です。改善点などございましたらお教えいただけると幸いです。

プロジェクト生成コマンド

上記のディレクトリ構成のEcho,ginフレームワークを用いた雛形を生成するコマンドを作成しました。

go-api-starter

引数でGOPATHからのパスを設定することでGoのテンプレート機能を利用し、プロジェクトファイル群を生成します。

追記(2021/03/28)

go-api-startergormがアップデートされていたため、テンプレートをアップデートされたバージョンに更新いたしました。