とある案件で Golang を使って開発しています。
私が Golang を知ったのは 2015 年ごろで、本格的に使うようになったのは 2017 年ごろでした。
当時は依存ライブラリの管理もこれといったものがなく、色々四苦八苦しながら実装していた思い出があります。

そんな状況だったにもかかわらず、今 Golang の案件に関わるようになったのは理由があります。
といってもそんな大それたものではなく、改めて記事を読んだりする機会があったからというのが主な理由です。

そんな Golang を使って api を作るためのあれこれを、何回かにわたって書こうと思います。

利用するリポジトリ

テスト用に作ったユーザ登録・参照ができる api です。
ルーティングまわりに Gin、データベース操作に GORM を使っています。

セットアップ

Git と Docker が必要なので、環境に沿ったものをインストールしてください。
その後以下コマンドを実行してリポジトリをチェックアウトします。

$ git clone git@github.com:gotoeveryone/auth-api.git
$ cd auth-api

続いてアプリケーション用の Docker コンテナを起動します。
一部抜粋してコメントします(以降のコードも同様です)。

# /Dockerfile

# alpine ベースの Go 用イメージを、ステージ development として定義
FROM golang:1.17-alpine as development

# 依存ライブラリ定義ファイルをイメージ内にコピー
COPY go.mod go.sum ./

# ホットリロードをさせたいので air というツールを使います
# swag はドキュメント用のツールですが、一旦無視してください
RUN go install github.com/cosmtrek/air@v1.29.0 && \
  go install github.com/swaggo/swag/cmd/swag@v1.8.0

# 依存ライブラリを取得
RUN go mod download

# `air -c .air.toml` コマンドを、コンテナ起動時のエントリポイントとする
CMD ["air", "-c", ".air.toml"]

# 以降の手順は一旦無視してください

コンテナを起動します。

$ docker compose up

ブラウザから localhost:8080 にアクセスし、以下のようなレスポンスが返ってくれば起動成功です。

{"status":"Active","environment":"debug","logLevel":"info","timezone":"Asia/Tokyo"}

何をやっているのか

大きく2点です。

  • compose.yml をもとに docker コンテナを起動
  • air コマンドを使い、.air.toml に記載した設定でアプリケーションを起動

compose.yml をもとに docker コンテナを起動

compose.yml というのは docker compose コマンドが読み込む設定ファイルです。
docker-compose.yml というファイル、docker-compose というコマンドもあり、こちらのほうが馴染みある方が多いと思いますが、Docker compose の最新バージョン (2.x) では上記が推奨されています。
こちらの記事でとてもわかりやすく説明されています。
記述方法は大きく変わっていないので、これまでの docker-compose.yml の書き方がわかればほぼ問題なく移行できると思います。
今回は api に加えて、データベース (mysql) およびキャッシュサーバ (redis) を起動しています。

air コマンドを使い、.air.toml に記載した設定でアプリケーションを起動

これが今回の肝です。
Golang はコンパイラ言語なので、ファイルの内容を変更しても都度コンパイルしないと確認できません。
これを自動化するために air というツールを使い、都度コンパイルする手間を省いているというわけです。
air は実行するコマンドのカスタマイズも可能です。

次はアプリケーションについてざっくり説明します。

起動・各種設定

main.go でやっています(一部 registry でもやっていますが今回は説明の都合上1ファイルにあるものとして書いています)。
こちらも抜粋しつつコメントします。

// メイン関数
// 原則 main パッケージの main 関数が最初に実行される
func main() {
    // ログの設定
    logrus.SetFormatter(&logrus.JSONFormatter{})

    // データベースコンテナ・キャッシュコンテナへの接続設定
    c := config.App{
        DB: config.DB{
            Host:     getEnv("DATABASE_HOST", "127.0.0.1"),
            Port:     getEnv("DATABASE_PORT", "3306"),
            Name:     getEnv("DATABASE_NAME", "auth_api"),
            User:     getEnv("DATABASE_USER", "auth_api"),
            Password: getEnv("DATABASE_PASSWORD", ""),
        },
        Cache: config.Cache{
            Host: getEnv("CACHE_HOST", "127.0.0.1"),
            Port: getEnv("CACHE_PORT", "6379"),
            Auth: getEnv("CACHE_AUTH", ""),
        },
    }

    // タイムゾーンの設定
    var err error
    time.Local, err = time.LoadLocation(getEnv("TZ", "Asia/Tokyo"))
    if err != nil {
        logrus.Error(fmt.Sprintf("Get location error: %s", err))
        // continue with default timezone.
    }

    // アプリケーション初期化
    // 移行は Gin を使った書き方ですが、標準パッケージ (net/http) や他のルーティングフレームワークでもほぼ同様です
    r := gin.Default()
    r.HandleMethodNotAllowed = true

    // リポジトリを初期化
    ur := registry.NewUserRepository()
    tr := registry.NewTokenRepository(c)

    // ハンドラを初期化
    // 引数にリポジトリを指定して依存性を注入
    sh := registry.NewStateHandler()
    ah := registry.NewAuthHandler(ur, tr)

    // ミドルウェアを初期化
    m := registry.NewAuthMiddleware(ur, tr)

    // ルーティング設定
    r.GET("/", sh.Get)
    // 404
    r.NoRoute(sh.NoRoute)
    // 405
    r.NoMethod(sh.NoMethod)
    // Application
    v1 := r.Group("v1")
    {
        // v1.XXX を使うことで /v1 以下のパスになる
        v1.GET("/", sh.Get)
        v1.POST("/users", ah.Registration)
        v1.POST("/activate", ah.Activate)
        v1.POST("/auth", ah.Authenticate)
        auth := v1.Group("")
        {
            // auth 以下は認証ミドルウェアを必ずかませる
            auth.Use(m.Authorized())
            auth.GET("/users", ah.GetUser)
            auth.DELETE("/deauth", ah.Deauthenticate)
        }
    }

    // アプリケーションを起動
    host := getEnv("APP_HOST", "0.0.0.0")
    port := getEnv("APP_PORT", "8080")
    if err := r.Run(fmt.Sprintf("%s:%s", host, port)); err != nil {
        logrus.Error(err)
        os.Exit(1)
    }
}

各レイヤーでは interface を使うことで、実装に依存しないようにしています。
Golang ではインターフェースが定義している実装を満たしていれば、インターフェースを実装していると判断されます(ダックタイピング)。

// presentation/handler/state.go

package handler

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

// StateHandler は Get の実装を必須とする
type State interface {
    Get(c *gin.Context)
}
// interface/api/server/state.go

package server

import (
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/gotoeveryone/auth-api/app/domain/entity"
    "github.com/gotoeveryone/auth-api/app/presentation/handler"
    "github.com/sirupsen/logrus"
)

type stateHandler struct{}

// NewStateHandler is state action handler
func NewStateHandler() handler.State {
    return &stateHandler{}
}

// StateHandler インターフェースが定義している Get の実装を満たす
func (h *stateHandler) Get(c *gin.Context) {
    c.JSON(http.StatusOK, entity.State{
        Status:      "Active",
        Environment: gin.Mode(),
        LogLevel:    logrus.GetLevel().String(),
        TimeZone:    time.Local.String(),
    })
}

まとめ

今回はセットアップから起動までをざっくり説明しました。
次回は今回触れなかった api ドキュメントについて書こうと思います。

※2022/4/7追記
こちらにて続編を書きました。