gorm互換の型安全なORMであるgenでCRUD APIを試作

GolangとGinでAPIを書くために必要な要素技術を学習中、
ORMは何が良いか考えてみたが、gormは無いなぁと思うに至った。
ORマッパーがどの程度の抽象化を担うべきか、については答えはないと思うが、
Webアプリケーションのシナリオで出てくるテーブル構造と関係程度は完全にSQLを排除して欲しい。
SQLを排除することで可読性が向上するし、静的型付けによる恩恵を得られる。

Genには以下のような特徴がある。

  • 型安全: コンパイル時にエラー検出
  • 自動補完: IDEでメソッドとフィールドが補完される
  • クエリビルダー: Where(q.Product.Name.Like(“%…”))のような直感的なAPI
  • GORM互換: 既存のGORMモデルをそのまま使用可能

なぜ”Gen”なのかは、ビルド時にGolangコードから静的に(ビルド前に)オブジェクトにアクセスする
ために必要なGoオブジェクトを生成する、という仕組みから来ているのではないかと思う。

環境構築

サクッとClaudeで環境を作った。実際に商用環境を作るとしたら必要な理解の度合いは上がるだろうが、
試してみるまでの時間が無駄にかかって勿体無いのと、Claudeに入口を教わるのは悪くない。
以下の構成で、Golang+GinにCRUDルートを設定しgenを介してDBアクセスできる。
models以下にテーブルと対応する型定義された構造体が格納される。
また、query以下にGormレベルの操作をGen(Golang)レベルに抽象化する自動生成コードが格納される。
query以下を読むと、GenがGormのラッパーであることが良くわかる。


$ tree . -n 2
.
├── cmd
│   └── generate 
│       └── main.go     # マイグレーション
├── config
│   └── database.go     # DB接続設定
├── database
│   └── database.go     # Conenct(), Close(), GetDB()など
├── docker-compose.yml  # Golangアプリケーション(8080), PostgreSQL(5432)
├── Dockerfile
├── go.mod
├── go.sum
├── handlers
│   └── product.go 
├── main.go             # CRUD APIのルーティング
├── models
│   └── product.go      # テーブル->モデル
├── query
│   ├── gen.go          # モデルを操作するラッパー
│   └── products.gen.go # SQLレベルのモデル操作をGolangレベルに抽象化するためのIF
└── README.md

CRUDルート

早速、CRUD APIのルートを作っていく。Claudeにお任せしたところ商品(Product)のCRUD APIが出来た。
その位置にMigrate置くの本当に良いの? という感があるが、本題はそこではないので省略。


package main

import (
	"log"

	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"github.com/ikuty/golang-gin/database"
	"github.com/ikuty/golang-gin/handlers"
	"github.com/ikuty/golang-gin/models"
	"github.com/ikuty/golang-gin/query"
)

func main() {
	// データベース接続
	if err := database.Connect(); err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	defer database.Close()

	// マイグレーション実行
	db := database.GetDB()
	if err := db.AutoMigrate(&models.Product{}); err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	// Gen初期化
	query.SetDefault(db)

	// Ginエンジンの初期化
	r := gin.Default()

	// 8. GORM + PostgreSQL - CRUD操作
	r.GET("/api/products", handlers.GetProductsHandler)           // 全商品取得
	r.GET("/api/products/:id", handlers.GetProductHandler)        // 商品詳細取得
	r.POST("/api/products", handlers.CreateProductHandler)        // 商品作成
	r.PUT("/api/products/:id", handlers.UpdateProductHandler)     // 商品更新
	r.DELETE("/api/products/:id", handlers.DeleteProductHandler)  // 商品削除
	r.GET("/api/products/search", handlers.SearchProductsHandler) // 商品検索

	// サーバー起動
	r.Run(":8080")
}

モデル

さて、モデル定義(=テーブル構造)はどうなっているかというと、以下の通り。
フィールドの物理型をGenを介してGolangで厳密で管理できるのは動的型付け言語にはない利点。


package models

import (
	"time"

	"gorm.io/gorm"
)

// Product は商品モデル
type Product struct {
	ID          uint           `gorm:"primarykey" json:"id"`
	Name        string         `gorm:"size:100;not null" json:"name" binding:"required"`
	Description string         `gorm:"size:500" json:"description"`
	Price       float64        `gorm:"not null" json:"price" binding:"required,gt=0"`
	Stock       int            `gorm:"default:0" json:"stock"`
	Category    string         `gorm:"size:50" json:"category"`
	CreatedAt   time.Time      `json:"created_at"`
	UpdatedAt   time.Time      `json:"updated_at"`
	DeletedAt   gorm.DeletedAt `gorm:"index" json:"-"`
}

// TableName はテーブル名を指定
func (Product) TableName() string {
	return "products"
}

ハンドラ(商品詳細取得)

素晴らしい。説明が不要なくらいDBアクセスが抽象化されている。
ただ、依存性注入があるEloquentと比べるとロジックと関係ない冗長な処理が残っている。
db,q,Contextは裏側に隠して欲しいという思いはあるものの、これでも良いかとも思う。
Find()はGenにより自動生成される。interfaceが用意されビルド時に全て解決される。
なお、VSCodeなどで補完が効く、というのは、例えば JetBrains環境であれば、
動的型付け言語であってもほぼ実現されているので、それほど実利があるメリットではない。


package handlers

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/ikuty/golang-gin/database"
	"github.com/ikuty/golang-gin/models"
	"github.com/ikuty/golang-gin/query"
)

// GetProductsHandler は全商品を取得
func GetProductsHandler(c *gin.Context) {
	db := database.GetDB()
	q := query.Use(db)

	products, err := q.Product.WithContext(c.Request.Context()).Find()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to fetch products",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": products,
		"count": len(products),
	})
}

