กลับไปยังบทความ

Backend

สร้าง Go REST API ด้วย Gin และ GORM: โครงสร้างโปรเจกต์, Migration และ CRUD

สร้าง REST API ด้วย Go, Gin และ GORM บน PostgreSQL ตั้งแต่โครงสร้างโปรเจกต์ การตั้งค่าด้วย godotenv, model, migration แบบมีเวอร์ชัน, seed ข้อมูล, CRUD handler, route group และ CORS

24 กันยายน 256910 นาทีในการอ่าน

go rest api with gin and gorm

การสร้าง Go Gin GORM REST API คือการจับคู่ HTTP layer ที่เล็กและเร็วอย่าง Gin เข้ากับ ORM อย่าง GORM ที่เขียน SQL ส่วนใหญ่ให้เรา โดยในบทความนี้ใช้ PostgreSQL เป็นฐานข้อมูล เราจะสร้าง API สำหรับจัดการบทความตั้งแต่ศูนย์ ครอบคลุมโครงสร้างโปรเจกต์ การอ่าน config จาก environment variable, model, migration แบบมีเวอร์ชัน, seed data, CRUD handler, route group และ CORS

บทความนี้เป็นตอนแรกของซีรีส์สามตอน ตอนที่สองเพิ่ม JWT authentication และ Casbin RBAC ส่วนตอนที่สามเพิ่ม validation, file upload และ pagination ทั้งสามตอนต่อยอดจากโค้ดชุดเดียวกัน

เรากำลังสร้างอะไร

JSON API สำหรับเผยแพร่บทความ โดยแต่ละบทความอยู่ในหมวดหมู่ (category):

MethodPathหน้าที่
GET/api/v1/articlesดึงรายการบทความล่าสุด
GET/api/v1/articles/:idดึงบทความเดียว
POST/api/v1/articlesสร้างบทความ
PUT/api/v1/articles/:idแทนที่บทความทั้งก้อน
DELETE/api/v1/articles/:idลบบทความ

คำว่า REST ในที่นี้หมายถึงอะไรง่าย ๆ คือ resource ระบุด้วย URL ใช้ HTTP method มาตรฐาน request เป็น stateless และตอบกลับด้วย status code ที่มีความหมาย

โครงสร้างโปรเจกต์สำหรับ Gin และ GORM API

Layout แบบ MVC เหมาะกับ API ขนาดนี้ เนื่องจาก API ตอบเป็น JSON จึงไม่มี view layer เหลือแค่ model, controller และ route:

text
articles-api/
├── main.go
├── .env.example
├── go.mod
├── config/
│   ├── config.go      # environment variables
│   └── database.go    # GORM connection
├── models/            # structs mapped to tables
├── migrations/        # versioned schema changes
├── seeds/             # demo and initial data
├── controllers/       # HTTP handlers
└── routes/            # route groups and wiring

แต่ละโฟลเดอร์คือหนึ่ง Go package ถ้ายังไม่แน่ใจว่า import เชื่อมโฟลเดอร์เหล่านี้เข้าด้วยกันอย่างไร อ่าน Go modules และ packages ก่อน ส่วนเรื่องการจัด layout และ error handling เมื่อโค้ดโตขึ้น ดูได้ที่ Go error handling และ project layout

สร้าง module และติดตั้ง dependency:

bash
go mod init example.com/articles-api
go get github.com/gin-gonic/gin gorm.io/gorm gorm.io/driver/postgres \
  github.com/joho/godotenv github.com/gin-contrib/cors \
  github.com/go-gormigrate/gormigrate/v2

Tutorial เก่า ๆ มักจะ import github.com/jinzhu/gorm ซึ่งเป็น GORM v1 ที่ไม่มีการดูแลแล้ว ให้ใช้ gorm.io/gorm แทน API ต่างกันหลายจุด เช่น RecordNotFound() ถูกแทนที่ด้วย errors.Is(err, gorm.ErrRecordNotFound)

ตั้งค่า config ด้วย environment variable และ godotenv

Port, database URL และ origin ที่อนุญาต จะต่างกันไปในแต่ละ environment จึงควรอยู่ใน environment variable ไม่ใช่ในโค้ด godotenv ช่วยโหลดไฟล์ .env เข้ามาตอน development:

