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.

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.