Xây dựng REST API với Go và Fiber: Routing, Middleware, Validation và Docker hóa

REST API là xương sống của hầu hết ứng dụng web hiện đại — từ mobile app, SPA frontend cho đến các dịch vụ nội bộ giao tiếp với nhau. Trong hệ sinh thái Go, bên cạnh net/http chuẩn của thư viện chuẩn, Fiber nổi lên như một framework web phổ biến nhờ cú pháp giống Express.js và hiệu năng cực cao nhờ được xây dựng trên Fasthttp — HTTP engine nhanh nhất của Go. Bài này hướng dẫn xây dựng một REST API hoàn chỉnh với Fiber v3: từ routing, middleware, validation, kết nối PostgreSQL bằng GORM, JWT authentication cho đến Docker hóa để deploy.

Đây là bài thứ ba trong series Go của chuyendev.com — sau bài xây dựng CLI tool với Go và Cobra và bài Go + Docker multi-stage build. Nếu bạn chưa đọc hai bài trước, nên đọc qua vì chúng ta sẽ tái sử dụng chính pattern build binary và Docker multi-stage ở phần cuối bài này.

Vì sao chọn Fiber cho REST API?

Trước khi viết code, cần hiểu rõ Fiber giải quyết vấn đề gì. Go đã có net/http rất tốt, nhưng khi dự án lớn dần, bạn sẽ cần routing linh hoạt, middleware chain, và cách đọc params/query/body ngắn gọn. Fiber mang lại những thứ đó theo phong cách Express, giúp developer quen JavaScript chuyển sang Go rất nhanh.

  • Hiệu năng hàng đầu: chạy trên Fasthttp, vượt trội so với net/http và Gin trong hầu hết benchmark.
  • Cú pháp Express-style: app.Get(), c.Params(), c.JSON() quen thuộc.
  • Zero memory allocation: tối ưu cho hệ thống có traffic lớn, chi phí RAM thấp.
  • Hệ sinh thái middleware đầy đủ: logger, recover, cors, jwt, swagger, rate limiter… đều có sẵn.
  • Fiber v3: phiên bản mới nhất (yêu cầu Go 1.25+), API ổn định, hỗ trợ cả handler chuẩn net/http qua adaptor.

Khởi tạo project

Cấu trúc thư mục chuẩn cho một API nhỏ nhưng dễ mở rộng. Chúng ta tách handler, middleware và model ra riêng — nguyên tắc này giúp project không biến thành một file main.go dài 2000 dòng khi thêm feature.

go-api/
├── cmd/
│   └── api/
│       └── main.go          # điểm khởi chạy
├── internal/
│   ├── handler/
│   │   └── product.go       # xử lý request/response
│   ├── middleware/
│   │   └── auth.go          # JWT middleware
│   └── model/
│       └── product.go       # struct map với database
├── go.mod
├── Dockerfile
└── docker-compose.yml

Khởi tạo module và cài Fiber v3:

go mod init github.com/yourname/go-api
go get github.com/gofiber/fiber/v3

File main.go đầu tiên — chú ý hàm Listen trả về error nên ta dùng log.Fatal để app thoát ngay nếu không bind được port (ví dụ port đã bị chiếm). Đây là chi tiết nhỏ nhưng tránh được tình huống app “chạy ngầm” mà không ai biết khi deploy.

package main

import (
    "log"

    "github.com/gofiber/fiber/v3"
)

func main() {
    app := fiber.New()

    app.Get("/", func(c fiber.Ctx) error {
        return c.SendString("Hello, Fiber v3!")
    })

    // Health check cho Docker HEALTHCHECK và load balancer
    app.Get("/health", func(c fiber.Ctx) error {
        return c.JSON(fiber.Map{"status": "ok"})
    })

    log.Fatal(app.Listen(":3000"))
}

Chạy thử và kiểm tra:

go run ./cmd/api

# terminal khác:
curl -s http://localhost:3000/health
# {"status":"ok"}

Routing RESTful với Fiber