bash
# .env (never commit this; commit .env.example instead)
PORT=8080
DATABASE_URL=postgres://articles:secret@localhost:5432/articles?sslmode=disable
CORS_ORIGINS=http://localhost:3000
go
package config

import (
	"errors"
	"io/fs"
	"log"
	"os"
	"strings"

	"github.com/joho/godotenv"
)

type Config struct {
	Port        string
	DatabaseURL string
	CORSOrigins []string
}

func Load() Config {
	// .env is a development convenience. In production the variables
	// come from the container or platform, so a missing file is fine.
	if err := godotenv.Load(); err != nil && !errors.Is(err, fs.ErrNotExist) {
		log.Fatalf("load .env: %v", err)
	}

	cfg := Config{
		Port:        getEnv("PORT", "8080"),
		DatabaseURL: os.Getenv("DATABASE_URL"),
		CORSOrigins: strings.Split(getEnv("CORS_ORIGINS", "http://localhost:3000"), ","),
	}
	if cfg.DatabaseURL == "" {
		log.Fatal("DATABASE_URL is required")
	}
	return cfg
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

อย่าเรียก log.Fatal ทุกครั้งที่ godotenv.Load() คืน error เพราะ container ส่วนใหญ่ไม่มีไฟล์ .env อยู่แล้ว service จะล่มทันทีใน production ให้ fail เฉพาะเมื่อ ค่าที่จำเป็น หายไปแทน

เชื่อมต่อ GORM กับ PostgreSQL

go
package config

import (
	"time"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

func OpenDB(dsn string) (*gorm.DB, error) {
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
		// Map unique and foreign key violations to gorm.ErrDuplicatedKey
		// and gorm.ErrForeignKeyViolated so handlers can return 409/422.
		TranslateError: true,
	})
	if err != nil {
		return nil, err
	}

	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}
	sqlDB.SetMaxOpenConns(20)
	sqlDB.SetMaxIdleConns(10)
	sqlDB.SetConnMaxLifetime(30 * time.Minute)
	return db, nil
}

*gorm.DB ห่อ connection pool เอาไว้ และใช้พร้อมกันจากหลาย goroutine ได้อย่างปลอดภัย ให้สร้างครั้งเดียวตอน startup แล้ว inject เข้าไปในส่วนที่ต้องใช้ แทนที่จะเรียก GetDB() แบบ global จากทุกไฟล์

GORM model และ association

go
package models

import (
	"time"

	"gorm.io/gorm"
)

type Category struct {
	ID   uint   `gorm:"primaryKey" json:"id"`
	Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
}

type Article struct {
	ID         uint           `gorm:"primaryKey" json:"id"`
	Title      string         `gorm:"size:200;uniqueIndex;not null" json:"title"`
	Excerpt    string         `gorm:"size:500;not null" json:"excerpt"`
	Body       string         `gorm:"type:text;not null" json:"body"`
	Image      string         `gorm:"size:255" json:"image"`
	CategoryID uint           `gorm:"not null;index" json:"categoryId"`
	Category   *Category      `json:"category,omitempty"`
	CreatedAt  time.Time      `json:"createdAt"`
	UpdatedAt  time.Time      `json:"updatedAt"`
	DeletedAt  gorm.DeletedAt `gorm:"index" json:"-"`
}
  • CategoryID คู่กับ Category คือ association แบบ belongs-to GORM ใช้ข้อมูลนี้สร้าง foreign key และใช้ตอน Preload category
  • Category เป็น pointer พร้อม omitempty จึงไม่โผล่ใน response เมื่อไม่ได้ preload มา
  • DeletedAt gorm.DeletedAt เปิดใช้ soft delete คือ Delete จะแค่ใส่ timestamp และ query ปกติจะข้ามแถวเหล่านั้นไป
  • การ embed gorm.Model ให้ field ชุดเดียวกัน แต่ไม่มี JSON tag จึงออกมาเป็น ID และ CreatedAt การประกาศเองทำให้ API เป็น camelCase สม่ำเสมอ

Database migration ด้วย GORM

