Go の Gin と GORM の連携

Gin と GORM を組み合わせると、Web API とデータベースを連携させたアプリケーションを効率的に構築できる。ここでは実践的な CRUD API の実装を解説する。

プロジェクト構成

├── main.go
├── models/
│   └── user.go
├── handlers/
│   └── user.go
└── database/
    └── database.go

データベース接続の設定

// database/database.go
package database

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var DB *gorm.DB

func Connect() error {
    dsn := "user:password@tcp(localhost:3306)/myapp?charset=utf8mb4&parseTime=True&loc=Local"
    
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return err
    }
    
    DB = db
    return nil
}

モデルの定義

// models/user.go
package models

import "gorm.io/gorm"

type User struct {
    gorm.Model
    Name  string `json:"name" gorm:"size:100;not null"`
    Email string `json:"email" gorm:"size:255;uniqueIndex;not null"`
    Age   int    `json:"age"`
}

gorm.Model を埋め込むと、ID、CreatedAt、UpdatedAt、DeletedAt フィールドが自動で追加される。

ハンドラの実装

// handlers/user.go
package handlers

import (
    "net/http"
    "myapp/database"
    "myapp/models"
    "github.com/gin-gonic/gin"
)

// ユーザー一覧取得
func GetUsers(c *gin.Context) {
    var users []models.User
    
    result := database.DB.Find(&users)
    if result.Error != nil {
        c.JSON(500, gin.H{"error": "Failed to fetch users"})
        return
    }
    
    c.JSON(200, users)
}

// ユーザー取得
func GetUser(c *gin.Context) {
    id := c.Param("id")
    var user models.User
    
    result := database.DB.First(&user, id)
    if result.Error != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }
    
    c.JSON(200, user)
}

作成と更新

type CreateUserInput struct {
    Name  string `json:"name" binding:"required"`
    Email string `json:"email" binding:"required,email"`
    Age   int    `json:"age" binding:"gte=0"`
}

// ユーザー作成
func CreateUser(c *gin.Context) {
    var input CreateUserInput
    
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    user := models.User{
        Name:  input.Name,
        Email: input.Email,
        Age:   input.Age,
    }
    
    result := database.DB.Create(&user)
    if result.Error != nil {
        c.JSON(500, gin.H{"error": "Failed to create user"})
        return
    }
    
    c.JSON(201, user)
}

type UpdateUserInput struct {
    Name  string `json:"name"`
    Email string `json:"email" binding:"omitempty,email"`
    Age   int    `json:"age" binding:"omitempty,gte=0"`
}

// ユーザー更新
func UpdateUser(c *gin.Context) {
    id := c.Param("id")
    var user models.User
    
    if err := database.DB.First(&user, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }
    
    var input UpdateUserInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    database.DB.Model(&user).Updates(input)
    c.JSON(200, user)
}

削除

func DeleteUser(c *gin.Context) {
    id := c.Param("id")
    var user models.User
    
    if err := database.DB.First(&user, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }
    
    database.DB.Delete(&user)
    c.JSON(200, gin.H{"message": "User deleted"})
}

GORM のデフォルトは論理削除(DeletedAt に日時をセット)だ。物理削除したい場合は Unscoped().Delete() を使う。

メインファイル

// main.go
package main

import (
    "log"
    "myapp/database"
    "myapp/handlers"
    "myapp/models"
    "github.com/gin-gonic/gin"
)

func main() {
    // DB接続
    if err := database.Connect(); err != nil {
        log.Fatal("Failed to connect database:", err)
    }
    
    // マイグレーション
    database.DB.AutoMigrate(&models.User{})
    
    r := gin.Default()
    
    // ルーティング
    users := r.Group("/users")
    {
        users.GET("", handlers.GetUsers)
        users.GET("/:id", handlers.GetUser)
        users.POST("", handlers.CreateUser)
        users.PUT("/:id", handlers.UpdateUser)
        users.DELETE("/:id", handlers.DeleteUser)
    }
    
    r.Run(":8080")
}

ページネーション

一覧取得にページネーションを追加する。

func GetUsers(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
    offset := (page - 1) * limit
    
    var users []models.User
    var total int64
    
    database.DB.Model(&models.User{}).Count(&total)
    database.DB.Offset(offset).Limit(limit).Find(&users)
    
    c.JSON(200, gin.H{
        "data":  users,
        "total": total,
        "page":  page,
        "limit": limit,
    })
}

トランザクション

複数のデータベース操作をまとめて行う場合はトランザクションを使う。

func TransferPoints(c *gin.Context) {
    var input TransferInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    err := database.DB.Transaction(func(tx *gorm.DB) error {
        // 送信元からポイント減算
        if err := tx.Model(&models.User{}).
            Where("id = ?", input.FromID).
            Update("points", gorm.Expr("points - ?", input.Amount)).Error; err != nil {
            return err
        }
        
        // 送信先にポイント加算
        if err := tx.Model(&models.User{}).
            Where("id = ?", input.ToID).
            Update("points", gorm.Expr("points + ?", input.Amount)).Error; err != nil {
            return err
        }
        
        return nil
    })
    
    if err != nil {
        c.JSON(500, gin.H{"error": "Transfer failed"})
        return
    }
    
    c.JSON(200, gin.H{"message": "Transfer successful"})
}

トランザクション内でエラーを返すと自動的にロールバックされる。Gin と GORM の組み合わせは Go での Web API 開発において定番の構成だ。両者の機能を理解し、適切に連携させることで、保守性の高いアプリケーションが構築できる。