2
3

More than 3 years have passed since last update.

Go+Gin+GORMでAPIサーバの環境構築/簡易アーキテクチャのサンプル

Last updated at Posted at 2021-03-26

概要

Go で API サーバーを構築する手順について、忘れてしまったので自分用にメモをしておく。
似たような記事は他にも多くあるので、ここでは簡易的な手順と設計のみを残しておく。

また、Go言語 チートシートも書いたのでよければ。

開発環境

  • macOS Catalina 10.15.4
  • go version go1.13.5 darwin/amd64
  • mysql Ver 8.0.23 for Linux on x86_64 (MySQL Community Server - GPL)

利用ライブラリなど

名称 役割
gvm goのバージョン管理
Go Modules パッケージ管理
Gin HTTPサーバ
fvbock/endless Zero downtime restarts
GORM ORMライブラリ
migorate マイグレーション
direnv 環境変数管理

簡易手順

gvm は macOSにGVM(Golang)をインストール 等を見ながら予めインストールしておく。

やることは下記の通り。

  • 必要な go バージョンをインストール
  • Go Modules のセットアップ
  • Migorate のインストール
$ gvm listall
$ gvm install goバージョン
$ gvm use goバージョン
$ go mod init 組織ドメイン/プロジェクト名
$ go get github.com/ClusterVR/migorate

その他パッケージのインストールは Goland の vgo integration support で適宜行う。(vgo の有効化設定が必要)

image.png

環境変数の設定

direnv を利用するとディレクトリごとに環境変数を分けてくれるので便利。
brew install direnv でインストール可能、プロジェクトディレクトリで .envrc を作成して下記を書き込み。

# データベース関連 (適宜設定)
export DB_USER=""
export DB_PASS=""
export DB_HOST=""
export DB_PORT=""
export DB_NAME=""

# サーバ設定
export PORT="8080"

編集完了後に direnv allow で読み込み完了。

アーキテクチャ

レイヤードアーキテクチャ風で実装した。
目的は「関心の分離」と「データフローの明確化」のみ。
DI を利用した依存関係の逆転は面倒だったので今回はしない。

Endpoint
↓↑ JSON
Handler: リクエスト/レスポンス生成
↓↑ Model                 Model
Usecase: Repositoryでデータ処理   ←-------|
↓↑ Model                                ↓ 
Repository: DBとのやりとりを行う        Adapter: 外部API通信
↓↑ Model                                ↓↑ Model
Database                             ExternalAPI

簡易実装でUsecase層は必要なのか?

以前 Handler と Repository のみでフローを実装したところ、Handler が肥大化した。
原因は下記の通りで、いずれもビジネスロジックとは少し違うものだと考えられる。

  • リクエストのバリデーション
  • リクエストのマッピング
  • リクエストの内容によって処理の分岐
  • Model -> JSON への変換処理

そのため、ビジネスロジックを Usecase に分離、Handler はリクエストの処理とレスポンスJSONの生成に集中させたほうが、後々の無茶な変更でもストレス控えめで実装ができると考えられる。

GoでOOP風の実装をする

構造体定義をクラス定義として考えて、インタンス化する関数を作ることで実装する。
Go の構造体には振る舞いが定義可能で、先頭大文字が public、小文字が private になる。

基本的に全てのオブジェクトは上記実装をしている、下記はリポジトリの例。

func NewCommentRepository() *CommentRepository {
    return &CommentRepository{}
}

type CommentRepository struct {}

func (r *CommentRepository) Store(comment *models.Comment) error {
    return DB.Create(comment).Error
}

利用例は下記の通り。

var repo := repositories.NewCommentRepository(DB)
err := repo.Store(comment)

リクエストオブジェクト

パスパラメータ

https://example.org/111111 の部分、まずはルーティング設定で定義。

apiGroupV1.DELETE("comments/:id", commentsHandler.Delete)

その後、Handler 内で gin.Context を通じて取得する。