db.AutoMigrate(&models.Article{}) สร้างตารางและเพิ่มคอลัมน์ที่ขาด แต่ไม่เคยลบหรือเปลี่ยนชื่ออะไร และไม่เก็บประวัติว่าเปลี่ยนอะไรไปเมื่อไร สำหรับ production ควรใช้ migration แบบมีเวอร์ชัน:

วิธีเหมาะกับข้อจำกัด
AutoMigrate ตอน startupPrototype, testไม่มีประวัติ, rollback ไม่ได้, rename ไม่ได้
gormigrate (โค้ด Go)อยากเขียน migration เป็น GoDDL ยังสร้างโดย GORM
golang-migrate, goose, Atlasทีมที่อยาก review SQL ดิบเป็นเครื่องมือแยก

ซีรีส์นี้ใช้ gormigrate แต่ละ migration มี ID และ library จะบันทึก ID ที่รันแล้วไว้ในตาราง migrations:

go
package migrations

import (
	"time"

	"github.com/go-gormigrate/gormigrate/v2"
	"gorm.io/gorm"
)

func Run(db *gorm.DB) error {
	m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
		{
			ID: "202609240001_create_categories_and_articles",
			Migrate: func(tx *gorm.DB) error {
				// Snapshot structs: later edits to models/ must not
				// change what this migration does.
				type Category struct {
					ID   uint   `gorm:"primaryKey"`
					Name string `gorm:"size:100;uniqueIndex;not null"`
				}
				type Article struct {
					ID         uint   `gorm:"primaryKey"`
					Title      string `gorm:"size:200;uniqueIndex;not null"`
					Excerpt    string `gorm:"size:500;not null"`
					Body       string `gorm:"type:text;not null"`
					Image      string `gorm:"size:255"`
					CategoryID uint   `gorm:"not null;index"`
					Category   Category
					CreatedAt  time.Time
					UpdatedAt  time.Time
					DeletedAt  gorm.DeletedAt `gorm:"index"`
				}
				return tx.AutoMigrate(&Category{}, &Article{})
			},
			Rollback: func(tx *gorm.DB) error {
				return tx.Migrator().DropTable("articles", "categories")
			},
		},
	})
	return m.Migrate()
}

Struct ที่ประกาศไว้ภายใน migration สำคัญมาก ถ้า migration อ้างอิง models.Article ตรง ๆ วันหนึ่งที่เราเพิ่ม field ใน model สิ่งที่ migration เก่าตัวนี้สร้างบนฐานข้อมูลใหม่ก็จะเปลี่ยนไปแบบเงียบ ๆ

Seed ข้อมูลเริ่มต้น

Seeding คือการใส่ข้อมูลที่ฐานข้อมูลใหม่ต้องมี เช่น category เริ่มต้น และข้อมูลตัวอย่างสำหรับ development ควรเขียน seed ให้ idempotent คือรันซ้ำกี่ครั้งก็ไม่ error และไม่สร้างข้อมูลซ้ำ:

go
package seeds

import (
	"example.com/articles-api/models"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

func Run(db *gorm.DB) error {
	return db.Transaction(func(tx *gorm.DB) error {
		var goCat models.Category
		if err := tx.FirstOrCreate(&goCat, models.Category{Name: "Go"}).Error; err != nil {
			return err
		}

		articles := []models.Article{
			{Title: "Hello, Gin", Excerpt: "A first route.", Body: "...", CategoryID: goCat.ID},
		}
		// ON CONFLICT DO NOTHING skips titles that already exist.
		return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&articles).Error
	})
}

CRUD handler ด้วย Gin และ GORM

Handler ผูกอยู่กับ struct ที่ถือ database ไว้ ทำให้ dependency ชัดเจนและเปลี่ยนเป็นตัวปลอมใน test ได้ง่าย:

go
package controllers

import (
	"errors"
	"net/http"
	"strconv"

	"example.com/articles-api/models"
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

type ArticleHandler struct{ db *gorm.DB }

func NewArticleHandler(db *gorm.DB) *ArticleHandler { return &ArticleHandler{db: db} }

type articleRequest struct {
	Title      string `json:"title" binding:"required,max=200"`
	Excerpt    string `json:"excerpt" binding:"required,max=500"`
	Body       string `json:"body" binding:"required"`
	CategoryID uint   `json:"categoryId" binding:"required"`
}

func parseID(c *gin.Context) (uint64, bool) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return 0, false
	}
	return id, true
}

