Hình dung một máy chủ HTTP như một quầy tiếp tân cử hẳn một nhân viên riêng cho mỗi người khách bước vào. Nhân viên rẻ tới mức thuê bao nhiêu cũng được (bài 31). Nhưng có một rủi ro: người khách bước tới quầy rồi đứng im không nói gì sẽ giữ nhân viên đó đứng chờ mãi mãi — và vài nghìn người như thế là cả quầy đóng băng. Máy chủ HTTP nằm trong thư viện chuẩn Go, đủ tốt cho sản xuất — đó là lý do nhiều dịch vụ Go không dùng framework nào. Nhưng cái quầy mặc định không có lấy một quy định kiên nhẫn nào.

Mười dòng

mux := http.NewServeMux()
mux.HandleFunc("GET /don/{id}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "don id=%s", r.PathValue("id"))
})
http.ListenAndServe(":8080", mux)

Từ Go 1.22, ServeMux hiểu method và tham số đường dẫn — người tiếp tân giờ điều hướng theo cả "cửa nào" lẫn "đến làm gì":

  GET    /don/DH-9  -> 200 don id=DH-9 method=GET
  POST   /don       -> 200 tao don: {"x":1}
  GET    /khac      -> 404 khong tim thay
  DELETE /don/1     -> 404 khong tim thay

DELETE /don/1 trả 404 vì chỉ có GET /don/{id} được đăng ký — router phân biệt method.

Đây là thay đổi lớn: trước 1.22, ServeMux chỉ khớp tiền tố đường dẫn, và mọi người phải dùng chi, gorilla/mux, hay gin. Giờ thư viện chuẩn đủ cho phần lớn API.

Cú pháp mẫu:

"GET /don/{id}"          tham số đường dẫn
"GET /tep/{path...}"     bắt phần còn lại
"GET /don/{$}"           khớp CHÍNH XÁC /don/, không khớp /don/abc
"example.com/api/"       khớp theo host

Dấu {$} giải quyết một vấn đề cũ: "/don/" trước đây khớp mọi thứ bắt đầu bằng /don/.

Mỗi request một goroutine

  50 request song song -> đỉnh đồng thời 50

net/http tạo một goroutine cho mỗi request — một nhân viên cho mỗi khách — và bài 31 đã đo vì sao điều đó khả thi: 2,47 µs và 576 byte.

Hệ quả cho cách bạn viết handler:

Handler chạy đồng thời — mọi trạng thái dùng chung phải được bảo vệ. Bài 34 và 36 áp dụng nguyên vẹn.

Không cần lo về pool luồng. Không có ExecutorService, không có cấu hình số worker.

Nhưng vẫn cần giới hạn tài nguyên. Mười nghìn request đồng thời là mười nghìn goroutine cùng xin kết nối CSDL. Giới hạn bằng pool kết nối (bài 49) hoặc semaphore.

Panic trong handler chỉ giết request đó — net/http có recover sẵn. Nhưng nó chỉ in ra stderr, nên bạn vẫn nên có middleware riêng như bài 25 đã nói.

Bốn timeout bắt buộc

  http.ListenAndServe(addr, h) -> MỌI timeout đều bằng 0

Đây là điểm quan trọng nhất của bài. http.ListenAndServe dùng Server mặc định, và không có timeout nào.

Nghĩa là một client mở kết nối rồi không gửi gì sẽ giữ một goroutine của bạn mãi mãi — đúng người khách đứng im ở quầy. Vài nghìn kết nối như vậy là dịch vụ chết, và đó là một kiểu tấn công từ chối dịch vụ rất rẻ.

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 2 * time.Second,    // chống Slowloris
	ReadTimeout:       5 * time.Second,    // đọc xong cả request
	WriteTimeout:      10 * time.Second,   // ghi xong cả response
	IdleTimeout:       60 * time.Second,   // keep-alive rảnh
}
log.Fatal(srv.ListenAndServe())

Bốn quy định kiên nhẫn của cái quầy: nói rõ yêu cầu trong N giây, gửi xong request trong N giây, được phục vụ tối đa N giây, và ngồi không giữa hai lần hỏi quá N giây thì mời về. ReadHeaderTimeout là cái quan trọng nhất và hay bị quên — nó chặn kiểu tấn công gửi header rất chậm.