ハンドラ(指定の商品を取得)

バリデータを介さず自力でバリデーション(IDがUintか)を行っている。
Productに対してWhereで条件指定し(Order By Ascした後に)先頭のオブジェクトを取得している。
もはや他に説明が必要ないくらい抽象化されていて良い。


// GetProductHandler は指定IDの商品を取得
func GetProductHandler(c *gin.Context) {
	id := c.Param("id")
	idUint, err := strconv.ParseUint(id, 10, 32)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}

	db := database.GetDB()
	q := query.Use(db)

	product, err := q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).First()
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{
			"error": "Product not found",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": product,
	})
}

ハンドラ(商品作成)

次はCreate。モデルオブジェクトを空から生成し入力値をバインドして整形した後に、
Create()に渡している。Create()の内部はGormレベルの(低レイヤの)コードが動く。


// CreateProductHandler は商品を作成
func CreateProductHandler(c *gin.Context) {
	var product models.Product

	if err := c.ShouldBindJSON(&product); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid request",
			"details": err.Error(),
		})
		return
	}

	db := database.GetDB()
	q := query.Use(db)

	if err := q.Product.WithContext(c.Request.Context()).Create(&product); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to create product",
		})
		return
	}

	c.JSON(http.StatusCreated, gin.H{
		"message": "Product created successfully",
		"data": product,
	})
}

ハンドラ(商品更新)

基本的にはCreate()と同じ。空モデルに入力値をバインドしUpdate()に渡している。
実行後に更新対象のオブジェクトを取得しているがEloquentは確か更新の戻りがオブジェクトだった。


// UpdateProductHandler は商品を更新
func UpdateProductHandler(c *gin.Context) {
	id := c.Param("id")
	idUint, err := strconv.ParseUint(id, 10, 32)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}

	db := database.GetDB()
	q := query.Use(db)
	ctx := c.Request.Context()

	// 既存の商品を取得
	product, err := q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First()
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{
			"error": "Product not found",
		})
		return
	}

	// 更新データをバインド
	var updateData models.Product
	if err := c.ShouldBindJSON(&updateData); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid request",
			"details": err.Error(),
		})
		return
	}

	// 更新実行
	_, err = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).Updates(&updateData)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to update product",
		})
		return
	}

	// 更新後のデータを取得
	product, _ = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First()

	c.JSON(http.StatusOK, gin.H{
		"message": "Product updated successfully",
		"data": product,
	})
}

ハンドラ(論理削除)

DeletedAtフィールドがNULLの場合、そのレコードはアクティブ。非Nullなら論理削除済み。
Unscoped()を介さずDelete()した場合(つまりデフォルトでは)論理削除となる。
DeletedAtは他のAPIから透過的に扱われる。論理削除状態かどうかは把握しなくて良い。
DeletedAtはデフォルトでは*time.Time型だが、のデータ形式の対応も可能。


// DeleteProductHandler は商品を削除(ソフトデリート)
func DeleteProductHandler(c *gin.Context) {
	id := c.Param("id")
	idUint, err := strconv.ParseUint(id, 10, 32)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}

	db := database.GetDB()
	q := query.Use(db)

	// ソフトデリート実行
	_, err = q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).Delete()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to delete product",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"message": "Product deleted successfully",
	})
}

ハンドラ(商品検索)

Where句を複数記述する場合など、手続き的に条件用のオブジェクトを足していける。
一見、productQueryを上から上書きしているように見えるが、Genのクエリビルダーはimmutableパターン
として振る舞い、都度実行によりWhereの戻りとなるオブジェクトが累積していく動作となる。


// SearchProductsHandler は商品を検索
func SearchProductsHandler(c *gin.Context) {
	db := database.GetDB()
	q := query.Use(db)
	ctx := c.Request.Context()

	// クエリパラメータを取得
	name := c.Query("name")
	category := c.Query("category")
	minPrice := c.Query("min_price")
	maxPrice := c.Query("max_price")

	// クエリビルダー
	productQuery := q.Product.WithContext(ctx)

	if name != "" {
		productQuery = productQuery.Where(q.Product.Name.Like("%" + name + "%"))
	}

	if category != "" {
		productQuery = productQuery.Where(q.Product.Category.Eq(category))
	}

	if minPrice != "" {
		if price, err := strconv.ParseFloat(minPrice, 64); err == nil {
			productQuery = productQuery.Where(q.Product.Price.Gte(price))
		}
	}

	if maxPrice != "" {
		if price, err := strconv.ParseFloat(maxPrice, 64); err == nil {
			productQuery = productQuery.Where(q.Product.Price.Lte(price))
		}
	}

	// 検索実行
	products, err := productQuery.Find()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "Failed to search products",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": products,
		"count": len(products),
	})
}

変換後のクエリを見てみる。


$ http://localhost:8080/api/products/search?name=Test&category=Electronics&min_price=1400&max_price=1600

  SELECT * FROM "products"
  WHERE "products"."name" LIKE '%Test%'
    AND "products"."category" = 'Electronics'
    AND "products"."price" >= 1400
    AND "products"."price" <= 1600
    AND "products"."deleted_at" IS NULL

まとめ

GolangのORMであるGormをラップしたGenを使って、CRUDを行うAPIをGinで書いて動かしてみた。
確かにGormレベル(SQLレベル)の記述が不要であることを確認した。
(まだ見ていないが)テーブルをJOINしていった先にGormを素で触らないといけない場面は発生するだろうが、
多くのシナリオでGenだけで行けるのであれば、Genを導入するメリットとなるのではないだろうか。