func (h *ArticleHandler) List(c *gin.Context) {
	var articles []models.Article
	err := h.db.WithContext(c.Request.Context()).
		Preload("Category").Order("created_at DESC").Limit(20).
		Find(&articles).Error
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load articles"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"data": articles})
}

func (h *ArticleHandler) Create(c *gin.Context) {
	var req articleRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	article := models.Article{
		Title: req.Title, Excerpt: req.Excerpt, Body: req.Body, CategoryID: req.CategoryID,
	}
	err := h.db.WithContext(c.Request.Context()).Create(&article).Error
	switch {
	case errors.Is(err, gorm.ErrDuplicatedKey):
		c.JSON(http.StatusConflict, gin.H{"error": "an article with this title already exists"})
	case errors.Is(err, gorm.ErrForeignKeyViolated):
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "category does not exist"})
	case err != nil:
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create article"})
	default:
		c.JSON(http.StatusCreated, gin.H{"data": article})
	}
}

Get ใช้ parseID แปลง ID แล้วเรียก Preload("Category").First(&article, id) และแปลง errors.Is(err, gorm.ErrRecordNotFound) เป็น 404 ส่วน Delete เรียก db.Delete(&models.Article{}, id) แล้วคืน 404 ถ้า RowsAffected เป็น 0 ไม่อย่างนั้นคืน 204 ขณะที่ Update โหลดแถวด้วย First ก่อน bind request แล้วเรียก db.Model(&article).Updates(models.Article{...})

ข้อควรระวังคือ Updates ที่รับ struct จะ ข้าม field ที่เป็น zero value ซึ่งไม่มีปัญหากับการแทนที่ทั้งก้อน แต่ถ้าเป็น PATCH แบบบางส่วน ให้ใช้ pointer field หรือ map[string]any เพื่อแยกให้ออกว่า "ไม่ได้ส่งมา" กับ "ตั้งใจให้ว่าง"

นิสัยสองอย่างที่ควรทำตั้งแต่แรก:

  • ส่ง c.Request.Context() ผ่าน WithContext เพื่อให้ query หยุดเมื่อ client ตัดการเชื่อมต่อ
  • ตอบ 500 ด้วยข้อความกลาง ๆ แล้ว log รายละเอียดไว้ฝั่ง server เพราะ error ดิบจากฐานข้อมูลเปิดเผยชื่อตารางและ constraint

ตอนนี้ binding error ยังถูกส่งกลับเป็น string ดิบ ๆ ตอนที่สามจะเปลี่ยนเป็นข้อความแยกราย field ที่มีโครงสร้าง

Route group

Group ใช้ path prefix ร่วมกัน และต่อไปจะใช้ middleware ร่วมกันด้วย การใส่เวอร์ชัน /api/v1 ไว้ตั้งแต่แรกแทบไม่มีต้นทุน แต่ช่วยได้มากเมื่อต้องเปลี่ยน API แบบ breaking change:

go
package routes

import (
	"net/http"

	"example.com/articles-api/controllers"
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

func Register(r *gin.Engine, db *gorm.DB) {
	articles := controllers.NewArticleHandler(db)

	r.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })

	api := r.Group("/api/v1")
	{
		a := api.Group("/articles")
		a.GET("", articles.List)
		a.GET("/:id", articles.Get)
		a.POST("", articles.Create)
		a.PUT("/:id", articles.Update)
		a.DELETE("/:id", articles.Delete)
	}
}

CORS และ main.go

CORS (Cross-Origin Resource Sharing) คือกลไกที่ browser ใช้ตัดสินว่า JavaScript บน https://admin.example.com อ่าน response จาก API ที่อยู่คนละ origin ได้หรือไม่ มีแค่ browser ที่บังคับใช้ จึงกัน curl หรือ server อื่นไม่ได้ และใช้แทน authentication ไม่ได้

go
package main

