Mọi server HTTP thực tế cần các lớp xử lý cắt ngang: ghi log, xác thực, CORS, nén gzip, giới hạn tần suất, phục hồi panic. Nhồi tất cả vào từng handler là lặp và rối. Go giải bài này bằng một mẫu thanh lịch dựa trên hàm bậc cao: middleware — một hàm nhận http.Handler và trả về http.Handler bọc thêm hành vi. Xâu chuỗi chúng lại cho ta một pipeline xử lý gọn, tái dùng được. Bài này mổ xẻ cơ chế củ hành, cách một middleware dừng chuỗi, một combinator để kết hợp, và đo thật chi phí thực sự.

Middleware là hàm bọc Handler

Định nghĩa chuẩn: Middleware là kiểu func(http.Handler) http.Handler. Nó nhận handler "tiếp theo" và trả về một handler mới thực hiện việc của nó rồi gọi handler tiếp theo:

type Middleware func(http.Handler) http.Handler

func Log(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("vào")
		next.ServeHTTP(w, r) // gọi lớp trong -> mô hình củ hành
		fmt.Println("ra")    // chạy SAU khi lớp trong xong
	})
}

Đây là mô hình củ hành (onion): code trước next.ServeHTTP chạy lúc request đi vào, code sau chạy lúc response đi ra. Nhờ closure bắt giữ next, mỗi lớp bọc lớp trong nó, tạo thành các vòng đồng tâm quanh handler lõi.

Ảnh chụp đoạn mã Go nền tối minh hoạ middleware chain trong Go xâu chuỗi xử lý HTTP bằng hàm bậc cao, middleware bằng hàm nhận Handler trả Handler bọc thêm type Middleware func http Handler http Handler func Log next http Handler http Handler return http HandlerFunc func w r fmt Println vào next ServeHTTP w r gọi lớp trong mô hình củ hành fmt Println ra chạy sau khi lớp trong xong, short-circuit middleware có thể dừng chuỗi func Auth next http Handler http Handler return http HandlerFunc func w r nếu r Header Get X-Token bằng rỗng http Error w cần token 401 return không gọi next handler chính không chạy next ServeHTTP w r, Chain kết hợp nhiều middleware giữ thứ tự khai báo func Chain mws biến thiên Middleware Middleware return func final http Handler http Handler for i bằng len mws trừ 1 i lớn hơn bằng 0 i trừ trừ bọc từ trong ra final bằng mws i final mw đầu tiên chạy đầu return final app bằng Chain Log Auth handler Log sang Auth sang Handler, vì sao mẫu này mạnh mỗi mối quan tâm log auth cors gzip là 1 hàm độc lập ghép đổi thứ tự dễ tái dùng across route server chữ ký chuẩn http Handler khớp mọi router thư viện dựng một lần lúc khởi tạo không phí mỗi request

Hình 1: Middleware là func(http.Handler) http.Handler. Log in "vào" trước next.ServeHTTP và "ra" sau — mô hình củ hành. Auth short-circuit bằng cách return không gọi next. Chain kết hợp nhiều middleware giữ đúng thứ tự khai báo.

Short-circuit: dừng chuỗi giữa chừng

Sức mạnh của mẫu này là một middleware có thể không gọi next — dừng toàn bộ chuỗi và trả response luôn. Đây là cách Auth chặn request thiếu token:

func Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Token") == "" {
			http.Error(w, "cần token", 401)
			return // KHÔNG gọi next -> handler chính KHÔNG chạy
		}
		next.ServeHTTP(w, r)
	})
}

Chain: kết hợp giữ đúng thứ tự

Xâu chuỗi thủ công Log(Auth(handler)) đọc ngược và khó khi có nhiều lớp. Một combinator Chain gói việc đó, giữ thứ tự khai báo tự nhiên:

func Chain(mws ...Middleware) Middleware {
	return func(final http.Handler) http.Handler {
		for i := len(mws) - 1; i >= 0; i-- { // bọc từ TRONG ra
			final = mws[i](final)
		}
		return final
	}
}
app := Chain(Log, Auth)(handler) // Log -> Auth -> Handler

Vòng lặp bọc từ cuối lên đầu, để middleware đầu tiên trong danh sách chạy đầu tiên (ngoài cùng). Đo thật (Go 1.23):

