การสร้าง 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):
| Method | Path | หน้าที่ |
|---|---|---|
| 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:
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:
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/v2Tutorial เก่า ๆ มักจะ 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:
# .env (never commit this; commit .env.example instead)
PORT=8080
DATABASE_URL=postgres://articles:secret@localhost:5432/articles?sslmode=disable
CORS_ORIGINS=http://localhost:3000package 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
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
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 และใช้ตอนPreloadcategoryCategoryเป็น 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 ตอน startup | Prototype, test | ไม่มีประวัติ, rollback ไม่ได้, rename ไม่ได้ |
| gormigrate (โค้ด Go) | อยากเขียน migration เป็น Go | DDL ยังสร้างโดย GORM |
| golang-migrate, goose, Atlas | ทีมที่อยาก review SQL ดิบ | เป็นเครื่องมือแยก |
ซีรีส์นี้ใช้ gormigrate แต่ละ migration มี ID และ library จะบันทึก ID ที่รันแล้วไว้ในตาราง migrations:
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 และไม่สร้างข้อมูลซ้ำ:
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 ได้ง่าย:
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:
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 ไม่ได้
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