func (h *commentsHandler) Delete(c *gin.Context) {
    path := c.Param("id")
    ...

クエリパラメータ

https://example.org?key=valuekey=value の部分、構造体定義は下記の通り。

package requests

type CommentsGetRequest struct {
    ChannelId string `form:"channel_id"`
    Page      string `form:"page"`
    Limit     string `form:"limit"`
}

デシリアライズは下記の通り。

var request requests.CommentsGetRequest
if err := c.Bind(&request); err != nil {
    c.JSON(http.StatusBadRequest, errors.NewBadRequest())
    return
}

リクエストボディ (application/json)

POST 等で送るリクエスト、リクエストの構造体定義は下記の通り。

package requests

type CommentsPostRequest struct {
    ChannelId string `json:"channel_id" binding:"required"`
    Message   string `json:"message" binding:"required"`
}

デシリアライズは下記の通り。

var request requests.CommentsPostRequest
if err := c.BindJSON(&request); err != nil {
    c.JSON(http.StatusBadRequest, errors.NewBadRequest())
    return
}

リクエストボディ (multipart/form-data)

ファイル等を送るときのリクエスト、gin.Context を通じて multipart.FileHeader 型で取得可能。
multipart.FileHeader 型の使い方はPackage multipart を参照。

func UsersPutRequestMapper(c *gin.Context) *UsersPutRequest {
    name := c.PostForm("name")
    icon, _ := c.FormFile("icon") // *multipart.FileHeader型
    ...

レスポンスオブジェクト

レスポンスの構造体定義は下記の通り。
他のレスポンスで使い回せるようにするために細分化してある。

package responses

type CommentsGetResponse struct {
    CommentsWithUsers []responses.CommentWithUser `json:"comments"`
}

type CommentWithUser struct {
    Comment Comment `json:"comment"`
    User    User    `json:"user"`
}

type Comment struct {
    Id      string `json:"id"`
    Message string `json:"message"`
}

type User struct {
    Id   string `json:"id"`
    Name string `json:"name"`
    Icon string `json:"icon"`
    Role string `json:"role"`
}

シリアライズは下記の通り。

var commentWithUserValues []responses.CommentWithUser
for _, comment := range *comments {
    userValue := responses.User{
        Id:   strconv.Itoa(int(comment.User.ID)),
        Name: comment.User.Name,
        Icon: comment.User.Icon,
    }
    commentValue := responses.Comment{
        Id:      strconv.Itoa(int(comment.ID)),
        Message: comment.Message,
    }
    commentWithUserValue := responses.CommentWithUser{
        Comment: commentValue,
        User:    userValue,
    }
    commentWithUserValues = append(commentWithUserValues, commentWithUserValue)
}

response := responses.CommentsGetResponse{CommentsWithUsers: commentWithUserValues}
c.JSON(http.StatusOK, response)

DBアクセスを行わないAPIを実装してみる

GET /ping{message: 'pong'} が返る API を下記の手順に沿って作る。
(シンプルな API なので Usecase は利用しない)

  1. Handler, Response を実装
  2. CORSやロギング等のミドルウェアを実装
  3. サーバ立ち上げ → ルーティングの仕組みを実装

Handler, Response を実装

2つのファイルを作成。

  • presentations/handlers/ping_handler.go
  • presentations/responses/ping/ping_response.go

ping_handler.go がやることは「レスポンスJSONの生成」のみ。

presentations/handlers/ping_handler.go
package handlers

import (
    "github.com/gin-gonic/gin"
    "組織ドメイン/プロジェクト名/presentations/responses/ping"
    "net/http"
)

// PingHandlerを生成して返す
func NewPingHandler() *pingHandler {
    return &pingHandler{}
}

// PingAPIのハンドラ
type pingHandler struct {
}

func (h *pingHandler) Get(c *gin.Context) {
    response := responses.PingResponse{Message: "Pong!"}
    c.JSON(http.StatusOK, response)
}

ping_response.go ではメッセージの定義を行う。

presentations/responses/ping/ping_response.go
package responses

// レスポンスJSONの型を定義
type PingResponse struct {
    Message string `json:"message"`
}

CORSやロギング等のミドルウェアを実装

2つのファイルを作成。

  • middleware/cors.go
  • middleware/request_logger.go

CORS の設定をしないと web ブラウザからアクセスできないので必須。

middleware/cors.go
package middleware

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

func CORS() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, DELETE, GET, PUT")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }

        c.Next()
    }
}

