Go × Gin 応用編:実践テクニックとスケーラブルなAPI設計

  • golang
    golang
  • gin
    gin
2023/11/29に公開

はじめに

Go × Ginの基礎を押さえたら、いよいよ次のステップに進みます。
この応用編では、実際のAPI開発で役立つテクニックや、将来的にスケーラブルなシステムを作るための設計のヒントをまとめます。

具体的には以下のようなテーマを扱います。

  • 認証やバリデーションの実装例
  • エラーハンドリングのパターン
  • Ginアプリケーションのテスト手法
  • カスタムミドルウェアの作り方
  • スケーラブルなAPI設計への展望

これらを踏まえ、実務でも迷わない基礎力をさらに強化していきます。

Ginの基礎を知りたい方は、こちらの記事をご覧ください。

https://shinagawa-web.com/blogs/go-gin-basic-guide

Ginでよく使う便利な機能

バインディング(Binding)

Ginは、リクエストのパラメータを構造体にマッピング(バインディング)できます。
以下は、クエリパラメータを構造体にバインディングする例です。

main.go
type QueryParams struct {
    Name string `form:"name"`
    Age  int    `form:"age"`
}

r.GET("/bind", func(c *gin.Context) {
    var params QueryParams
    if err := c.ShouldBindQuery(&params); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"name": params.Name, "age": params.Age})
})
curl 'http://localhost:8080/bind?name=Gopher'

レスポンス

{"age":0,"name":"Gopher"}

バリデーション(Validation)

Ginは binding タグを使ってバリデーションも行えます。

type User struct {
    Name string `json:"name" binding:"required"`
    Age  int    `json:"age" binding:"gte=0,lte=120"`
}

r.POST("/validate", func(c *gin.Context) {
    var user User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"name": user.Name, "age": user.Age})
})
  • binding:"required" → 必須チェック
  • binding:"gte=0,lte=120" → 0以上120以下の範囲をチェック

リクエスト

curl -X POST http://localhost:8080/validate \
  -H "Content-Type: application/json" \
  -d '{"name": "Gopher", "age": 5}'

レスポンス

{"age":5,"name":"Gopher"}

リクエスト(ageを範囲外の数字に変更)

curl -X POST http://localhost:8080/validate \
  -H "Content-Type: application/json" \
  -d '{"name": "Gopher", "age": 999}'

レスポンス

{"error":"Key: 'User.Age' Error:Field validation for 'Age' failed on the 'lte' tag"}

カスタムバリデータの作成

標準のバリデーションでは足りない場合は、独自のバリデーションロジックを登録できます。

package main

import (
    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"
    "github.com/gin-gonic/gin/binding"
)

func main() {
    r := gin.Default()

    // カスタムバリデーション登録
    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("customTag", customValidation)
    }

    r.POST("/custom", func(c *gin.Context) {
        var user User
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        c.JSON(200, gin.H{"name": user.Name})
    })

    r.Run()
}

type User struct {
    Name string `json:"name" binding:"required,customTag"`
}

// カスタムバリデーション関数
func customValidation(fl validator.FieldLevel) bool {
    value := fl.Field().String()
    return len(value) >= 3 // 3文字以上で合格
}

リクエスト

curl -X POST http://localhost:8080/custom \
  -H "Content-Type: application/json" \
  -d '{"name":"Gopher"}'

レスポンス

{"name":"Gopher"}

リクエスト(条件に合わない「3文字未満」の例)

curl -X POST http://localhost:8080/custom \
  -H "Content-Type: application/json" \
  -d '{"name":"Go"}'

レスポンス

{"error":"Key: 'User.Name' Error:Field validation for 'Name' failed on the 'customTag' tag"}

構造化されたハンドラの設計

Ginを使ったAPI開発が進むにつれて、ハンドラの構造化が重要になってきます。
ここでは、ハンドラを疎結合で保守しやすくするための考え方や、依存性注入の基本例を見ていきます。

なぜ構造化が必要

ハンドラを func(c *gin.Context) のまま書いていくと、以下のような課題が出てきます。

  • データベース接続や外部サービスの依存が増える
  • テストしにくい
  • 可読性が下がる