Với endpoint stream hoặc tải tệp lớn, WriteTimeout phải đủ dài hoặc đặt 0 và dùng context để kiểm soát.

Handler và HandlerFunc

type Handler interface {
	ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Lại là một interface một method. http.HandlerFunc là adapter biến hàm thành Handler:

type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

Mẫu này đáng học vì nó xuất hiện khắp Go: định nghĩa kiểu hàm, gắn method cho nó, và giờ hàm thoả mãn interface.

Handler cần trạng thái thì dùng struct:

type API struct { kho *Kho }
func (a *API) LayDon(w http.ResponseWriter, r *http.Request) { ... }

mux.HandleFunc("GET /don/{id}", a.LayDon)

Ghi response cho đúng

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(don)

Thứ tự bắt buộc: đặt header → WriteHeader → ghi thân. Đặt header sau WriteHeader là không có tác dụng, và Go chỉ log một dòng cảnh báo nhỏ.

Gọi WriteHeader hai lần cũng chỉ có lần đầu tính, kèm cảnh báo superfluous response.WriteHeader call.

Không gọi WriteHeader thì lần ghi đầu tiên tự động gửi 200.

Một cái bẫy thật: nếu bạn đã ghi một phần thân rồi mới gặp lỗi, không đổi được mã trạng thái nữa. Nên với response lớn, hãy dựng xong trong bộ nhớ hoặc kiểm hết lỗi trước khi ghi byte đầu tiên.

Giới hạn thân request

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

Như bài 45 đã nói. Không có nó, một request 10GB làm hết bộ nhớ máy chủ.

Nếu muốn thấy cái cửa DoS đó tận mắt trong ba mươi giây, chạy server mười dòng mặc định rồi từ terminal khác:

printf 'GET / HTTP/1.1\r\nHost: x\r\n' | nc localhost 8080

Không gửi dòng trống kết thúc header. Kết nối treo vĩnh viễn, và một goroutine của bạn kẹt theo. Đặt ReadHeaderTimeout: 2 * time.Second và thử lại — nó đóng sau hai giây. Đó là lý do bốn dòng timeout đáng viết.

Mẫu số chung

Mọi máy chủ đều xử lý đồng thời bằng cách dành một đơn vị cho mỗi kết nối — Go một goroutine, Java một luồng (pool Tomcat, hoặc luồng ảo giờ), Node/nginx một chỗ trong vòng lặp sự kiện, Python một worker. Hình dạng khác nhau, nhưng sự thật chung là: một kết nối chậm hoặc ngồi im chiếm mất một đơn vị đồng thời của bạn — nên một máy chủ không có timeout đọc/ghi/rảnh có thể bị đóng băng bởi vài client chậm. Đó chính là tấn công Slowloris, từng hạ gục Apache, và nó là anh em song sinh phía-máy-chủ của bài học phía-client "luôn đặt timeout" ở bài gọi HTTP.

Điều đáng khắc sâu: mép mạng vốn thù địch. Một peer bạn không kiểm soát có thể chậm, có thể khổng lồ, có thể ác ý — nên một máy chủ sản xuất phải chặn trần mọi thứ một peer không đáng tin có thể tiêu thụ: thời gian (bằng timeout), bộ nhớ (bằng trần kích thước thân MaxBytesReader), đồng thời (bằng giới hạn kết nối/tài nguyên). Mặc định "không giới hạn" luôn chỉ cách một cú từ-chối-dịch-vụ rẻ tiền. Sợi chỉ chung: coi mọi kết nối là có thể chậm hoặc thù địch, và đặt một cái trần cho từng pha — bao lâu để gửi header, để xong request, để nhận response, để ngồi không — và cho từng kích thước; cái one-liner tiện lợi của thư viện chuẩn bỏ sót tất cả, nên lên sản xuất nghĩa là thay nó bằng một Server khai tường minh.

Ngày mai: gọi API bằng net/http client.

Bài tập làm thử

Bài 1 (đọc hiểu). Với ServeMux từ Go 1.22 đã đăng ký duy nhất GET /don/{id}, hãy cho biết mã trạng thái trả về của từng request sau và giải thích vì sao:

POST   /don/DH-9
DELETE /don/1
GET    /don/
Đáp án

Cả ba đều trả 404 khong tim thay. POST /don/DH-9 không khớp vì route chỉ đăng ký cho method GET. DELETE /don/1 cũng vậy — router Go 1.22 phân biệt theo method, không chỉ theo path. GET /don/ không khớp GET /don/{id} vì thiếu segment {id} (mẫu {id} bắt buộc phải có một segment ở đó, khác với {id...} hay mẫu có dấu /).

Bài 2 (sửa lỗi). Server sau bị treo vô hạn khi một client mở kết nối rồi không gửi gì. Sửa để nó tự đóng kết nối sau 2 giây nếu client không gửi xong header:

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}
log.Fatal(srv.ListenAndServe())
Đáp án
srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 2 * time.Second,
	ReadTimeout:       5 * time.Second,
	WriteTimeout:      10 * time.Second,
	IdleTimeout:       60 * time.Second,
}
log.Fatal(srv.ListenAndServe())

