Response Result: Làm thế nào một backend trả về dữ liệu API cho FE một cách duyên dáng.

Nội dung bài viết

Video học lập trình mỗi ngày

Bài viết này được đưa vào Series: Con đường trở thành backend Go.

Trong đó bạn sẽ hiểu và đi từng level cho đến khi thành thạo.

Go - Những khái niệm quan trọng cần học trước khi phát triển dự án

Go - Dự án cho fresher vs junior

Go - Dự án cho Senior

Trong các dự án thực tế, đặc biệt là khi làm việc theo mô hình Microservices hoặc phối hợp với anh em làm ở vị trí Frontend/Mobile, thì việc trả về một cấu trúc JSON nhất quán là bắt buộc.

Nó không chỉ là vấn đề dễ dàng xử lý dữ liệu trả về từ backend mà còn là nền tảng để xây dựng hệ thống ổn định và dễ mở rộng cho việc sau này.

Tại sao phải chuẩn hóa API Response?


Đừng chỉ trả về c.JSON(200, data). Hãy tưởng tượng team Frontend phải viết hàng chục hàm if-else chỉ để kiểm tra xem server trả về lỗi theo định dạng nào. Vì vậy đây là một kỹ năng cơ bản của một backend, cho dù bạn xuất phát từ level nào cũng nên có nguyên tắc này.

  • Tính nhất quán (Consistency): Mọi endpoint đều trả về cùng một "vỏ" (envelope), giúp việc xử lý logic ở phía Client (như Axios Interceptors trong React/Vue) trở nên cực kỳ đơn giản.

  • Quản lý mã lỗi (Error Codes): Thay vì chỉ dựa vào HTTP Status Code (vốn rất hạn chế), bạn có thể định nghĩa các Business Code riêng (ví dụ: 10001: Sai mật khẩu, 10002: Tài khoản bị khóa).

  • Khả năng mở rộng: Dễ dàng bổ sung các thông tin như trace_id (để tracking log), timestamp, hoặc pagination.

Giải thích về cấu trúc thư mục pkg (Dành cho Go Newbie)


Trong ví dụ này, chúng ta đặt code vào pkg/response. Tại sao lại là pkg?

Tính công khai (Public Visibility): Theo quy chuẩn Standard Go Project Layout, thư mục pkg chứa code mà bạn cho phép các dự án khác có thể import và sử dụng. Nếu bạn viết một thư viện response tốt, team khác trong công ty có thể dùng lại nó.

Phân biệt với internal: Ngược lại với pkg, thư mục internal là thư mục đặc biệt trong Go. Code nằm trong internal không thể được import bởi bất kỳ package nào bên ngoài module đó.

Tách biệt Logic: Giúp thư mục gốc (root) của project sạch sẽ hơn, chỉ chứa các file cấu hình như go.mod, main.go, Makefile.

Tôi đã nói rất nhiều nếu bạn là một lập trình viên mới bước vào go or nếu bạn đang xây dựng một ứng dụng nhỏ, bạn có thể để ngay tại root không sao cả. Nhưng khi project lớn dần, thì cố gắng hãy xác định và phân loại các folder cho đúng. Tôi nói sơ qua về điều này...

cmd/: Chứa file main.go.
internal/: Chứa logic nghiệp vụ cốt lõi (Private).
pkg/: Chứa các tiện ích dùng chung (Public).

Thiết kế cấu trúc ResponseResult


Chúng ta sẽ định nghĩa một cấu trúc cơ bản bao gồm 3 trường chính như sau:

type baseResponse struct {
    Code int    `json:"code"` // Business code or HTTP status code
    Msg  string `json:"msg"`  // Human-readable message
    Data any    `json:"data"` // Actual payload
}

Code: Nên phân tách giữa HTTP Status (dùng để router hiểu) và Business Code (dùng để app hiểu logic).

Msg: Dùng msg thay vì message giúp giảm dung lượng payload và nhất quán với các framework hiện đại.

Data: Dùng kiểu any (Alias của interface{}) để có thể chứa bất kỳ kiểu dữ liệu nào (Struct, Map, Slice).

Source hoàn chỉnh về ResponseResult

Để có cách nhìn chuẩn hơn tôi khuyên bạn xem video về điều này: Làm thế nào backend có thể response dữ liệu cho FE một cách duyên dáng

pkg/response/response.go