ハンドラを構造体としてまとめ、ビジネスロジックをサービス層に分離することで、これらの問題を防ぎます。

サービス層とは?

サービス層は、アプリケーションのビジネスロジックを担当する部分です。
具体的な例として、ユーザー情報を扱う UserService インターフェースを用意することが多いです。

type UserService interface {
    GetByID(id string) (User, error)
}

構造体ハンドラの例

ハンドラは、サービス層の依存を注入して初期化します。

type UserHandler struct {
    userService UserService
}

func NewUserHandler(service UserService) *UserHandler {
    return &UserHandler{userService: service}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    user, err := h.userService.GetByID(id)
    if err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }
    c.JSON(200, user)
}

この例では、ハンドラは UserService に仕事を任せており、処理の中心はサービス層に移っています。

ルーティングでの利用

実際にルーティングで利用するときは、依存関係を組み合わせて渡します。

// 例: main.go
repo := NewUserRepository()
userService := NewUserService(repo)
userHandler := NewUserHandler(userService)

r.GET("/users/:id", userHandler.GetUser)
  • UserRepository: データアクセス担当
  • UserService: ビジネスロジック担当
  • UserHandler: HTTPのやりとりを担当

このように分離することで、役割が明確になります。

構造化のメリット

  • 依存性が明確化し、コードの見通しが良くなる
  • モックの注入がしやすくなり、テストが容易に
  • 役割ごとに保守・拡張しやすくなる

補足:実装例

上記イメージを実際のコードに落としたものとなります。

ディレクトリ構成(例)

go-gin-test-guide/
├── main.go
├── handler/
│   └── user_handler.go
├── service/
│   └── user_service.go
├── repository/
│   └── user_repository.go
└── model/
    └── user.go
model/user.go
package model

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}
repository/user_repository.go
package repository

import "my-gin-app/model"

type UserRepository interface {
    FindByID(id string) (*model.User, error)
}

type userRepositoryImpl struct {
    // 実際にはDB接続などを持つが、今回は省略
}

func NewUserRepository() UserRepository {
    return &userRepositoryImpl{}
}

func (r *userRepositoryImpl) FindByID(id string) (*model.User, error) {
    // 例として、静的なユーザー情報を返す
    if id == "1" {
        return &model.User{ID: "1", Name: "Gopher"}, nil
    }
    return nil, nil // 見つからなかった場合
}
service/user_service.go
package service

import (
    "errors"
    "my-gin-app/model"
    "my-gin-app/repository"
)

type UserService interface {
    GetByID(id string) (*model.User, error)
}

type userServiceImpl struct {
    repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) UserService {
    return &userServiceImpl{repo: repo}
}

func (s *userServiceImpl) GetByID(id string) (*model.User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, err
    }
    if user == nil {
        return nil, errors.New("user not found")
    }
    return user, nil
}
handler/user_handler.go
package handler

