Hình dung middleware như một bộ búp bê Nga lồng nhau. Con trong cùng là handler thật; mỗi lớp middleware là một con búp bê lớn hơn bọc lấy con bên trong. Một request "mở" các con từ ngoài vào tới lõi, rồi "đóng" lại từ trong ra — mỗi lớp được làm một việc lúc mở và một việc lúc đóng. Middleware trong Go không cần framework; nó là hệ quả trực tiếp của việc http.Handler là interface một method.
Bảy dòng
func Log(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t0 := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(t0))
})
}
Đó là toàn bộ khái niệm: hàm nhận Handler, trả về Handler — nhận một con búp bê, trả về con lớn hơn bọc nó. Không có chú thích, không có đăng ký, không có thứ tự cấu hình ẩn.
Dùng:
http.ListenAndServe(":8080", Log(mux))
Xâu chuỗi
h := Log(PhucHoi(XacThuc(mux)))
Đọc từ ngoài vào trong: Log chạy trước, rồi PhucHoi, rồi XacThuc, rồi mux — đúng thứ tự các con búp bê từ lớn tới nhỏ. Trên đường về thì ngược lại.
Chuỗi dài thì viết hàm gộp:
func Chain(h http.Handler, ms ...func(http.Handler) http.Handler) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}
h := Chain(mux, Log, PhucHoi, XacThuc)
Lặp ngược để thứ tự đọc từ trái sang phải khớp thứ tự chạy.
Middleware bắt buộc phải có
Phục hồi panic — bài 25 đã nói. net/http có recover sẵn nhưng chỉ in ra stderr:
func PhucHoi(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "lỗi máy chủ", 500)
}
}()
next.ServeHTTP(w, r)
})
}
debug.Stack() là phần quan trọng — không có nó bạn chỉ biết "có panic".
Mã tương quan để lần theo request qua log:
func MaRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ma := r.Header.Get("X-Request-ID")
if ma == "" { ma = uuid.New().String() }
ctx := context.WithValue(r.Context(), khoaMa{}, ma)
w.Header().Set("X-Request-ID", ma)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Chú ý r.WithContext(ctx) — Request bất biến với context, nên phải tạo bản mới. Đây là chỗ WithValue thật sự chính đáng, đúng như bài 29 đã nói.
Giới hạn kích thước thân — http.MaxBytesReader, bài 45.
Bắt mã trạng thái để log
http.ResponseWriter không cho đọc lại mã trạng thái đã ghi. Muốn log nó thì phải bọc:
type ghiLai struct {
http.ResponseWriter
ma int
}
func (g *ghiLai) WriteHeader(ma int) {
g.ma = ma
g.ResponseWriter.WriteHeader(ma)
}
Đây là embedding interface ở bài 15: ghiLai nhúng http.ResponseWriter nên tự động thoả mãn nó, và bạn chỉ ghi đè method cần đổi.
gl := &ghiLai{ResponseWriter: w, ma: 200} // mặc định 200 vì handler có thể không gọi WriteHeader
next.ServeHTTP(gl, r)
log.Printf("%d %s %s", gl.ma, r.Method, r.URL.Path)
Một cảnh báo: bọc ResponseWriter làm mất các interface tuỳ chọn như http.Flusher và http.Hijacker. Với endpoint stream hay WebSocket, bạn phải chuyển tiếp chúng tường minh — đây là hạn chế thật của mẫu này. Con búp bê bọc ngoài quên mất vài thứ con trong vốn làm được.
Khi nào cần router bên ngoài
Từ Go 1.22, ServeMux đủ cho phần lớn API — bài 46 đã cho thấy nó hiểu method và tham số đường dẫn.
Cân nhắc chi hoặc gin khi cần:
Nhóm route với middleware riêng — /api/v1/* dùng xác thực, /health thì không. Với ServeMux bạn phải tự dựng nhiều mux.
Ràng buộc tham số — regex trên path variable.
Middleware theo route thay vì theo toàn bộ server.
chi đáng chú ý vì nó tương thích hoàn toàn với http.Handler — middleware bạn viết dùng được ở cả hai nơi, và bỏ nó ra sau này không phải viết lại gì.
gin nhanh hơn nhưng dùng kiểu gin.Context riêng, nên mã handler của bạn bị khoá vào nó. Đó là đánh đổi cần biết trước.
Lời khuyên của tôi cho dự án mới: bắt đầu với ServeMux. Nó không có phụ thuộc, và nếu sau này cần router mạnh hơn thì handler viết theo http.Handler chuyển sang được gần như không sửa.
Nếu chỉ thêm một middleware hôm nay, thêm cái log in cả thời gian lẫn mã trạng thái:
log.Printf("%d %s %s %v", gl.ma, r.Method, r.URL.Path, time.Since(t0))
Chạy vài phút dưới tải thật rồi sắp xếp theo thời gian. Endpoint chậm nhất thường không phải cái bạn đoán — và đó là thông tin bạn có được sau ba mươi giây viết mã.
Mẫu số chung
Cái "middleware" này có một cái tên cũ hơn nhiều: mẫu trang trí (decorator pattern) của GoF — bọc một đối tượng trong một đối tượng khác cùng giao diện để thêm hành vi, và ghép được nhiều lớp. Bản của Go nhẹ tới mức chỉ là một hàm, vì http.Handler là interface một method — con búp bê mỏng nhất có thể. Và nó có ở mọi khung web, thường kèm cái tên "vỏ hành" (onion): next() của Express, mô hình onion đặt tên hẳn hoi của Koa, Rack của Ruby, pipeline của ASP.NET Core, Layer của tower trong Rust. Điểm chung cấu trúc là mỗi lớp có một lượt lúc vào và một lượt lúc ra.
Hai điều đáng mang theo. Một: trang trí có thể lặng lẽ làm mất khả năng của thứ bị bọc — ResponseWriter bọc lại của Go đánh rơi Flusher/Hijacker — nên một cái vỏ bọc phải chuyển tiếp mọi thứ cái gốc vốn có; con búp bê ngoài quên một cánh cửa là cái cửa đó biến mất với mọi người gọi cần nó. Hai: nói đúng giao diện chuẩn quyết định bạn có bị khoá hay không — middleware/router dựng trên kiểu handler chuẩn của ngôn ngữ (như chi trên http.Handler) thì ghép được với tất cả và thay ra được; cái dựng trên một kiểu context riêng (như gin.Context) thì khoá mã của bạn vào nó. Sợi chỉ chung: mô hình hoá các mối quan tâm cắt ngang thành handler-bọc-handler trên giao diện chuẩn — mỗi lớp là một cặp trước-và-sau, và khi bọc thì nhớ chuyển tiếp đủ mọi khả năng cái gốc có.
Ngày mai: database/sql — pool kết nối và những chỗ nó âm thầm khác Java.
Bài tập làm thử
Bài 1 (đọc hiểu). Với đoạn mã sau, hãy cho biết thứ tự các dòng "vào" và "ra" được in ra, biết mỗi middleware in một dòng lúc vào (trước next.ServeHTTP) và một dòng lúc ra (sau next.ServeHTTP):
h := Log(PhucHoi(XacThuc(mux)))
Đáp án
Đọc từ ngoài vào trong lúc "mở": Log vào → PhucHoi vào → XacThuc vào → mux chạy. Sau đó "đóng" theo chiều ngược lại: XacThuc ra → PhucHoi ra → Log ra. Đúng như búp bê Nga: mở từ ngoài vào lõi, đóng từ lõi ra ngoài.
Bài 2 (sửa lỗi). Đoạn ghiLai sau được dùng để bắt mã trạng thái nhưng có lỗi khiến WriteHeader không cập nhật đúng giá trị. Tìm lỗi:
type ghiLai struct {
http.ResponseWriter
ma int
}
func (g ghiLai) WriteHeader(ma int) {
g.ma = ma
g.ResponseWriter.WriteHeader(ma)
}
Đáp án
Receiver dùng giá trị (g ghiLai) thay vì con trỏ (g *ghiLai). Gán g.ma = ma chỉ sửa bản sao cục bộ, giá trị ma lưu trong struct gốc mà middleware đọc lại sau đó (gl.ma) vẫn không đổi — mặc định vẫn là 200. Phải khai func (g *ghiLai) WriteHeader(ma int) và dùng con trỏ &ghiLai{...} khi tạo.
Bài 3 (vận dụng). Bạn cần thêm middleware GioiHanKichThuoc giới hạn thân request tối đa 1MB, đặt nó vào đúng vị trí trong chuỗi Chain(mux, Log, PhucHoi, XacThuc, GioiHanKichThuoc) sao cho việc giới hạn kích thước xảy ra sớm nhất có thể (trước cả xác thực, để tránh tốn công xác thực một request quá khổ). Viết lại lời gọi Chain với thứ tự tham số đúng, biết Chain bọc theo đúng thứ tự liệt kê từ ngoài vào trong.
Đáp án
h := Chain(mux, Log, PhucHoi, GioiHanKichThuoc, XacThuc)
Chain lặp ngược danh sách middleware để bọc, nghĩa là middleware liệt kê trước sẽ chạy trước (nằm ngoài cùng). Muốn GioiHanKichThuoc chạy trước XacThuc thì phải đặt nó trước XacThuc trong danh sách tham số. Log và PhucHoi thường vẫn nên đứng ngoài cùng để log/phục hồi panic bao trùm mọi thứ, kể cả lỗi giới hạn kích thước.
Bài 4 (bẫy/đánh đổi). Bài viết nói bọc http.ResponseWriter "làm mất" các interface tuỳ chọn như http.Flusher và http.Hijacker. Giải thích vì sao lại mất, và điều đó gây hậu quả gì với một endpoint dùng Server-Sent Events (cần gọi Flush() sau mỗi lần ghi)?
Đáp án
ghiLai nhúng http.ResponseWriter nhưng bản thân struct ghiLai không tự động thoả mãn http.Flusher (interface có method Flush()) trừ khi http.ResponseWriter cụ thể bên trong nó có method đó VÀ được expose ra ngoài đúng kiểu — thực chất vấn đề là: nếu handler bên trong làm type assertion w.(http.Flusher) trên gl (giá trị *ghiLai), assertion đó thất bại vì *ghiLai không tự thừa hưởng method Flush từ interface nhúng (chỉ các method đã khai trong http.ResponseWriter interface mới được thăng cấp, còn Flusher/Hijacker là interface khác, tách biệt). Với SSE, handler gọi Flush() sẽ panic hoặc bị bỏ qua vì gl không lộ ra là Flusher, dữ liệu bị giữ trong buffer thay vì đẩy ngay tới client. Cách khắc phục là middleware phải tự cài thêm method Flush()/Hijack() chuyển tiếp tường minh xuống ResponseWriter gốc.
Bài 5 (đọc hiểu số liệu/bẫy). Bài viết khuyên "bắt đầu với ServeMux" cho dự án mới thay vì chi hay gin. Nêu lý do kỹ thuật cụ thể được nêu trong bài để chọn chi hơn gin nếu sau này cần router mạnh hơn.
Đáp án
chi tương thích hoàn toàn với http.Handler chuẩn của thư viện net/http, nên middleware viết theo interface đó dùng được ở cả ServeMux lẫn chi, và bỏ chi sau này không phải viết lại gì. gin thì dùng kiểu gin.Context riêng của nó, khiến mã handler bị khoá cứng vào gin — đổi router khác sẽ phải viết lại toàn bộ handler.