Ảnh chụp bảng kết quả đo thật nền tối middleware chạy kiểu củ hành short-circuit được 5 lớp gần như 0 phí, go run cộng go test bench Go 1.23 arm64 10 core, request có token thứ tự củ hành vào rồi ra Log vào api Log bọc ngoài chạy trước Auth OK Auth lớp giữa Handler xử lý chính lõi trong cùng Log ra api Log chạy sau khi lõi xong, request thiếu token Auth dừng chuỗi Log vào api Auth từ chối thiếu token Auth return không gọi next Log ra api Log ra vẫn chạy sau ServeHTTP mã trả 401 Handler chính không chạy, benchmark chi phí của chuỗi middleware không middleware handler trần 81,85 ns 208 byte 4 alloc chuỗi 5 middleware 80,22 ns 208 byte 4 alloc 5 lớp middleware gần như không thêm phí 80,22 vs 81,85 ns cùng cấp phát chuỗi dựng một lần lúc khởi tạo mỗi request chỉ gọi qua closure lồng đã dựng sẵn, cốt lõi middleware func Handler Handler bọc thêm hành vi củ hành code trước next chạy vào code sau chạy ra thứ tự Chain giữ thứ tự khai báo mw đầu chạy đầu dừng chuỗi không gọi next handler chính bị bỏ qua 401 chi phí gần 0 mỗi request vì chuỗi dựng 1 lần lúc khởi tạo

Hình 2: Request có token — thứ tự củ hành: Log vào → Auth OK → Handler → Log ra. Request thiếu token — Auth từ chối (401), Handler chính không chạy, nhưng Log ra vẫn chạy (nó nằm sau ServeHTTP). Benchmark: 5 middleware (80,22 ns) gần như bằng handler trần (81,85 ns).

Kết quả chạy: với token, thứ tự là Log vào → Auth OK → Handler → Log ra — đúng mô hình củ hành. Thiếu token, Auth từ chối và trả 401, Handler chính không chạy, nhưng Log ra vẫn chạy vì nó nằm sau lời gọi next.ServeHTTP (mà Log vẫn gọi Auth; chính Auth mới không gọi handler lõi).

Đo thật: chuỗi middleware gần như miễn phí

Câu hỏi công bằng: mỗi lớp middleware là một lời gọi hàm — chồng 5-10 lớp có chậm không? Benchmark handler trần so với chuỗi 5 middleware:

  • không middleware: 81,85 ns/op, 208 B, 4 cấp phát.
  • chuỗi 5 middleware: 80,22 ns/op, 208 B, 4 cấp phát.

Gần như không khác biệt (chênh trong nhiễu, cùng cấp phát). Lý do: chuỗi được dựng một lần lúc khởi tạo server (app := Chain(...)(handler)), tạo ra các closure lồng nhau cố định. Mỗi request chỉ gọi qua chuỗi đã dựng sẵn — mỗi lớp là một lời gọi hàm mà Go tối ưu rất tốt. (208 B/4 cấp phát ở đây chủ yếu là httptest.NewRecorder trong benchmark, không phải middleware.) Kết luận: đừng lo về chi phí xâu chuỗi middleware; nó không đáng kể.

Đánh đổi cần cân nhắc

Chuẩn func(http.Handler) http.Handler khớp toàn hệ sinh thái. Vì middleware của bạn dùng đúng chữ ký http.Handler chuẩn, nó cắm được vào bất kỳ router nào (chi, gorilla/mux, net/http thuần) và tái dùng across nhiều server. Đừng phát minh chữ ký middleware riêng (nhận *Context tùy biến) trừ khi thật sự cần — nó khóa bạn vào framework và mất tính tương thích. Router như chi xây trọn vẹn quanh chuẩn này.

Thứ tự middleware quan trọng và dễ sai. Recover (phục hồi panic) phải ngoài cùng để bắt panic từ mọi lớp trong; Log thường ngoài để đo cả thời gian; Auth trước handler nghiệp vụ; Gzip gần lõi. Đặt sai thứ tự gây bug tinh vi (panic không được bắt, log thiếu thời gian auth). Chain giữ thứ tự khai báo rõ ràng, nhưng bạn vẫn phải suy nghĩ về thứ tự đó.

Truyền dữ liệu giữa middleware qua context, không qua biến chung. Khi một middleware (auth) cần chuyển dữ liệu (user id) cho handler, dùng r.Context() với context.WithValue — không dùng biến toàn cục hay field trên struct dùng chung (không an toàn với nhiều request đồng thời). Đây là lý do context.Context tồn tại; middleware là nơi dùng nó đúng nhất.

Ba ý mang về

  1. Middleware trong Go là func(http.Handler) http.Handler — hàm bậc cao bọc thêm hành vi quanh handler theo mô hình củ hành: code trước next.ServeHTTP chạy lúc vào, code sau chạy lúc ra (đo thật Log vào → Auth → Handler → Log ra).
  2. Middleware short-circuit được: không gọi next là dừng chuỗi và bỏ qua handler lõi (đo thật Auth thiếu token trả 401, handler chính không chạy) — nền tảng cho auth, rate limit, cache; Chain combinator kết hợp nhiều lớp giữ đúng thứ tự khai báo.
  3. Chi phí gần như bằng không: đo thật 5 lớp middleware (80,22 ns) ngang handler trần (81,85 ns) vì chuỗi dựng một lần lúc khởi tạo — dùng chuẩn http.Handler để khớp cả hệ sinh thái, và truyền dữ liệu giữa lớp qua context.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho middleware:

