Viết REST API bằng net/http với routing mới của Go 1.22
1. Tổng quan
Trước Go 1.22, http.ServeMux chỉ khớp theo đường dẫn. Nó không phân biệt GET với POST và không đọc được path parameter như /orders/{id}. Vì vậy nhiều dự án phải dùng thêm gorilla/mux hoặc chi.
Từ Go 1.22, ServeMux hỗ trợ khớp theo method, wildcard và quy tắc ưu tiên rõ ràng. Bài này hướng dẫn bạn xây một REST API quản lý Order chỉ với thư viện chuẩn. Bạn cũng sẽ viết test bằng httptest và biết các lỗi hay gặp với cú pháp mới.
2. Chuẩn bị dự án
Bạn cần Go 1.22 trở lên. Dòng go trong go.mod cũng phải từ 1.22 trở lên:
mkdir order-api && cd order-api
go mod init example.com/order-api
File go.mod sau khi tạo:
module example.com/order-api
go 1.22
Lưu ý: Hành vi routing mới phụ thuộc vào dòng
gotronggo.mod, không phụ thuộc vào phiên bản toolchain bạn cài. Nếugo.modghigo 1.21,ServeMuxvẫn chạy theo kiểu cũ. Khi đó pattern"GET /orders/{id}"bị hiểu là một đường dẫn chứa dấu cách, và request của bạn sẽ nhận 404 mà không có thông báo lỗi nào.
Bài này không cần dependency ngoài, nên không có lệnh go get.
3. Cú pháp pattern mới
3.1. Khớp theo method và path parameter
Ví dụ tối giản trong main.go:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Write([]byte("Xin chào, " + name + "\n"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Đoạn code trên làm những việc sau:
"GET /hello/{name}": phần trước dấu cách là method, phần sau là path.{name}là wildcard, khớp đúng một segment.r.PathValue("name")trả về giá trị đã khớp, luôn ở dạngstring. Nếu tên wildcard không tồn tại, hàm trả về chuỗi rỗng.
Chạy thử:
go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
Xin chào, Lan
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed
Khi path khớp nhưng method không khớp, ServeMux tự trả 405 kèm header Allow. Pattern GET cũng tự khớp cả HEAD.
3.2. Wildcard nhiều segment và khớp chính xác
Hai cú pháp đặc biệt khác:
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("File: " + r.PathValue("path") + "\n"))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Trang chủ\n"))
})
{path...}phải đứng cuối pattern. Nó khớp phần còn lại của đường dẫn, kể cả dấu/. Với/files/docs/2024/report.pdf, giá trị làdocs/2024/report.pdf.{$}chỉ khớp đúng đường dẫn kết thúc bằng/. Nếu không có nó, pattern"/"khớp mọi đường dẫn. Đây là nguyên nhân phổ biến khiến route lạ không trả 404.
3.3. Quy tắc ưu tiên
Khi nhiều pattern cùng khớp một request, pattern cụ thể hơn sẽ thắng:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // thắng với /orders/latest
/orders/latest cụ thể hơn /orders/{id}, vì mọi request khớp pattern thứ hai đều khớp pattern thứ nhất, còn chiều ngược lại thì không. Thứ tự đăng ký không ảnh hưởng.
Nếu hai pattern chồng lấn mà không pattern nào cụ thể hơn, Go sẽ panic ngay lúc đăng ký:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...
Lỗi xuất hiện khi khởi động chương trình, không phải lúc đang phục vụ request. Nhờ vậy bạn phát hiện lỗi routing sớm.
4. Xây dựng Order API
4.1. Model và store trong bộ nhớ
Tạo file store.go:
package main
import (
"sort"
"sync"
)
type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Total float64 `json:"total"`
}
type OrderStore struct {
mu sync.RWMutex
orders map[int]Order
nextID int
}
func NewOrderStore() *OrderStore {
return &OrderStore{orders: make(map[int]Order), nextID: 1}
}
func (s *OrderStore) Create(o Order) Order {
s.mu.Lock()
defer s.mu.Unlock()
o.ID = s.nextID
s.nextID++
s.orders[o.ID] = o
return o
}
func (s *OrderStore) Get(id int) (Order, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
o, ok := s.orders[id]
return o, ok
}
func (s *OrderStore) List() []Order {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Order, 0, len(s.orders))
for _, o := range s.orders {
result = append(result, o)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (s *OrderStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.orders[id]; !ok {
return false
}
delete(s.orders, id)
return true
}
Mỗi request được xử lý trên một goroutine riêng. Vì vậy map phải được bảo vệ bằng sync.RWMutex. Nếu thiếu mutex, chạy go test -race sẽ báo data race ngay. List sắp xếp theo ID vì thứ tự duyệt map trong Go là ngẫu nhiên.
4.2. Helper cho JSON
Tạo file json.go:
package main
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
Thứ tự gọi rất quan trọng. Bạn phải Header().Set trước WriteHeader. Sau khi đã gọi WriteHeader hoặc Write, mọi thay đổi header đều bị bỏ qua.
4.3. Handler
Tạo file handler.go:
package main
import (
"encoding/json"
"net/http"
"strconv"
)
type OrderHandler struct {
store *OrderStore
}
func (h *OrderHandler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /orders", h.list)
mux.HandleFunc("POST /orders", h.create)
mux.HandleFunc("GET /orders/{id}", h.get)
mux.HandleFunc("DELETE /orders/{id}", h.delete)
}
func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // tối đa 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in Order
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "body không hợp lệ: "+err.Error())
return
}
if in.Customer == "" || in.Total <= 0 {
writeError(w, http.StatusUnprocessableEntity, "customer và total là bắt buộc")
return
}
created := h.store.Create(in)
w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
writeJSON(w, http.StatusCreated, created)
}
func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
order, found := h.store.Get(id)
if !found {
writeError(w, http.StatusNotFound, "không tìm thấy order")
return
}
writeJSON(w, http.StatusOK, order)
}
func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if !h.store.Delete(id) {
writeError(w, http.StatusNotFound, "không tìm thấy order")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "id phải là số nguyên dương")
return 0, false
}
return id, true
}
Một số điểm cần chú ý:
Routesgom toàn bộ khai báo route vào một chỗ. Bạn đọc một lần là biết API có những endpoint nào.GET /ordersvàGET /orders/{id}không xung đột, vì số segment khác nhau.ServeMuxkhông kiểm tra kiểu dữ liệu.{id}khớp cảabc, nên bạn phải tự validate bằngstrconv.Atoi.MaxBytesReaderchặn body quá lớn.DisallowUnknownFieldstừ chối các field lạ như"totl"do gõ nhầm.
4.4. Middleware và hàm main
Middleware trong net/http chỉ là một hàm nhận http.Handler và trả về http.Handler. Cập nhật main.go:
package main
import (
"log"
"net/http"
"time"
)
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
})
}
func newServer() http.Handler {
mux := http.NewServeMux()
h := &OrderHandler{store: NewOrderStore()}
h.Routes(mux)
return logging(mux)
}
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: newServer(),
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
Hàm newServer tách phần dựng handler ra khỏi main. Nhờ vậy test có thể dùng lại đúng cấu hình đang chạy thật. http.Server có ReadHeaderTimeout để chống client gửi header quá chậm. http.ListenAndServe mặc định không đặt timeout này.
Từ Go 1.23, http.Request có thêm field Pattern. Bạn có thể log r.Pattern (ví dụ GET /orders/{id}) thay cho path thật. Cách này giúp gom metric theo route.
Chạy thử:
go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"id phải là số nguyên dương"}
204
5. Viết test với httptest
Go có sẵn net/http/httptest, nên bạn không cần mở port thật. Tạo file handler_test.go:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateAndGetOrder(t *testing.T) {
srv := newServer()
body := strings.NewReader(`{"customer":"Lan","total":120000}`)
req := httptest.NewRequest(http.MethodPost, "/orders", body)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("POST status = %d, want 201", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/orders/1" {
t.Errorf("Location = %q, want /orders/1", loc)
}
req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
var got Order
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Customer != "Lan" || got.Total != 120000 {
t.Errorf("got %+v", got)
}
}
func TestRouting(t *testing.T) {
srv := newServer()
tests := []struct {
name, method, path string
want int
}{
{"id không phải số", http.MethodGet, "/orders/abc", http.StatusBadRequest},
{"không tồn tại", http.MethodGet, "/orders/99", http.StatusNotFound},
{"sai method", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
{"route lạ", http.MethodGet, "/customers", http.StatusNotFound},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
Test thứ nhất kiểm tra luồng tạo rồi đọc order. Test thứ hai là table-driven test, kiểm tra hành vi routing, bao gồm cả 405 do ServeMux tự sinh. Chạy kèm race detector:
go test -race -v ./...
=== RUN TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN TestRouting
=== RUN TestRouting/id_không_phải_số
...
--- PASS: TestRouting (0.00s)
PASS
6. Lỗi hay gặp và khi nào không nên dùng
Các lỗi thường gặp:
go.modghi phiên bản cũ. Route mới trả 404 toàn bộ. Hãy kiểm tra dònggotronggo.modtrước tiên.- Viết sai cú pháp pattern. Chỉ được có đúng một dấu cách giữa method và path. Method phải viết hoa, ví dụ
GETchứ không phảiget. - Dùng
"/"làm fallback một cách vô tình. Pattern"/"bắt mọi request không khớp route khác. Hãy dùng"/{$}"nếu bạn chỉ muốn trang chủ. - Wildcard chiếm trọn segment. Pattern
/orders/{id}.jsonkhông hợp lệ. Wildcard không thể đứng chung với chữ khác trong cùng một segment. - Lỗi 405 trả về dạng text.
ServeMuxtrả body text thuần cho 404 và 405, không phải JSON. Nếu client cần JSON thống nhất, bạn phải bọc thêm một lớp xử lý riêng.
Lưu ý: Nếu một dự án cũ bị hỏng sau khi nâng
go.modlên 1.22, bạn có thể tạm thời đặtGODEBUG=httpmuxgo121=1để quay về hành vi cũ. Đây chỉ là giải pháp chuyển tiếp. Bạn vẫn nên sửa lại pattern.
net/http thuần có thể chưa đủ khi bạn cần:
- Ràng buộc kiểu ngay trong route, ví dụ chỉ khớp số hoặc khớp regex.
- Nhóm route với middleware riêng cho từng nhóm. Việc này vẫn làm được bằng nhiều
ServeMuxlồng nhau, nhưng code sẽ dài dòng. - Hệ sinh thái middleware dựng sẵn như CORS, rate limit hay recovery.
Với các trường hợp đó, chi là lựa chọn nhẹ và tương thích hoàn toàn với http.Handler. Ngoài các trường hợp này, thư viện chuẩn đã đáp ứng đủ cho đa số REST API.
7. Kết luận
Go 1.22 giúp http.ServeMux đủ mạnh cho REST API thực tế. Bạn có thể khớp theo method, đọc path parameter bằng r.PathValue, và để Go tự xử lý 405 cùng quy tắc ưu tiên. Bạn cũng đã xây một Order API hoàn chỉnh có middleware, validate input và test bằng httptest, không cần dependency ngoài.
Điểm quan trọng nhất cần nhớ: đặt go 1.22 trở lên trong go.mod, dùng {$} để khớp chính xác, và tự validate giá trị wildcard trước khi dùng.