Thiếu ReadHeaderTimeout là nguyên nhân chính của kiểu treo Slowloris mô tả trong bài (client gửi GET / HTTP/1.1\r\nHost: x\r\n rồi im, không có dòng trống kết thúc header). http.ListenAndServe dùng Server mặc định với mọi timeout bằng 0, tức chờ vĩnh viễn.

Bài 3 (vận dụng). Bạn viết một handler ghi response JSON. Đoạn mã sau có lỗi thứ tự khiến header Content-Type không có tác dụng. Chỉ ra lỗi và sửa:

w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(don)
Đáp án

Thứ tự bắt buộc là đặt header → WriteHeader → ghi thân. Đoạn trên gọi WriteHeader trước khi Set header, nên việc đặt Content-Type sau đó không có tác dụng (Go chỉ log một cảnh báo nhỏ, không báo lỗi rõ ràng). Sửa:

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(don)

Bài 4 (bẫy/đánh đổi). Handler dưới đây ghi một phần response rồi mới phát hiện lỗi ở tầng nghiệp vụ. Nó cố trả mã 500 nhưng không hoạt động như mong đợi. Giải thích vì sao, dựa theo bài viết:

func Lay(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, `{"trangThai":"dangXuLy"`)
	don, err := timDon(r)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError) // không có tác dụng
		return
	}
	fmt.Fprintf(w, `,"id":"%s"}`, don.ID)
}
Đáp án

Lần ghi đầu tiên (fmt.Fprint) đã tự động gửi mã 200 vì WriteHeader chưa được gọi tường minh trước đó. Một khi mã trạng thái và một phần thân đã được gửi ra mạng, không thể đổi mã trạng thái nữa — client đã nhận 200 kèm một JSON dở dang, không hợp lệ. Bài viết khuyên: với response cần kiểm lỗi giữa chừng, phải dựng xong nội dung trong bộ nhớ (hoặc kiểm hết lỗi) trước khi ghi byte đầu tiên.

Bài 5 (đọc-hiểu/số liệu). Vì sao net/http "một goroutine cho mỗi request" lại vẫn cần giới hạn tài nguyên riêng dù goroutine rất rẻ (theo số liệu bài 31 mà bài này nhắc lại: 2,47 µs và 576 byte)?

Đáp án

Rẻ về chi phí tạo goroutine không có nghĩa là rẻ về tài nguyên hạ nguồn: 10.000 request đồng thời là 10.000 goroutine cùng lúc xin kết nối tới cơ sở dữ liệu, cùng cấp phát bộ nhớ xử lý, v.v. Bản thân goroutine không phải nút thắt, nhưng tài nguyên dùng chung (pool kết nối CSDL, băng thông, bộ nhớ) thì có giới hạn thật — nên vẫn phải giới hạn đồng thời bằng pool kết nối hoặc semaphore, dù runtime của Go cho phép tạo goroutine gần như tự do.