Fiber định nghĩa route theo phương thức HTTP + path, hỗ trợ route group để gom các endpoint cùng tiền tố /api/v1. Khi API lớn, route group giúp bạn gắn middleware riêng cho từng nhóm — ví dụ nhóm /admin cần auth, nhóm /public thì không.

api := app.Group("/api/v1")

api.Get("/products", listProducts)
api.Get("/products/:id", getProduct)
api.Post("/products", createProduct)
api.Put("/products/:id", updateProduct)
api.Delete("/products/:id", deleteProduct)

Đọc dữ liệu từ path params bằng c.Params() và từ query string bằng c.Query(). Tham số thứ hai của c.Query() là giá trị mặc định — rất hữu ích cho pagination khi client không truyền page hay limit.

app.Get("/api/v1/products/:id", func(c fiber.Ctx) error {
    id := c.Params("id")

    page := c.Query("page", "1")
    limit := c.Query("limit", "20")

    return c.JSON(fiber.Map{
        "id":    id,
        "page":  page,
        "limit": limit,
    })
})

Fiber còn hỗ trợ wildcard * để bắt mọi path còn lại — thường dùng cho route fallback trả về 404 JSON thay vì trang HTML mặc định. Client gọi API sẽ nhận được lỗi có cấu trúc, dễ xử lý hơn nhiều:

app.Use(func(c fiber.Ctx) error {
    return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
        "error":   "Endpoint not found",
        "method":  c.Method(),
        "path":    c.Path(),
    })
})

Middleware: logger, recover và CORS

Middleware là hàm chạy trước (hoặc sau) handler, dùng để xử lý việc lặp lại như ghi log, bắt panic, kiểm tra auth. Fiber v3 cung cấp sẵn các middleware phổ biến trong package github.com/gofiber/fiber/v3/middleware.

import (
    "github.com/gofiber/fiber/v3"
    "github.com/gofiber/fiber/v3/middleware/cors"
    "github.com/gofiber/fiber/v3/middleware/logger"
    "github.com/gofiber/fiber/v3/middleware/recover"
)

app := fiber.New()

app.Use(logger.New())       // ghi log mỗi request
app.Use(recover.New())      // bắt panic, trả 500 thay vì crash cả app
app.Use(cors.New(cors.Config{
    AllowOrigins: []string{"https://example.com"},
    AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
}))

Điểm quan trọng: recover.New() không nên thiếu trong production. Nếu một handler panic mà không có middleware này, toàn bộ tiến trình Go sẽ sụp — mọi request khác đang xử lý cũng chết theo. Tại Code Tốt, khi triển khai dịch vụ nội bộ cho các dự án WordPress agency, chúng tôi luôn đặt recover ở vị trí đầu tiên trong middleware chain và gắn thêm một hook gửi stack trace vào Slack — nhờ đó lỗi panic được phát hiện trong vài phút thay vì khi khách hàng báo cáo.

Middleware custom cũng chỉ là một hàm nhận fiber.Ctx và trả về error. Ví dụ middleware kiểm tra header Authorization trước khi vào handler bảo vệ:

func AuthRequired(c fiber.Ctx) error {
    token := c.Get("Authorization")
    if token == "" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Missing Authorization header",
        })
    }
    // TODO: verify JWT token ở phần sau
    return c.Next()
}

// Dùng cho nhóm route cần đăng nhập
admin := app.Group("/admin", AuthRequired)

Validation dữ liệu với go-playground/validator

Không bao giờ tin dữ liệu từ client. Validation ngay tại tầng handler trước khi chạm vào database giúp bạn trả lỗi nhanh, không tạo ra các bản ghi rác. Fiber kết hợp tốt với go-playground/validator — thư viện validation phổ biến nhất của Go, dùng struct tags khai báo rule.

go get github.com/go-playground/validator/v10
type CreateProductRequest struct {
    Name  string  `json:"name" validate:"required,min=3,max=100"`
    Price float64 `json:"price" validate:"required,gt=0"`
    Stock int     `json:"stock" validate:"gte=0"`
}

var validate = validator.New()

