บั๊กและช่องโหว่ส่วนใหญ่ของ JSON API เริ่มต้นตรงจุดที่ input ที่เชื่อถือไม่ได้ไหลเข้ามา บทความนี้ครอบคลุมสามฟีเจอร์ที่ Gin API แทบทุกตัวต้องมี คือ Gin validation สำหรับ body, query และ path parameter พร้อม error message ที่ client เอาไปใช้ได้จริง, การอัปโหลดรูปอย่างปลอดภัยด้วย FormFile และ pagination แบบ limit, offset และ total pages โดยให้ query นับจำนวนทั้งหมดรันพร้อมกันกับ query ดึงข้อมูล
นี่คือตอนที่สามของซีรีส์ ตอนแรก สร้าง API บทความด้วย GORM และ PostgreSQL ส่วน ตอนที่สอง เพิ่ม JWT login และ role ด้วย Casbin โค้ดในตอนนี้ต่อยอดจากโปรเจกต์เดิม
ข้อมูลใน request ของ Gin มาจากไหนบ้าง
Gin bind input แต่ละแหล่งเข้า struct โดยใช้ tag ต่างกัน:
| แหล่งข้อมูล | ตัวอย่าง | Struct tag | Method ที่ใช้ bind |
|---|---|---|---|
| JSON body | {"title":"..."} | json:"title" | c.ShouldBindJSON(&req) |
| Query string | ?page=2&limit=12 | form:"page" | c.ShouldBindQuery(&q) |
| Path parameter | /articles/:id | uri:"id" | c.ShouldBindUri(&u) |
| Multipart หรือ URL-encoded form | อัปโหลดไฟล์ | form:"title" | c.ShouldBind(&f) |
c.Query, c.DefaultQuery, c.Param และ c.PostForm ยังใช้ดึงค่าเดี่ยว ๆ ได้ แต่ได้ค่ากลับมาเป็น string ที่ต้องแปลงและตรวจเอง การ bind เข้า struct ได้ทั้งการแปลงชนิดและ validation ในขั้นเดียว
ให้ใช้ method กลุ่ม ShouldBind* เพราะกลุ่ม Bind* จะ abort พร้อม 400 เปล่า ๆ ทันทีที่ bind ไม่ผ่าน ทำให้เราเขียน error body ที่มีประโยชน์ไม่ได้
type articleURI struct {
ID uint `uri:"id" binding:"required,min=1"`
}
type listQuery struct {
Page int `form:"page,default=1" binding:"min=1"`
Limit int `form:"limit,default=12" binding:"min=1,max=100"`
Category uint `form:"category"`
Sort string `form:"sort,default=newest" binding:"oneof=newest oldest title"`
}
type articleRequest struct {
Title string `json:"title" binding:"required,notblank,min=5,max=200"`
Excerpt string `json:"excerpt" binding:"required,notblank,max=500"`
Body string `json:"body" binding:"required,notblank"`
Image string `json:"image" binding:"omitempty,max=255"`
CategoryID uint `json:"categoryId" binding:"required,gt=0"`
}Tag binding ใช้กฎของ go-playground/validator ตัวที่ใช้บ่อยคือ required, min, max, gt, oneof, email, url, omitempty (ข้ามกฎอื่นถ้า field ว่าง) และ dive (ตรวจทุก element ใน slice) ส่วน default= ใน tag form จะเติมค่าให้ query parameter ที่ไม่ได้ส่งมา ก่อน validation จะรัน
จุดที่คนพลาดบ่อย: required บนตัวเลขจะ fail เมื่อค่าเป็น 0 และบน bool จะ fail เมื่อเป็น false ถ้าศูนย์เป็นค่าที่ถูกต้อง ให้ใช้ pointer (*int) เพื่อแยก "ไม่ได้ส่ง" ออกจาก "ส่งศูนย์มา"
Custom validation error ที่ client เอาไปใช้ได้
โดย default เมื่อ bind ไม่ผ่าน เราจะได้ข้อความประมาณ Key: 'articleRequest.Title' Error:Field validation for 'Title' failed on the 'min' tag ซึ่งเปิดเผยชื่อ struct ใน Go และ frontend เอาไปใช้ต่อไม่ได้ ขั้นแรกให้รายงานชื่อ field ตามที่ client ส่งมา และลงทะเบียนกฎที่เขียนเอง โดยทำครั้งเดียวตอน startup:
package validation
import (
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
func Setup() error {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
return nil
}
// Use json/form/uri tag names in errors instead of Go field names.
v.RegisterTagNameFunc(func(f reflect.StructField) string {
for _, key := range []string{"json", "form", "uri"} {
name := strings.SplitN(f.Tag.Get(key), ",", 2)[0]
if name != "" && name != "-" {
return name
}
}
return f.Name
})
// "required" accepts " ". This rule does not.
return v.RegisterValidation("notblank", func(fl validator.FieldLevel) bool {
return strings.TrimSpace(fl.Field().String()) != ""
})
}ขั้นที่สอง แปลง validator.ValidationErrors ให้เป็น map จากชื่อ field ไปยังข้อความ:
func Respond(c *gin.Context, err error) {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
// Malformed JSON, wrong types, unreadable body.
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed request"})
return
}
fields := make(map[string]string, len(ve))
for _, fe := range ve {
fields[fe.Field()] = message(fe)
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "validation failed", "fields": fields})
}
func message(fe validator.FieldError) string {
unit := ""
if fe.Kind() == reflect.String {
unit = " characters"
}
switch fe.Tag() {
case "required", "notblank":
return "is required"
case "min":
return "must be at least " + fe.Param() + unit
case "max":
return "must be at most " + fe.Param() + unit
case "gt":
return "must be greater than " + fe.Param()
case "oneof":
return "must be one of: " + fe.Param()
}
return "is invalid"
}ทุก handler แค่เรียก validation.Respond(c, err) เมื่อ ShouldBind* คืน error และ client ก็ได้ response รูปแบบเดิมเสมอ ซึ่ง map เข้ากับช่องในฟอร์มได้ทันที:
{
"error": "validation failed",
"fields": {
"title": "must be at least 5 characters",
"categoryId": "is required"
}
}จะตอบ validation error ด้วย 400 หรือ 422 เป็นเรื่องของ convention เลือกอย่างใดอย่างหนึ่ง และแยก JSON ที่ผิดรูปแบบ (400) ออกมาต่างหาก
กฎที่ต้องพึ่งฐานข้อมูล เช่น "category นี้มีอยู่จริง" ยังต้องอยู่ใน handler โดยแปลง gorm.ErrForeignKeyViolated และ gorm.ErrDuplicatedKey แบบเดียวกับในตอนแรก
อัปโหลดไฟล์ด้วย FormFile
Client อัปโหลดรูปก่อน ได้ URL กลับมา แล้วส่ง URL นั้นใน field image ตอนสร้างบทความ:
const maxImageSize = 5 << 20 // 5 MiB
var allowedImageTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
func (h *UploadHandler) Image(c *gin.Context) {
// Hard cap on the whole request body, including multipart overhead.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageSize+(1<<20))
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "image is required and must be under 5 MiB"})
return
}
if file.Size > maxImageSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image must be under 5 MiB"})
return
}
ext, err := sniffImage(file)
if err != nil {
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "only JPEG, PNG and WebP are allowed"})
return
}
name := randomName() + ext
if err := c.SaveUploadedFile(file, filepath.Join(h.dir, name)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save image"})
return
}
c.JSON(http.StatusCreated, gin.H{"url": "/uploads/" + name})
}
// sniffImage checks the real content, not the client-supplied header or extension.
func sniffImage(fh *multipart.FileHeader) (string, error) {
f, err := fh.Open()
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, err := io.ReadFull(f, head)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return "", err
}
ext, ok := allowedImageTypes[http.DetectContentType(head[:n])]
if !ok {
return "", errors.New("unsupported file type")
}
return ext, nil
}
func randomName() string {
b := make([]byte, 16)
_, _ = rand.Read(b) // crypto/rand
return hex.EncodeToString(b)
}เหตุผลของแต่ละขั้น:
MaxBytesReaderคือตัวจำกัดขนาดตัวจริง ส่วนr.MaxMultipartMemoryแค่กำหนดว่าจะเก็บฟอร์มไว้ใน memory ได้เท่าไรก่อนที่ส่วนที่เหลือจะถูกเขียนลงไฟล์ชั่วคราว มันไม่ได้ปฏิเสธไฟล์ขนาดใหญ่- ตรวจจาก byte จริง
Content-Typeของแต่ละ part และนามสกุลไฟล์ client เป็นคนกำหนดเองhttp.DetectContentTypeอ่าน byte แรก ๆ ของไฟล์จริงแทน - ห้ามใช้
file.Filenameซ้ำ เพราะอาจมี../ชนกับไฟล์อื่น หรือมีนามสกุลอันตราย ให้สุ่มชื่อใหม่เสมอ - เสิร์ฟเฉพาะสิ่งที่ยอมรับ ปฏิเสธ SVG และ HTML ถ้าไม่ได้ sanitize เพราะทั้งคู่ฝัง script ได้
ลงทะเบียน route ไว้หลัง auth middleware จากตอนที่สอง เพิ่ม p, editor, /api/v1/uploads/images, POST ใน Casbin policy แล้วเสิร์ฟโฟลเดอร์:
if err := os.MkdirAll("uploads", 0o755); err != nil {
log.Fatal(err)
}
r.Static("/uploads", "./uploads") // no directory listing
protected.POST("/uploads/images", uploads.Image)Unix permission สำหรับโฟลเดอร์อัปโหลด
| Mode | Owner | Group | Others | ใช้กับ |
|---|---|---|---|---|
0755 | rwx | r-x | r-x | โฟลเดอร์อัปโหลด |
0644 | rw- | r-- | r-- | ไฟล์ที่อัปโหลด |
0777 | rwx | rwx | rwx | หลีกเลี่ยง: user ใดในเครื่องก็แทนที่ไฟล์ได้ |
ไฟล์ที่บันทึกด้วย SaveUploadedFile จะได้ 0666 หักด้วย umask ของ process ซึ่งปกติออกมาเป็น 0644 ควรรัน service ด้วย user ที่ไม่ใช่ root และเป็นเจ้าของแค่โฟลเดอร์นี้
Local disk ใช้ได้ถ้ามี instance เดียว แต่ถ้ามีหลาย replica หรือ container ที่ถูกสร้างใหม่ทุกครั้งที่ deploy ให้เก็บไฟล์ใน object storage อย่าง S3 แล้วเสิร์ฟผ่าน CDN ซึ่งเปลี่ยนแค่ขั้นตอนบันทึกไฟล์เท่านั้น
Pagination ด้วย limit, offset และ total pages
การคืนทุกแถวในครั้งเดียวจะช้าลงเรื่อย ๆ เมื่อตารางโตขึ้น Offset pagination เป็นวิธีที่ง่ายที่สุด:
?page=1&limit=12 -> OFFSET 0 LIMIT 12
?page=2&limit=12 -> OFFSET 12 LIMIT 12
offset = (page - 1) * limit
totalPages = ceil(total / limit)Client ยังต้องการจำนวนทั้งหมดด้วย ซึ่งต้องใช้ query COUNT(*) อีกตัว สอง query นี้ไม่ขึ้นต่อกัน จึงรันพร้อมกันได้ helper แบบ generic ด้านล่างใช้ errgroup ทำเรื่องนี้:
package pagination
import (
"context"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
)
type Params struct{ Page, Limit int }
type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
TotalPages int `json:"totalPages"`
PrevPage *int `json:"prevPage"`
NextPage *int `json:"nextPage"`
}
type Page[T any] struct {
Data []T `json:"data"`
Meta Meta `json:"meta"`
}
// Paginate runs the COUNT and the page query concurrently. base holds the
// filters; list adds page-only options such as Preload and Order.
func Paginate[T any](ctx context.Context, base *gorm.DB, p Params,
list func(*gorm.DB) *gorm.DB) (Page[T], error) {
q := base.Session(&gorm.Session{}) // safe to reuse from two goroutines
var (
total int64
items []T
)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return q.WithContext(ctx).Model(new(T)).Count(&total).Error
})
g.Go(func() error {
offset := (p.Page - 1) * p.Limit
return list(q.WithContext(ctx)).Limit(p.Limit).Offset(offset).Find(&items).Error
})
if err := g.Wait(); err != nil {
return Page[T]{}, err
}
totalPages := int((total + int64(p.Limit) - 1) / int64(p.Limit))
meta := Meta{Page: p.Page, Limit: p.Limit, Total: total, TotalPages: totalPages}
if p.Page > 1 {
prev := p.Page - 1
meta.PrevPage = &prev
}
if p.Page < totalPages {
next := p.Page + 1
meta.NextPage = &next
}
if items == nil {
items = []T{} // encode as [] rather than null
}
return Page[T]{Data: items, Meta: meta}, nil
}การเรียก Session สำคัญมาก query ของ GORM ที่สร้างด้วย Where แล้วนำมาใช้ซ้ำไม่ปลอดภัย เพราะทุก method ที่ chain ต่อจะแก้ statement ตัวเดียวกัน ถ้าไม่เปิด session ใหม่ สอง goroutine จะเกิด race และ query นับอาจติด LIMIT ของ query ดึงข้อมูลมาด้วย สังเกตด้วยว่า Count ใน GORM v2 รับ *int64 และ Preload อยู่ใน list แยกจาก query นับ
การรันสอง query พร้อมกันใช้ connection จาก pool สองเส้นต่อ request และจะคุ้มก็ต่อเมื่อทั้งสอง query ใช้เวลานานพอสมควร อ่านเรื่อง errgroup และ cancellation เพิ่มเติมได้ที่ Go concurrency ใน production
Handler ต้อง whitelist ลำดับการเรียง เพราะ Order() ใส่ argument ลงใน SQL ตรง ๆ:
var sortOrders = map[string]string{
"newest": "created_at DESC, id DESC",
"oldest": "created_at ASC, id ASC",
"title": "title ASC, id ASC",
}
func (h *ArticleHandler) List(c *gin.Context) {
var q listQuery
if err := c.ShouldBindQuery(&q); err != nil {
validation.Respond(c, err)
return
}
base := h.db.Model(&models.Article{})
if q.Category != 0 {
base = base.Where("category_id = ?", q.Category)
}
page, err := pagination.Paginate[models.Article](c.Request.Context(), base,
pagination.Params{Page: q.Page, Limit: q.Limit},
func(tx *gorm.DB) *gorm.DB { return tx.Preload("Category").Order(sortOrders[q.Sort]) })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load articles"})
return
}
c.JSON(http.StatusOK, page)
}id ที่ใส่ต่อท้ายเป็นตัวตัดสินเมื่อค่าเท่ากัน ทำให้ลำดับคงที่ แถวที่ timestamp ตรงกันจะไม่กระโดดข้ามหน้า ตอนนี้ GET /api/v1/articles?page=2&limit=12 จะคืน data พร้อม object meta ที่มี page, limit, total, totalPages, prevPage และ nextPage (เป็น null ในหน้าแรกและหน้าสุดท้าย)
Offset pagination มีข้อจำกัด ฐานข้อมูลยังต้องอ่านแล้วทิ้งทุกแถวที่ข้ามไป หน้าลึก ๆ จึงช้า และแถวใหม่ที่เพิ่มเข้ามาจะทำให้หน้าเลื่อน สำหรับ feed หรือตารางใหญ่มาก ให้ใช้ keyset pagination ซึ่งอธิบายไว้ใน การปรับแต่ง SQL query
คำถามที่พบบ่อย
ShouldBind กับ Bind ใน Gin ต่างกันอย่างไร
ShouldBind* คืน error ให้เราเขียน response เอง ส่วน Bind* จะ abort พร้อม 400 เปล่า ๆ สำหรับ JSON API ให้ใช้ ShouldBind*
Validate query parameter ใน Gin อย่างไร
ประกาศ struct ที่มี tag form และกฎใน binding แล้วเรียก c.ShouldBindQuery ใช้ form:"page,default=1" สำหรับค่า default
จำกัดขนาดไฟล์อัปโหลดใน Gin อย่างไร
ห่อ body ด้วย http.MaxBytesReader ก่อนเรียก FormFile และตรวจ file.Size ด้วย MaxMultipartMemory ไม่ได้จำกัดขนาด
ควรใช้ offset หรือ cursor pagination
Offset เหมาะกับหน้าที่มีเลขหน้าบนข้อมูลขนาดปานกลาง ส่วน cursor (keyset) pagination เหมาะกับ feed และตารางขนาดใหญ่
เช็กลิสต์
- Input ทั้งหมดถูก bind เข้า struct ด้วย
ShouldBind*และตรวจด้วย tagbinding - Validation error ใช้ชื่อ field ฝั่ง client และมีรูปแบบ JSON ที่คงที่
- ไฟล์อัปโหลดถูกจำกัดขนาดด้วย
MaxBytesReaderตรวจ byte จริง ตั้งชื่อใหม่ และเก็บด้วย0755/0644 - Pagination จำกัดค่า
limitwhitelist คอลัมน์ที่ใช้เรียง และมีตัวตัดสินเมื่อค่าเท่ากัน - Query นับและ query ดึงหน้าที่รันพร้อมกันใช้ GORM session ใหม่
ตอนนี้ API บทความของเรามีสิ่งที่โปรเจกต์ส่วนใหญ่ต้องใช้ตั้งแต่วันแรกแล้ว ถ้าต้องการคนช่วยพา Go API แบบนี้ขึ้น production Vectorkub รับพัฒนาและ review ระบบ backend