また、最初のままだとリクエストもレスポンスもあまりログを出してくれないので、独自に実装した logger を挟む。Request Body の内容に関しては Ginのリクエストボディはストリーム を参考にさせて頂いている。

middleware/request_logger.go
package middleware

import (
    "bytes"
    "fmt"
    "github.com/gin-gonic/gin"
    "io/ioutil"
)

type bodyLogWriter struct {
    gin.ResponseWriter
    body *bytes.Buffer
}

// リクエストのログをコンソールに表示する
func RequestLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Responseに書き込まれる内容を傍受する
        blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
        c.Writer = blw

        fmt.Print("Request: ")
        fmt.Println(c.Request)
        fmt.Print("Parameters; ")

        // TODO: multipart/form-data だと {"message":"unexpected EOF"} になるのでコメントアウトする
        // TODO: 本番環境でコンソールロギングは必要無いので変えられるようにしておく
        // requestLogging(c)

        c.Next()
        fmt.Print("Response: ")
        fmt.Println(blw.body.String())
    }
}

func (w bodyLogWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b)
}

func requestLogging(c *gin.Context) {
    // リクエストボディを取得して元に戻す
    // https://qiita.com/IzumiSy/items/e9bafb122c8cfab72361 を参照
    buf := make([]byte, 2048)
    n, _ := c.Request.Body.Read(buf)
    b := string(buf[0:n])
    fmt.Println(b)
    c.Request.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(b)))
}

サーバ立ち上げ → ルーティングの仕組みを実装

3つのファイルを作成。

  • main.go
  • config/server.go
  • config/route.go

main.go はエントリーポイントで、server.goの呼び出しとエラーハンドリングを行う。
また、DB 接続する際はここで DB への接続処理も実装する。

main.go
package main

import (
    "fmt"
    "組織ドメイン/プロジェクト名/config"
)

func main() {
    // server.goの呼び出しとエラーハンドリング
    if err := config.Start(); err != nil {
        fmt.Printf("%v¥n", err)
        return
    }
}

config/server.go はサーバーの立ち上げ処理を行う。

config/server.go
package config

import (
    "github.com/fvbock/endless"
    "github.com/gin-gonic/gin"
    "os"
)

// サーバの起動設定
func Start() error {
    app := setup()
    return endless.ListenAndServe(":"+os.Getenv("PORT"), app)
}

func setup() *gin.Engine {
    app := gin.New()
    RouteV1(app)
    return app
}

config/route.go はミドルウェア設定とルーティング設定を行う。
認証が必要/不要な API で関数を分けて見やすくする。

config/route.go
package config

import (
    "github.com/gin-gonic/gin"
    "組織ドメイン/プロジェクト名/middleware"
    "組織ドメイン/プロジェクト名/presentations/handlers"
)

// ルーティング設定
func RouteV1(app *gin.Engine) {
    app.Use(middleware.CORS())
    app.Use(gin.Logger())
    app.Use(middleware.RequestLogger())

    openAPI(app)
    authAPI(app)
}

// 認証不要のAPI
func openAPI(app *gin.Engine) {
    pingHandler := handlers.NewPingHandler()
    openApiGroup := app.Group("open")

    // 疎通確認
    openApiGroup.GET("ping", pingHandler.Get)
}

// 認証必須のAPI
func authAPI(app *gin.Engine) {
}

検証してみる

これで API Endpoint -> PingHandler までの流れができて、レスポンスも返せるようになったので、早速テストしてみる。GET API なのでブラウザからテストが可能。