func Log(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("vào")
		next.ServeHTTP(w, r)
		fmt.Println("ra")
	})
}

Với app := Chain(Log, Auth)(handler), khi request có token hợp lệ, thứ tự các dòng in ra là gì? Giải thích theo "mô hình củ hành".

Đáp án

Thứ tự: Log vào → (Auth cho qua) → Handler xử lý → Log ra. Theo mô hình củ hành, Log là lớp ngoài cùng: code trước next.ServeHTTP(w, r) ("vào") chạy khi request đi vào, sau đó gọi lớp trong (Auth, rồi Handler), và code sau next.ServeHTTP ("ra") chỉ chạy sau khi toàn bộ các lớp trong đã xử lý xong và trả về.

Bài 2 (sửa lỗi). Middleware Auth sau có lỗi: dù thiếu token, handler chính vẫn chạy:

func Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Token") == "" {
			http.Error(w, "cần token", 401)
		}
		next.ServeHTTP(w, r)
	})
}

Sửa lại.

Đáp án
func Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Token") == "" {
			http.Error(w, "cần token", 401)
			return // THIẾU dòng này — phải return để KHÔNG gọi next
		}
		next.ServeHTTP(w, r)
	})
}

Thiếu return sau http.Error khiến next.ServeHTTP(w, r) vẫn được gọi bất kể có token hay không — mất tác dụng short-circuit. Middleware phải return ngay sau khi quyết định dừng chuỗi để handler lõi không chạy.

Bài 3 (vận dụng). Viết một middleware Recover phục hồi panic từ các lớp trong, trả về mã lỗi 500 thay vì để server sập, và đặt nó ở vị trí đúng khi gọi Chain.

Đáp án
func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				http.Error(w, "lỗi máy chủ", 500)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

app := Chain(Recover, Log, Auth)(handler) // Recover NGOÀI CÙNG

Recover phải đặt ngoài cùng (đầu danh sách trong Chain, vì Chain giữ thứ tự khai báo và middleware đầu tiên chạy đầu tiên/ngoài cùng nhất) để defer/recover của nó bao trùm và bắt được panic xảy ra ở bất kỳ lớp trong nào (Log, Auth, hay Handler) — đúng như bài viết khuyến nghị "Recover phải ngoài cùng để bắt panic từ mọi lớp trong".

Bài 4 (bẫy/đánh đổi). Đo thật cho thấy chuỗi 5 middleware (80,22 ns/op) gần như bằng handler trần (81,85 ns/op), cùng 4 cấp phát. Vì sao chi phí gần như bằng 0 dù có 5 lớp bọc, và điều gì làm cho điều này đúng?

Đáp án

Vì chuỗi middleware được dựng một lần duy nhất lúc khởi tạo server (app := Chain(...)(handler)), tạo ra các closure lồng nhau cố định ngay từ đầu. Mỗi request chỉ đơn giản gọi qua chuỗi closure đã dựng sẵn đó — mỗi lớp chỉ là một lời gọi hàm bổ sung, mà Go tối ưu rất tốt, không có việc dựng lại chuỗi hay cấp phát thêm cho mỗi request. Đây là lý do bài viết kết luận "đừng lo về chi phí xâu chuỗi middleware; nó không đáng kể".

Bài 5 (đọc hiểu/đánh đổi). Bài viết khuyên "truyền dữ liệu giữa middleware qua context, không qua biến chung". Nếu một middleware Auth lưu user ID vào một biến toàn cục (package-level variable) để handler đọc lại, vấn đề gì sẽ xảy ra khi server xử lý nhiều request đồng thời?

Đáp án

Biến toàn cục dùng chung giữa mọi request sẽ bị ghi đè lẫn nhau khi nhiều goroutine xử lý nhiều request cùng lúc (race condition) — request A vừa lưu user ID của mình, request B chạy song song ghi đè ngay, khiến handler của A đọc nhầm user ID của B. Đây là lý do phải dùng r.Context() với context.WithValue để lưu dữ liệu: mỗi request có context riêng, không chia sẻ trạng thái giữa các goroutine xử lý request khác nhau — an toàn và đúng thiết kế mà context.Context được tạo ra để giải quyết.

Phần sau ta xét cách mở rộng chương trình bằng các thành phần nạp động: Phần sau mổ xẻ plugin architecture trong Go — từ package plugin chính thức tới cách dựng hệ thống mở rộng bằng interface đăng ký, và vì sao Go ít dùng plugin nạp lúc chạy.