import (
	"flag"
	"log"
	"net/http"
	"time"

	"example.com/articles-api/config"
	"example.com/articles-api/migrations"
	"example.com/articles-api/routes"
	"example.com/articles-api/seeds"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

func main() {
	seed := flag.Bool("seed", false, "insert seed data and exit")
	flag.Parse()

	cfg := config.Load()
	db, err := config.OpenDB(cfg.DatabaseURL)
	if err != nil {
		log.Fatalf("connect database: %v", err)
	}
	if err := migrations.Run(db); err != nil {
		log.Fatalf("migrate: %v", err)
	}
	if *seed {
		if err := seeds.Run(db); err != nil {
			log.Fatalf("seed: %v", err)
		}
		log.Println("seed complete")
		return
	}

	r := gin.Default() // Logger and Recovery middleware
	if err := r.SetTrustedProxies(nil); err != nil {
		log.Fatal(err)
	}
	r.Use(cors.New(cors.Config{
		AllowOrigins:     cfg.CORSOrigins,
		AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
		AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
		AllowCredentials: true,
		MaxAge:           12 * time.Hour,
	}))
	routes.Register(r, db)

	srv := &http.Server{Addr: ":" + cfg.Port, Handler: r, ReadHeaderTimeout: 5 * time.Second}
	log.Fatal(srv.ListenAndServe())
}

ให้ลงทะเบียน CORS ก่อน route ระบุ origin ให้ชัดเจน และอย่าใช้ "allow all origins" คู่กับ credentials ส่วน SetTrustedProxies(nil) ทำให้ Gin ไม่เชื่อ header X-Forwarded-For จาก client ใด ๆ ถ้าอยู่หลัง load balancer ให้ระบุช่วง IP ของมันแทน

รัน go run . -seed หนึ่งครั้ง จากนั้น go run . แล้วลอง curl localhost:8080/api/v1/articles

คำถามที่พบบ่อย

GORM เร็วพอสำหรับ production ไหม

สำหรับ CRUD API ทั่วไป เร็วพอ ความช้าส่วนใหญ่มาจากตัว query เอง เช่น N+1 หรือขาด index ใช้ db.Debug() เพื่อดู SQL ที่ GORM สร้าง และอ่านวิธีแก้ที่พบบ่อยได้ใน การปรับแต่ง SQL query

ควรใช้ AutoMigrate ใน production ไหม

ไม่ควรใช้เป็นวิธีเดียว เพราะมัน rename หรือ drop คอลัมน์ไม่ได้และไม่มีประวัติ ให้ใช้ migration แบบมีเวอร์ชัน

ควรใช้ Gin หรือ router ของ standard library

ตั้งแต่ Go 1.22 net/http รองรับ pattern อย่าง GET /articles/{id} แล้ว แต่ Gin ยังมี binding, validation, route group และ middleware helper ให้ใช้ จึงยังเป็นตัวเลือกยอดนิยมสำหรับ JSON API

ทำไม API ใช้ได้กับ curl แต่ใช้ใน browser ไม่ได้

เกือบทุกครั้งเป็นเรื่อง CORS ตรวจว่า origin ของหน้าเว็บ (scheme, host และ port) อยู่ใน AllowOrigins ตรงตัว และ preflight request แบบ OPTIONS ไม่ถูก middleware อื่นปฏิเสธไปก่อน

เช็กลิสต์ก่อนไปตอนต่อไป

  • Config มาจาก environment variable และค่าที่จำเป็นต้อง fail ทันทีถ้าไม่มี
  • สร้าง *gorm.DB ครั้งเดียวตอน startup แล้ว inject เข้า handler
  • การเปลี่ยน schema อยู่ใน migration แบบมีเวอร์ชันที่ใช้ snapshot struct
  • Seed เป็น idempotent
  • Handler แปลง ErrRecordNotFound, duplicate และ foreign key error เป็น 404, 409 และ 422
  • CORS ระบุ origin ชัดเจน

ตอนนี้ใครก็สร้างหรือลบบทความได้ ตอนที่สอง จะเพิ่ม login, JWT และ role-based access control ถ้าต้องการทีมช่วยออกแบบหรือ review Go backend แบบนี้ Vectorkub รับพัฒนาและ review API สำหรับ production

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