ターミナルから go run main.go を実行。
その後、http://localhost:8080/open/ping へアクセスすると確認できる。

image.png

これで PING API の実装が完了、その後の API も実装も同じようにすれば良い。

DBアクセスを行うAPIを実装してみる

MySQL + GORM で実装する。

  1. MySQL を立ち上げる
  2. MySQL の接続情報を環境変数に設定する
  3. DB接続処理を実装する
  4. テスト用のテーブルを作る
  5. テスト用のテーブルとやりとりするAPIを実装する

MySQL を立ち上げる

方法は何でも良いが、自分は docker-compose で立ち上げている。
docker-compose でMySQL環境簡単構築 - Qiita らへんの記事が参考になると思われる。

その後のデータベース作成なども必要ではあるが、本記事では詳細は省く。

MySQL の接続情報を環境変数に設定する

上述した .envrc に下記の情報を追記する。
編集が完了したら direnv allow を忘れずに。

  • ユーザー名
  • パスワード
  • ホスト名
  • ポート番号
  • DB名

DB接続処理を実装する

GORM を通じて DB へ接続、まずは db/db.go というファイルを実装する。

db/db.go
package db

import (
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
    "os"
)

// Repository から直接参照する DB 構造体
var DB *gorm.DB

func NewDatabaseManager() *DatabaseManager {
    return &DatabaseManager{}
}

// データベース管理
type DatabaseManager struct {
}

// データベース接続
func (d *DatabaseManager) Connect() {
    DBMS := "mysql"
    USER := os.Getenv("DB_USER")
    PASS := os.Getenv("DB_PASS")
    HOST := "tcp(" + os.Getenv("DB_HOST") + ":" + os.Getenv("DB_PORT") + ")"
    DBNAME := os.Getenv("DB_NAME")

    CONNECT := USER + ":" + PASS + "@" + HOST + "/" + DBNAME + "?charset=utf8mb4&parseTime=true"
    db, err := gorm.Open(DBMS, CONNECT)
    db.LogMode(true)

    // グローバル変数にセット
    DB = db

    if err != nil {
        // 接続失敗したら起動させたく無いのでpanicさせる
        // TODO: Slack等に通知するようにする
        panic(err.Error())
    }
}

// データベース切断 (エラー処理のためにラップしている)
func (d *DatabaseManager) Close() {
    if err := DB.Close(); err != nil {
        panic(err.Error())
    }
}

その後、先程作成した main.go から DatabaseManager.Connect() を呼び出す。

main.go
package main

import (
    "fmt"
    "組織ドメイン/プロジェクト名/db"
)

func main() {
    // deferで閉じたいのでここで接続している
    dbm := db.NewDatabaseManager()
    dbm.Connect()
    defer dbm.Close()

    // server.goの呼び出しとエラーハンドリング
    if err := config.Start(); err != nil {
        fmt.Printf("%v¥n", err)
        return
    }
}

テスト用のテーブルを作る

DB の更新は migorate を使ってマイグレーション可能な状態にする。
まずは migorate の設定を行う、プロジェクトディレクトリに .migoraterc を作成。

mysql:
  host: localhost
  port: 3306
  user: migorate
  password: migorate
  database: migorate

次に、テスト用テーブルを作成する SQL を migorate で生成する。

$ migorate generate create_examples id:id message:string created_at:timestamp updated_at:timestamp deleted_at:timestamp

これで db/migrations/xxxxxxxxxxx_create_examples.sql が生成される。

-- +migrate Up
CREATE TABLE examples(id , message VARCHAR(255), created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);

-- +migrate Down
DROP TABLE examples;

しかし、生成された SQL は不完全なので下記のように調整を行う。

  • id は PRIMARY KEY AUTO_INCREMENT
  • 全てのパラメータは基本 NOT NULL (update_at, deleted_at のみ NULL 許可)
  • created_at は DEFAULT CURRENT_TIMESTAMP
  • 見やすいように適当にインデントを設定 (SQLフォーマッターFor WEBが便利)