func parseAndValidate(c fiber.Ctx, payload any) error {
    if err := c.BodyParser(payload); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid JSON body",
        })
    }
    if err := validate.Struct(payload); err != nil {
        return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
            "error": err.Error(),
        })
    }
    return nil
}

Luồng xử lý giờ rất rõ ràng: parse body → validate → lưu database. Nếu body sai định dạng JSON trả 400, nếu dữ liệu không hợp lệ trả 422, nếu mọi thứ ổn trả 201 kèm object vừa tạo. Tách parseAndValidate thành helper giúp mọi handler CREATE/UPDATE dùng chung, tránh lặp code.

Kết nối PostgreSQL với GORM

GORM là ORM phổ biến nhất của Go. Với REST API, GORM giúp viết CRUD ngắn gọn và tự động map struct sang bảng database. Cài driver PostgreSQL và GORM:

go get gorm.io/gorm
go get gorm.io/driver/postgres

Định nghĩa model — struct map với bảng products:

package model

import "time"

type Product struct {
    ID        uint      `gorm:"primaryKey" json:"id"`
    Name      string    `json:"name"`
    Price     float64   `json:"price"`
    Stock     int       `json:"stock"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

Kết nối database ở main.go. Lưu ý dùng biến môi trường thay vì hardcode DSN — cùng một binary chạy được ở local, staging và production chỉ khác nhau biến môi trường. AutoMigrate tiện cho development, nhưng với production nên dùng migration file có version để kiểm soát thay đổi schema.

dsn := fmt.Sprintf(
    "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
    os.Getenv("DB_HOST"),
    os.Getenv("DB_USER"),
    os.Getenv("DB_PASSWORD"),
    os.Getenv("DB_NAME"),
    os.Getenv("DB_PORT"),
)

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
    log.Fatalf("cannot connect database: %v", err)
}
db.AutoMigrate(&model.Product{})

Handler nhận *gorm.DB qua closure. Pattern này gọi là dependency injection bằng tay — dễ test hơn biến global vì bạn có thể truyền một database mock hoặc SQLite trong test.

func getProduct(db *gorm.DB) fiber.Handler {
    return func(c fiber.Ctx) error {
        var product model.Product
        if err := db.First(&product, c.Params("id")).Error; err != nil {
            return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
                "error": "Product not found",
            })
        }
        return c.JSON(product)
    }
}

func createProduct(db *gorm.DB) fiber.Handler {
    return func(c fiber.Ctx) error {
        var req handler.CreateProductRequest
        if err := parseAndValidate(c, &req); err != nil {
            return err
        }
        product := model.Product{Name: req.Name, Price: req.Price, Stock: req.Stock}
        if err := db.Create(&product).Error; err != nil {
            return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
                "error": "Cannot create product",
            })
        }
        return c.Status(fiber.StatusCreated).JSON(product)
    }
}

JWT Authentication

Hầu hết API thực tế đều cần xác thực. JWT (JSON Web Token) là chuẩn phổ biến nhất: server ký token khi login, client gửi token trong header Authorization, server verify mà không cần lưu session. Cài thư viện:

go get github.com/golang-jwt/jwt/v5
var jwtSecret = []byte(os.Getenv("JWT_SECRET")) // KHÔNG hardcode trong production

func generateToken(userID uint) (string, error) {
    claims := jwt.MapClaims{
        "sub": userID,
        "exp": time.Now().Add(24 * time.Hour).Unix(),
        "iat": time.Now().Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

Endpoint POST /api/v1/auth/login nhận email/password, kiểm tra trong database và trả token. Middleware AuthRequired ở phần trước giờ verify token thật:

func AuthRequired(c fiber.Ctx) error {
    header := c.Get("Authorization")
    if header == "" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Missing Authorization header",
        })
    }

    tokenString := strings.TrimPrefix(header, "Bearer ")
    token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return jwtSecret, nil
    })
    if err != nil || !token.Valid {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid or expired token",
        })
    }

    c.Locals("userID", token.Claims.(jwt.MapClaims)["sub"])
    return c.Next()
}

Chi tiết bảo mật quan trọng: luôn kiểm tra signing method khi parse token. Nếu bỏ qua bước này, attacker có thể gửi token ký bằng thuật toán none hoặc HS256 với secret rỗng — lỗ hổng kinh điển alg confusion từng ảnh hưởng nhiều thư viện JWT. Ngoài ra, secret phải đặt trong biến môi trường (hoặc secret manager như Vault), không commit lên Git.

Docker hóa API

Deploy REST API lên server bằng Docker. Ta tái sử dụng chính kỹ thuật multi-stage build đã viết ở bài trước: stage đầu compile binary, stage sau chỉ chứa binary — image chỉ còn khoảng 20MB thay vì 800MB nếu đóng cả toolchain Go.

# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /api ./cmd/api

FROM alpine:3.21
RUN adduser -D -H appuser
COPY --from=builder /api /usr/local/bin/api
USER appuser
EXPOSE 3000
ENTRYPOINT ["api"]

File docker-compose.yml chạy cả API và PostgreSQL. Chú ý block healthcheck của database — API chỉ khởi động sau khi Postgres sẵn sàng nhận kết nối, tránh lỗi connection refused lúc boot. Nếu muốn hiểu sâu hơn về cơ chế này, đọc bài Docker HEALTHCHECK trên chuyendev.com.

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_USER: app
      DB_PASSWORD: secret
      DB_NAME: shop
      JWT_SECRET: ${JWT_SECRET:-change-me}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shop
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d shop"]
      interval: 5s
      timeout: 3s
      retries: 5
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Trong quá trình triển khai API cho một dự án thực tế tại Code Tốt, chúng tôi từng gặp tình huống API bị restart liên tục trên server vì database chưa kịp khởi động. Nguyên nhân không phải do code mà do thiếu depends_on: condition: service_healthy — API chạy trước Postgres, kết nối fail rồi crash. Thêm healthcheck như trên là đủ, nhưng với hệ thống lớn hơn nên kết hợp thêm retry logic trong code (ví dụ dùng gorm.io/gorm kèm backoff) để app tự phục hồi mà không cần restart thủ công.

Testing API với app.Test()

Fiber có sẵn phương thức app.Test() mô phỏng HTTP request mà không cần mở port thật — test chạy nhanh, phù hợp chạy trong CI/CD. Đây là cách Khôi Pro thường áp dụng khi viết test cho các dịch vụ Go: test handler trực tiếp, không phụ thuộc môi trường bên ngoài.

func TestHealthCheck(t *testing.T) {
    app := fiber.New()
    app.Get("/health", func(c fiber.Ctx) error {
        return c.JSON(fiber.Map{"status": "ok"})
    })

    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    resp, err := app.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != http.StatusOK {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}
go test ./... -v

Với handler có database, chỉ cần truyền một instance GORM trỏ tới SQLite (gorm.io/driver/sqlite) trong test — cùng một struct model, không cần spin cả PostgreSQL lên. Đây là lợi ích lớn của pattern closure injection ở phần trên.

Tổng kết

Bạn vừa xây dựng một REST API hoàn chỉnh với Go và Fiber v3: routing theo nhóm, middleware (logger, recover, CORS, JWT), validation struct-based, CRUD với GORM và PostgreSQL, Docker multi-stage và test tự động. Toàn bộ pattern này đủ dùng cho dự án production cỡ vừa — và khi cần scale, hướng đi tiếp theo là tách service, thêm message queue và rate limiting.

Series Go trên chuyendev.com đang tiếp tục: bắt đầu từ bài CLI tool với Cobra, qua bài Docker multi-stage, và bây giờ là REST API với Fiber. Bài tiếp theo trong series sẽ là Go concurrency với goroutine và channel — kỹ thuật không thể thiếu để xử lý hàng nghìn request song song.

Khoi Pro (Nguyễn Minh Khôi)

Founder of Code Tốt (codetot.vn). WordPress developer and performance optimization specialist. Author at Chuyên DEV (chuyendev.com). Personal site: khoipro.com.