package response
import (
    "net/http"
    "github.com/gin-gonic/gin"
)
// baseResponse defines the uniform API response structure
type baseResponse struct {
    Code int    `json:"code"` // Business or HTTP error code
    Msg  string `json:"msg"`  // Descriptive message
    Data any    `json:"data"` // Business data payload
}
// ResponseOption allows customizing the message
type ResponseOption struct {
    Msg string `json:"msg"`
}
// Success returns a 200 OK response with data
func Success(c *gin.Context, data any, opts *ResponseOption) {
    msg := "success"
    if opts != nil && opts.Msg != "" {
        msg = opts.Msg
    }
    c.JSON(http.StatusOK, baseResponse{
        Code: http.StatusOK,
        Msg:  msg,
        Data: data,
    })
}
// Created returns a 201 Created response, typically used for POST/PUT requests
func Created(c *gin.Context, data any, opts *ResponseOption) {
    msg := "resource created"
    if opts != nil && opts.Msg != "" {
        msg = opts.Msg
    }
    c.JSON(http.StatusCreated, baseResponse{
        Code: http.StatusCreated,
        Msg:  msg,
        Data: data,
    })
}
// InternalError returns a 500 Internal Server Error
func InternalError(c *gin.Context, msg string) {
    if msg == "" {
        msg = "internal server error"
    }
    c.JSON(http.StatusInternalServerError, baseResponse{
        Code: http.StatusInternalServerError,
        Msg:  msg,
        Data: nil,
    })
}
// Standard helper for common errors (400, 401, 403, 404)
func SendError(c *gin.Context, httpStatus int, msg string) {
    c.JSON(httpStatus, baseResponse{
        Code: httpStatus,
        Msg:  msg,
        Data: nil,
    })
}

Một điểm cực kỳ quan trọng là phải bắt được các lỗi gây "sập" server (panic) để trả về JSON thay vì trả về một trang HTML trắng trông nó quá tệ đúng không. Vì vậy cố gắng quản lý panic() cho tốt. Tôi đã viết thêm một func CustomRecovery(), nên anh em cố gắng xem xét.

package main
import (
    "log/slog"
    "net"
    "net/http"
    "os"
    "runtime/debug"
    "strings"
    "tmpgo/pkg/response" // Change to your actual module name
    "github.com/gin-gonic/gin"
)
func main() {
    // Set Gin mode
    gin.SetMode(gin.ReleaseMode)
    // Initialize Gin engine without default middlewares (Logger & Recovery)
    // We will add them manually to have better control
    r := gin.New()
    r.Use(gin.Logger())       // Standard logging
    r.Use(CustomRecovery())    // Standardize panic responses
    // Routes
    r.GET("/ping", func(c *gin.Context) {
        response.Success(c, "pong", nil)
    })
    r.GET("/panic", func(c *gin.Context) {
        panic("Oops! Something went wrong.")
    })
    // Handle 404 - Not Found
    r.NoRoute(func(c *gin.Context) {
        response.SendError(c, http.StatusNotFound, "Endpoint not found")
    })
    // Handle 405 - Method Not Allowed
    r.NoMethod(func(c *gin.Context) {
        response.SendError(c, http.StatusMethodNotAllowed, "Method not allowed")
    })
    slog.Info("Server starting on :10000")
    r.Run(":10000")
}
// CustomRecovery intercepts panics and returns a structured JSON error
func CustomRecovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if err := recover(); err != nil {
                // Check for broken network connection (client disconnected)
                var brokenPipe bool
                if ne, ok := err.(*net.OpError); ok {
                    if se, ok := ne.Err.(*os.SyscallError); ok {
                        errMsg := strings.ToLower(se.Error())
                        if strings.Contains(errMsg, "broken pipe") || 
                           strings.Contains(errMsg, "connection reset by peer") {
                            brokenPipe = true
                        }
                    }
                }
                stack := string(debug.Stack())
                slog.Error("Recovery from panic", 
                    "error", err, 
                    "stack", stack,
                    "path", c.Request.URL.Path,
                )
                if brokenPipe {
                    c.Abort() // Error is already handled by network, just abort
                    return
                }
                // Always return a clean JSON response on crash
                c.Abort()
                response.InternalError(c, "Server encountered an unexpected error")
            }
        }()
        c.Next()
    }
}

Những điểm "Vàng" cho người mới học Go (Takeaways)


Có những khái niệm bạn nên cần hiểu cho việc sử dụng vũ khí như go đó là đừng dùng gin.Default() cho Production. Vì gin.Default() tự động thêm Recovery() và Logger().

Cố gắng tự viết CustomRecovery như trên giúp anh em mình ẩn đi các thông tin nhạy cảm của server khi có lỗi (không lộ source code line trong stack trace cho user).

Đi chậm, cười duyên nói khẽ là nhân tố giúp những người ẩn mình luôn sống sót.

Có thể bạn đã bị missing