-- +migrate Up
CREATE TABLE examples(
    id INT PRIMARY KEY AUTO_INCREMENT,
    message VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NULL,
    deleted_at TIMESTAMP NULL
);

-- +migrate Down
DROP TABLE examples;

最後に migorate exec all をすれば SQL が実行されてテーブルが生成される。(画像は Sequel Ace)
詳しい使い方は https://github.com/ClusterVR/migorate を参照すると良い。

image.png

テスト用のテーブルとやりとりするAPIを実装する

今回はリクエストの内容を DB に保存する POST API のみを実装する、手順は下記の通り。

  1. Repository, Model を実装
  2. Usecase を実装
  3. Handler, Request, Responseを実装
  4. ルーティングを設定

Repository, Model を実装

Model は infrastructures/models/example.go として実装する。

infrastructures/models/example.go
package models

import "github.com/jinzhu/gorm"

type Example struct {
    gorm.Model
    Message string
}

func (Example) TableName() string {
    return "examples"
}

ちなみに構造体内部に入っている gorm.Model の中身は下記の通り。

gorm.model.go
package gorm

import "time"

// Model base model definition, including fields `ID`, `CreatedAt`, `UpdatedAt`, `DeletedAt`, which could be embedded in your models
//    type User struct {
//      gorm.Model
//    }
type Model struct {
    ID        uint `gorm:"primary_key"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt *time.Time `sql:"index"`
}

次に、引数で受け取った Model を DB に保存する Repository を実装する。
ファイルは infrastructures/repositories/example_repository.go

今回は POST しか実装していないが、その他の DB 操作は GORM公式ドキュメント を確認すると良い。

infrastructures/repositories/example_repository.go
package repositories

import (
    "組織ドメイン/プロジェクト名/db"
    "組織ドメイン/プロジェクト名/infrastructures/models"
)

func NewExampleRepository() *ExampleRepository {
    return &ExampleRepository{}
}

type ExampleRepository struct {}

func (r *ExampleRepository) Store(example *models.Example) error {
    return db.DB.Create(example).Error
}

Usecase を実装

Usecase は Repository へモデルを渡し、エラーを返す。
GET 系の API の場合はモデルとエラーの両方を返す。

ファイルは usecases/examples_usecase.go

usecases/examples_usecase.go
package usecases

import (
    "組織ドメイン/プロジェクト名/infrastructures/models"
    "組織ドメイン/プロジェクト名/infrastructures/repositories"
)

func NewExampleUsecase() *examplesUsecase {
    return &examplesUsecase{}
}

type examplesUsecase struct {}

func (u *examplesUsecase) Exec(example *models.Example) error {
    r := repositories.NewExampleRepository()
    if err := r.Store(example); err != nil {
        return err
    }
    return nil
}

Handler, Request, Response を実装

Request は Message の内容で、Response は {message: "success!"} と返す。
(この Response は使い回せるので、汎用的な形で実装する)

まずは requests/examples/examples_post_request.go を実装。

requests/examples/examples_post_request.go
package requests

type ExamplesPostRequest struct {
    Message string `json:"message" binding:"required"`
}

次に presentations/responses/values/message_response.go を実装。
なお、このモデルはエラー発生時のレスポンスにも利用する。

汎用的なレスポンス構造体は responses/values ディレクトリ、特定のレスポンスは responses/<APIパス> ディレクトリに実装し、特定のレスポンスの中に response/values の構造体を入れ子にすることでスッキリする。
(今回は responses/values のみ実装する)

responses/values/message_response.go
package responses

type MessageResponse struct {
    Message string `json:"message"`
}

最後に、これらを取り回す presentations/handlers/examples_handler.go を実装。
Handler の責務は「バリデーション」「リクエストのモデル化」「Usecaseの橋渡し」「レスポンスの生成」。
(ここでリクエストをモデルにせず、DTOにしたほうが良いパターンもあると考えられる)

presentations/handlers/examples_handler.go
package handlers

import (
    "github.com/gin-gonic/gin"
    "組織ドメイン/プロジェクト名/infrastructures/models"
    "組織ドメイン/プロジェクト名/presentations/requests/examples"
    "組織ドメイン/プロジェクト名/presentations/responses/values"
    "組織ドメイン/プロジェクト名/usecases"
    "net/http"
)

func NewExamplesHandler() *examplesHandler {
    return &examplesHandler{}
}

type examplesHandler struct{}

func (h *examplesHandler) Post(c *gin.Context) {
    // リクエストのデシリアライズ/バリデーション
    var request requests.ExamplesPostRequest
    if err := c.BindJSON(&request); err != nil {
        c.JSON(http.StatusBadRequest, responses.MessageResponse{Message: "Bad Request."})
        return
    }

    // リクエストのモデル化/Usecaseの橋渡し
    usecase := usecases.NewExampleUsecase()
    example := models.Example{Message: request.Message}
    if err := usecase.Exec(&example); err != nil {
        c.JSON(http.StatusInternalServerError, responses.MessageResponse{Message: "Internal Server Error."})
        return
    }

    // レスポンスの生成
    response := responses.MessageResponse{Message: "Success"}
    c.JSON(http.StatusOK, response)
}

ルーティングを設定

上述した config/route.go を下記のように編集。

config/route.go
package config

import (
    "github.com/gin-gonic/gin"
    "組織ドメイン/プロジェクト名/middleware"
    "組織ドメイン/プロジェクト名/presentations/handlers"
)

// ルーティング設定
func RouteV1(app *gin.Engine) {
    app.Use(middleware.CORS())
    app.Use(gin.Logger())
    app.Use(middleware.RequestLogger())

    openAPI(app)
    authAPI(app)
}

// 認証不要のAPI
func openAPI(app *gin.Engine) {
    openApiGroup := app.Group("open")

    // 疎通確認
    pingHandler := handlers.NewPingHandler()
    openApiGroup.GET("ping", pingHandler.Get)

    // DBの疎通確認
    exampleHandler := handlers.NewExamplesHandler()
    openApiGroup.POST("example", exampleHandler.Post)
}

// 認証必須のAPI
func authAPI(app *gin.Engine) {
}

検証してみる

今回実装した API は POST なのでブラウザではテストできない。
そのため、Advanced REST clientを使って検証する。

まずは go run main.go でサーバーを立ち上げる。
次に、Advanced REST client で画像のように設定し、「SEND」をクリック。

image.png

最後に Sequel Ace で examples テーブルを見てみると、データが格納されたことが確認できる。
(created_at と update_at は自動で設定される)

image.png

また、リクエストボディを空で送信すると Bad Request になることも確認可能。

image.png

これで、APIサーバの環境構築ができた、あとはここに実務で利用する API を追加していくだけで良い。

最終的なディレクトリ構成

画像の通りになった。
(db/init は初期データ用のディレクトリ, Docker 立ち上げ時に SQL を実行するようにしている)

image.png

各ディレクトリの役割を整理すると下記の通り。

ディレクトリ名 役割
config HTTPサーバ関係の処理
db GORM関係の処理とSQL
infrastructures DBや外部APIとの接続関係の処理
middleware handlerより先に実行される, CORSとLogger
presentations HTTPのリクエスト/レスポンス関係の処理
usecases ビジネスロジック

さいごに

環境構築からDB接続テストまで実装ができたので、次から実装するときはこの記事が参考にできるようになった。基本的に変更に強い形を目指して実装しているが、オレオレ実装に加えて DI やユニットテスト等を省いているので不完全ではある(モデルもただのDTOになっている...)。ただ、今回は簡易実装なので、最低限の部分はカバーできているのではないかと思う。また、今回作ったプロジェクトは業務用の雛形なので GitHub 等での公開はしていないが、いずれ余裕があるときにアップできればと考えている。

Go を用いた API サーバの構成はまだまだ勉強中なので、ご指摘等あればコメントでいただけますと幸いです。

参考記事

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