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

Backend

Gin Validation, อัปโหลดไฟล์ และ Pagination: รับ Input อย่างปลอดภัยใน Go API

ทำ Gin validation และ pagination ให้ถูกต้อง ตั้งแต่ bind JSON, query และ path parameter, ตอบ error แยกราย field, รับอัปโหลดรูปอย่างปลอดภัยด้วย FormFile และแบ่งหน้าพร้อมนับจำนวนทั้งหมดแบบ concurrent

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

gin request validation file upload pagination

บั๊กและช่องโหว่ส่วนใหญ่ของ 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 tagMethod ที่ใช้ bind
JSON body{"title":"..."}json:"title"c.ShouldBindJSON(&req)
Query string?page=2&limit=12form:"page"c.ShouldBindQuery(&q)
Path parameter/articles/:iduri:"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 ที่มีประโยชน์ไม่ได้

go
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:

go
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 ไปยังข้อความ:

go
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 เข้ากับช่องในฟอร์มได้ทันที:

json
{
  "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 ตอนสร้างบทความ:

go
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 แล้วเสิร์ฟโฟลเดอร์:

go
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 สำหรับโฟลเดอร์อัปโหลด

ModeOwnerGroupOthersใช้กับ
0755rwxr-xr-xโฟลเดอร์อัปโหลด
0644rw-r--r--ไฟล์ที่อัปโหลด
0777rwxrwxrwxหลีกเลี่ยง: 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 เป็นวิธีที่ง่ายที่สุด:

text
?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 ทำเรื่องนี้:

go
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 ตรง ๆ:

go
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* และตรวจด้วย tag binding
  • Validation error ใช้ชื่อ field ฝั่ง client และมีรูปแบบ JSON ที่คงที่
  • ไฟล์อัปโหลดถูกจำกัดขนาดด้วย MaxBytesReader ตรวจ byte จริง ตั้งชื่อใหม่ และเก็บด้วย 0755/0644
  • Pagination จำกัดค่า limit whitelist คอลัมน์ที่ใช้เรียง และมีตัวตัดสินเมื่อค่าเท่ากัน
  • Query นับและ query ดึงหน้าที่รันพร้อมกันใช้ GORM session ใหม่

ตอนนี้ API บทความของเรามีสิ่งที่โปรเจกต์ส่วนใหญ่ต้องใช้ตั้งแต่วันแรกแล้ว ถ้าต้องการคนช่วยพา Go API แบบนี้ขึ้น production Vectorkub รับพัฒนาและ review ระบบ backend

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

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

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