import (
    "net/http"
    "my-gin-app/service"

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

type UserHandler struct {
    userService service.UserService
}

func NewUserHandler(service service.UserService) *UserHandler {
    return &UserHandler{userService: service}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    user, err := h.userService.GetByID(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    c.JSON(http.StatusOK, user)
}
main.go
package main

import (
    "my-gin-app/handler"
    "my-gin-app/repository"
    "my-gin-app/service"

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

func main() {
    r := gin.Default()

    userRepo := repository.NewUserRepository()
    userService := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userService)

    // ルーティング
    r.GET("/users/:id", userHandler.GetUser)

    r.Run(":8080")
}

リクエスト(ユーザーが見つかる場合)

curl http://localhost:8080/users/1

レスポンス

{"id":"1","name":"Gopher"}

リクエスト

curl http://localhost:8080/users/999

レスポンス

{"error":"User not found"}

テスト手法

Ginを使ったAPI開発では、以下の3つのレイヤーに分けてテストを設計すると堅牢です。

  • ハンドラのテスト
  • サービス層・リポジトリ層のテスト
  • インテグレーションテスト(統合テスト)

それぞれのテスト手法とサンプルコード例を以下にまとめます。

ハンドラのテスト

Ginのハンドラをテストする際は、以下を意識します。

  • 実際のHTTPリクエストを模倣できる httptest.NewRecorder
  • 必要に応じて依存するサービスやリポジトリをモック化する(今回の例ではサービスをモックにする)

モックの定義

まず、service.UserService をモック化します。
これにより、ハンドラの振る舞いだけをテストできます。

handler/user_handler_test.go
package handler_test

import (
	"errors"
	"go-gin-basic-guide/handler"
	"go-gin-basic-guide/model"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

// モックサービス
type mockUserService struct {
    getByIDFunc func(id string) (*model.User, error)
}

func (m *mockUserService) GetByID(id string) (*model.User, error) {
    return m.getByIDFunc(id)
}

ハンドラのテストコード

実際に /users/:id エンドポイントをテストします。

handler/user_handler_test.go
func TestGetUser_Success(t *testing.T) {
    // Ginをテストモードにする
    gin.SetMode(gin.TestMode)

    // モックサービスを準備
    mockService := &mockUserService{
        getByIDFunc: func(id string) (*model.User, error) {
            return &model.User{ID: id, Name: "Test User"}, nil
        },
    }

    // ハンドラを生成
    userHandler := handler.NewUserHandler(mockService)

    // Ginのルーターにハンドラを設定
    r := gin.Default()
    r.GET("/users/:id", userHandler.GetUser)

    // リクエストを作成
    req, _ := http.NewRequest("GET", "/users/1", nil)
    w := httptest.NewRecorder()

    // リクエストを実行
    r.ServeHTTP(w, req)

    // 結果を検証
    assert.Equal(t, http.StatusOK, w.Code)
    assert.JSONEq(t, `{"id":"1","name":"Test User"}`, w.Body.String())
}

func TestGetUser_NotFound(t *testing.T) {
    gin.SetMode(gin.TestMode)

    mockService := &mockUserService{
        getByIDFunc: func(id string) (*model.User, error) {
            return nil, errors.New("user not found")
        },
    }

    userHandler := handler.NewUserHandler(mockService)

    r := gin.Default()
    r.GET("/users/:id", userHandler.GetUser)

    req, _ := http.NewRequest("GET", "/users/999", nil)
    w := httptest.NewRecorder()

    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusNotFound, w.Code)
    assert.JSONEq(t, `{"error":"User not found"}`, w.Body.String())
}

テスト実行

go test -v ./handler

テストが正常に終了しました。

Image from Gyazo

最後にリファクタリングとして共通のテスト用ルーター初期化関数を作っておきます。

handler/user_handler_test.go
func setupRouter(handlerFunc gin.HandlerFunc) *gin.Engine {
	gin.SetMode(gin.TestMode)
	r := gin.Default()
	r.GET("/users/:id", handlerFunc)
	return r
}

func TestGetUser_Success(t *testing.T) {
	mockService := &mockUserService{
		getByIDFunc: func(id string) (*model.User, error) {
			return &model.User{ID: id, Name: "Test User"}, nil
		},
	}
	userHandler := handler.NewUserHandler(mockService)
	r := setupRouter(userHandler.GetUser)

	req, _ := http.NewRequest("GET", "/users/1", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.JSONEq(t, `{"id":"1","name":"Test User"}`, w.Body.String())
}

func TestGetUser_NotFound(t *testing.T) {
	mockService := &mockUserService{
		getByIDFunc: func(id string) (*model.User, error) {
			return nil, errors.New("user not found")
		},
	}
	userHandler := handler.NewUserHandler(mockService)
	r := setupRouter(userHandler.GetUser)

	req, _ := http.NewRequest("GET", "/users/999", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	assert.Equal(t, http.StatusNotFound, w.Code)
	assert.JSONEq(t, `{"error":"User not found"}`, w.Body.String())
}

テストパターンが増えた際にはこのような共通関数が必要となってきます。

サービス層のテスト

サービス層のテストでは、リポジトリをモック化してビジネスロジックのみを検証します。
例として service/user_service.go のテストコードを紹介します。

/service/user_service_test.go
package service_test

import (
	"go-gin-basic-guide/model"
	"go-gin-basic-guide/service"
	"testing"

	"github.com/stretchr/testify/assert"
)


// リポジトリのモック
type mockUserRepo struct {
    findByIDFunc func(id string) (*model.User, error)
}

func (m *mockUserRepo) FindByID(id string) (*model.User, error) {
    return m.findByIDFunc(id)
}

func TestGetByID_Success(t *testing.T) {
    repo := &mockUserRepo{
        findByIDFunc: func(id string) (*model.User, error) {
            return &model.User{ID: id, Name: "Mock User"}, nil
        },
    }

    userService := service.NewUserService(repo)

    user, err := userService.GetByID("1")
    assert.NoError(t, err)
    assert.Equal(t, "Mock User", user.Name)
}

func TestGetByID_NotFound(t *testing.T) {
    repo := &mockUserRepo{
        findByIDFunc: func(id string) (*model.User, error) {
            return nil, nil
        },
    }

    userService := service.NewUserService(repo)

    user, err := userService.GetByID("999")
    assert.Nil(t, user)
    assert.EqualError(t, err, "user not found")
}

✅ ポイント

  • サービス層では、ビジネスロジックに関わる条件分岐を中心にテスト
  • データアクセスはモック化(リポジトリ層に依存しない)

テスト実行

go test -v ./service 

Image from Gyazo

リポジトリ層のテスト

リポジトリ層では、実際のDB(例: SQLiteやテスト用MySQL)を使うか、インメモリDBを使うことが多いです。
小さい場合はテーブルモックなどを使う例もあります。

例(簡略化した考え方):

func TestFindByID_RealDB(t *testing.T) {
    db := setupTestDB() // 例: SQLiteのメモリDBを初期化
    repo := repository.NewUserRepositoryWithDB(db)

    // 事前にテストデータを作成
    db.Exec("INSERT INTO users (id, name) VALUES (?, ?)", "1", "Test User")

    user, err := repo.FindByID("1")
    assert.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

統合テスト(インテグレーションテスト)

「実際にサーバーを立ち上げて、APIのリクエスト&レスポンスを検証するテスト」です。
httptest.NewServer を使うことで、サーバーを本番に近い形で起動してテストできます。

integration/integration_test.go
package integration_test

import (
	"go-gin-basic-guide/handler"
	"go-gin-basic-guide/repository"
	"go-gin-basic-guide/service"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

// テストサーバーをセットアップ
func setupIntegrationRouter() *gin.Engine {
	repo := repository.NewUserRepository()
	userService := service.NewUserService(repo)
	userHandler := handler.NewUserHandler(userService)

	r := gin.Default()
	r.GET("/users/:id", userHandler.GetUser)
	return r
}

func TestGetUserIntegration_Success(t *testing.T) {
	gin.SetMode(gin.TestMode)

	r := setupIntegrationRouter()
	ts := httptest.NewServer(r)
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/users/1")
	assert.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	// レスポンスボディも検証
	defer resp.Body.Close()
	body := make([]byte, resp.ContentLength)
	resp.Body.Read(body)
	assert.JSONEq(t, `{"id":"1","name":"Gopher"}`, string(body))
}

func TestGetUserIntegration_NotFound(t *testing.T) {
	gin.SetMode(gin.TestMode)

	r := setupIntegrationRouter()
	ts := httptest.NewServer(r)
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/users/999")
	assert.NoError(t, err)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)

	defer resp.Body.Close()
	body := make([]byte, resp.ContentLength)
	resp.Body.Read(body)
	assert.JSONEq(t, `{"error":"User not found"}`, string(body))
}

✅ ポイント

  • Ginサーバーを立ち上げて、実際にHTTPリクエストを送る
  • 依存も「実装」なので、サービス層〜リポジトリ層まで一気通貫でテスト
  • E2E(外部連携含むテスト)よりは範囲が狭いが、「アプリ内部の統合確認」に最適

テスト実行

go test -v ./integration 

Image from Gyazo

Ginのグルーピング・ルーティング活用術

バージョニング

共通パス/認証ルールの整理

エラーハンドリングのパターン

エラーレスポンスの統一化

GinのError型の活用

認証・認可の実装例

JWT認証

ミドルウェアとしての実装

よりスケーラブルな設計へ

サービス分割、マイクロサービス化へのステップ

将来的なアーキテクチャの展望

まとめと振り返り

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