net/http
Signature ของ handler: ResponseWriter และ *Request
หัวข้อที่มีชื่อว่า “Signature ของ handler: ResponseWriter และ *Request”HTTP handler ทุกตัวใน Go มีรูปร่างเดียวกัน:
func(w http.ResponseWriter, r *http.Request)w มาก่อนตาม convention ที่สอดคล้องกับส่วนอื่นของ standard library — fmt.Fprintln(w, ...), io.Copy(dst, src), json.NewEncoder(w) ล้วนวางสิ่งที่ถูกเขียนไปไว้ก่อนสิ่งที่ถูกอ่านหรือตัวข้อมูลเอง ไม่มีกฎของ compiler บังคับลำดับนี้ มันเป็น convention ด้าน readability ที่ net/http วางไว้ และทุก framework ที่สร้างต่อบนมันก็ทำตาม
parameter ทั้งสองตัวมี type ต่างกันในแบบที่สำคัญ:
http.ResponseWriterเป็น interface (Header() Header,Write([]byte) (int, error),WriteHeader(int)) concrete type ที่อยู่เบื้องหลังต่างกันไปตาม context:net.Conn-backed writer จริงใน production,*httptest.ResponseRecorderใน test บาง concrete type implement interface เสริมเพิ่ม —http.Flusher,http.Hijacker— ที่ handler ทำ type assertion เอาได้เมื่อต้องการ (สอนไว้ใน Type Assertions)*Requestเป็น pointer ไปยัง struct ที่เป็น concrete typeRequestพก body (io.ReadCloser), context และ field อื่นๆ ที่ handler ไม่ควร copy โดยไม่ตั้งใจ — การ copyRequestโดยค่าจะ duplicate struct ที่ตั้งใจแทนหนึ่ง request ที่เข้ามา พร้อมเสี่ยง share (หรือแย่กว่านั้นคือแยก fork เงียบๆ) body reader ที่อยู่ข้างใต้ มีแค่*Requestเดียวต่อการเรียกหนึ่งครั้ง และ pointer ทำให้มันเป็นแบบนั้นต่อไป
HandleFunc, NewServeMux และ default mux
หัวข้อที่มีชื่อว่า “HandleFunc, NewServeMux และ default mux”http.HandleFunc(pattern, handler) เป็น function ระดับ package มันลงทะเบียน handler บน http.DefaultServeMux ซึ่งเป็นตัวแปร *ServeMux global ตัวเดียวที่ทุกการเรียก http.HandleFunc ในโปรแกรม — โค้ดของคุณ และ init() ของ package ที่ import มา — ใช้ร่วมกัน
import _ "net/http/pprof" // registers /debug/pprof/* on DefaultServeMux as a side effect
func main() { http.HandleFunc("/hello", helloHandler) // now sharing a mux with pprof http.ListenAndServe(":8080", nil) // nil Handler means "use DefaultServeMux"}การ import net/http/pprof เพื่อ side effect ล้วนๆ (blank import ด้วย _ แบบเดียวกับที่สอนใน Packages & Imports) จะลงทะเบียน profiling endpoint ไว้ที่ /debug/pprof/ บน mux global ตัวเดียวกันนั้น ถ้า binary ที่ production ของคุณ import pprof ที่ไหนสักแห่งใน dependency tree แล้วยังเรียก http.ListenAndServe(addr, nil) ด้วย profiling endpoint เหล่านั้น — ซึ่ง dump goroutine stack กับ heap content ได้ — จะเข้าถึงได้จาก public port นี่คือ incident category ที่เกิดจริงซ้ำๆ ไม่ใช่เรื่องสมมติ
http.NewServeMux() สร้าง mux ส่วนตัวแทน ขอบเขตอยู่แค่ที่คุณส่งมันเป็น Handler ให้ใช้แบบนี้ในโปรแกรมที่มี route มากกว่าไม่กี่ตัว และใช้เสมอใน library code — library ที่เรียก http.HandleFunc จะไปปนเปื้อนทุกโปรแกรมที่ import มันเข้าไป
Routing ตั้งแต่ Go 1.22
หัวข้อที่มีชื่อว่า “Routing ตั้งแต่ Go 1.22”ตั้งแต่ Go 1.22 pattern ของ ServeMux ใส่ HTTP method และ path segment แบบ wildcard ได้:
mux := http.NewServeMux()mux.HandleFunc("GET /items/{id}", itemHandler) // method + wildcard segmentmux.HandleFunc("POST /items", createHandler) // method, no wildcardmux.HandleFunc("GET /items/special", specialHandler) // more specific literalภายใน handler ที่ match แล้ว r.PathValue("id") คืน segment {id} ที่ดักได้จาก URL wildcard แบบ trailing ... (/files/{path...}) จะดักส่วนที่เหลือของ path ทั้งหมดแทนที่จะเป็นแค่ segment เดียว
Precedence: ตัวที่เจาะจงกว่าชนะ เมื่อ pattern ที่ลงทะเบียนไว้สองตัวสามารถ match request เดียวกันได้ทั้งคู่ ServeMux จะเลือกตัวที่เจาะจงกว่า — literal segment ชนะ wildcard ที่ตำแหน่งเดียวกัน ลงทะเบียน GET /items/{id} กับ GET /items/special แล้วยิง request แต่ละ path ตามลำดับ ยืนยันแล้วจริงในเครื่อง:
/items/special -> 200 literal special/items/42 -> 200 wildcard id= 42/items/ -> 200 subtree catch-all, path= /items//items/a/b -> 200 subtree catch-all, path= /items/a/b/items/special match กับ pattern แบบ literal ไม่ใช่แบบ wildcard แม้ทั้งคู่จะ match ได้ทางเทคนิค; /items/ (ที่ลงทะเบียนเป็น pattern ของตัวเองพร้อม trailing slash) ทำหน้าที่เป็น subtree catch-all สำหรับทุกอย่างข้างใต้มันที่ไม่มี pattern ที่เจาะจงกว่าอ้างสิทธิ์
Trailing slash redirect ถ้าคุณลงทะเบียนแค่ GET /items/ (pattern แบบ subtree) แล้ว client ขอ /items (ไม่มี trailing slash) ServeMux จะตอบกลับด้วย redirect เพิ่ม slash ให้อัตโนมัติ — ยืนยันแล้วจริงในเครื่อง:
GET /items -> 307 /items/Pattern ที่กำกวมเป็น panic ตอนลงทะเบียน ไม่ใช่การตัดสินใจ routing ตอน runtime pattern สองตัวที่ match request ชุดเดียวกันเป๊ะๆ — เช่น /items/{id} กับ /items/{name} — ลงทะเบียนพร้อมกันทั้งคู่ไม่ได้:
panic: pattern "/items/{name}" (registered at ...) conflicts with pattern "/items/{id}" (registered at ...):/items/{name} matches the same requests as /items/{id}(ตัด noise ของ file:line ออก ตัวข้อความไม่ได้แก้ไขจากการรันจริงในเครื่อง) สิ่งนี้ล้มเหลวทันทีตอนโปรแกรมเริ่ม ไม่ใช่ตอน request แรกที่ชนกัน นี่คืออีกเหตุผลที่ควรเลือก http.NewServeMux() บวก startup smoke test แทน DefaultServeMux ที่แอบแฝงอยู่
http.Server และ timeout ของมัน
หัวข้อที่มีชื่อว่า “http.Server และ timeout ของมัน”http.ListenAndServe(addr, handler) เป็น convenience wrapper รอบ http.Server ที่ทุก field ถูกปล่อยไว้ที่ zero value:
http.ListenAndServe(":8080", mux) // ReadTimeout, WriteTimeout, IdleTimeout: all zeroตามเอกสารของ http.Server ReadTimeout, WriteTimeout หรือ IdleTimeout ที่เป็น zero หมายถึง ไม่มี timeout เลย — client ที่ช้าหรือมุ่งร้ายสามารถถือ connection ค้างไว้ได้ไม่จำกัดเวลา ReadHeaderTimeout มีกฎ zero-means-none เดียวกันแต่จะ fallback ไปใช้ ReadTimeout เมื่อตัวมันเป็น zero และ ReadTimeout ถูกตั้งไว้ โค้ด production ควรสร้าง Server แบบเจาะจงเอง:
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, // caps time to read just the headers ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // keep-alive connections}srv.ListenAndServe()ReadHeaderTimeout เพียงอย่างเดียวหยุดการโจมตีแบบ slow-client ที่พบบ่อยที่สุด (ส่ง header ทีละ byte หยดๆ) โดยไม่จำกัดว่า request body ที่อัปโหลดช้าแบบถูกต้องจะใช้เวลาได้นานแค่ไหน — นั่นคือหน้าที่ของ ReadTimeout (ทั้ง request รวม body) และ deadline ต่อ handler ผ่าน r.Context()
การเขียน response
หัวข้อที่มีชื่อว่า “การเขียน response”กฎสามข้อควบคุมทุกอย่างที่ handler เขียนไปยัง w:
- ตั้ง header ก่อนเรียก
WriteHeaderหรือWritew.Header().Set(...)แก้ map ที่ถูกอ่านแค่ครั้งเดียว — ตอนที่ status line ถูกส่งออกไป header ใดที่ตั้งหลังจุดนั้นจะถูกละเลยเงียบๆ WriteHeader(code)ส่ง status line และ header เรียกมันมากกว่าหนึ่งครั้ง หรือเรียกหลังWriteที่ส่ง200 OKโดยปริยายไปแล้ว จะไม่เปลี่ยนสิ่งที่ client เห็น — มันแค่พิมพ์ warning ลง log ของ server (http: superfluous response.WriteHeader call from ...)http.Error(w, msg, code)คือวิธีมาตรฐานในการทำให้ request ล้มเหลว มันตั้งContent-Type: text/plain; charset=utf-8, ลบContent-Lengthที่ตั้งไว้ก่อนหน้า, เขียน status code, และเขียนmsgตามด้วย newline — ครบทั้งสามขั้นตอนตามลำดับที่ถูกต้อง จึงแทบไม่ต้องทำเองด้วยมือ
if !ok { http.Error(w, "item not found", http.StatusNotFound) return}w.Header().Set("Content-Type", "application/json") // must come before Encodejson.NewEncoder(w).Encode(it) // Encode's first Write implies WriteHeader(200)json.NewEncoder(w).Encode(v) คือ pattern การตอบกลับแบบ JSON ที่ idiomatic: มันเขียนตรงไปยัง w โดยไม่มี []byte คั่นกลาง และ byte แรกที่มันเขียนจะ trigger 200 OK โดยปริยายถ้าคุณยังไม่ได้เรียก WriteHeader เอง
การอ่าน request body
หัวข้อที่มีชื่อว่า “การอ่าน request body”json.NewDecoder(r.Body).Decode(&v) คือภาพสะท้อนของด้านขาเข้า — มัน decode ตรงจาก stream แทนที่จะ buffer body ทั้งหมดเข้าหน่วยความจำด้วย io.ReadAll ก่อน มีรายละเอียดด้านความปลอดภัยสองอย่างที่สำคัญสำหรับ body ใดๆ ที่ client ควบคุมได้:
http.MaxBytesReader(w, r.Body, n)จำกัดจำนวน byte ที่ body มีได้ ห่อr.Bodyด้วยมันก่อน decode; body ที่เกิน limit จะทำให้Decodeคืน error ที่มีข้อความเป๊ะๆ ว่าhttp: request body too large(ยืนยันแล้วจริงในเครื่อง) แทนที่จะปล่อยให้ attacker stream หลาย gigabyte เข้ามาใน process ของคุณ- คุณไม่ต้องปิด
r.Bodyเอง ตามเอกสารของnet/http: “For server requests, the Request Body is always non-nil… The Server will close the request body. The ServeHTTP Handler does not need to.” นี่คือจุดเดียวในทั้ง package ที่กฎปกติ “เปิดเอง ปิดเอง” ใช้ไม่ได้
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MiBvar in itemif err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return}ฝั่ง client
หัวข้อที่มีชื่อว่า “ฝั่ง client”http.Get, http.Post และเพื่อนๆ ระดับ package ใช้ http.DefaultClient ซึ่ง zero value ของมันมี Timeout: 0 — ไม่มี timeout ตามเอกสารของ http.Client (“A Timeout of zero means no timeout”) server ที่ค้างหรือ network partition ฝั่งตรงข้ามสามารถ block การเรียกนั้นได้ตลอดไป ควรสร้าง client พร้อม timeout เสมอ:
client := &http.Client{Timeout: 5 * time.Second}ยังมีข้อผิดพลาดอีกสามอย่างที่พบบ่อยจนทั้ง go vet และเอกสารของ standard library เตือนไว้ตรงๆ:
-
defer resp.Body.Close()ต้องมา หลัง การเช็ก error เสมอ ห้ามมาก่อน เอกสารของClient.Doบอกตรงๆ ว่า “On error, any Response can be ignored” — หมายความว่าrespเป็นnilได้เมื่อerr != nilการ deferresp.Body.Close()ก่อนเช็กerrคือการเรียก.Body.Close()ผ่าน pointer ที่เป็น nil analyzerhttpresponseของgo vetจับกรณีนี้ได้ตอน build:main.go:12:8: using resp before checking for errorsถ้าข้าม vet แล้วรันโค้ดเดียวกันอยู่ดี จะ panic ตรงตามที่คาดไว้:
panic: runtime error: invalid memory address or nil pointer dereferenceวิธีแก้มีรูปแบบเดียวกันเสมอ:
resp, err := client.Get(url)if err != nil {return err}defer resp.Body.Close() // resp is guaranteed non-nil here -
Doไม่เช็ก status code ให้คุณ ตามเอกสาร: “A non-2xx status code doesn’t cause an error.” response404หรือ500คือการเรียกDoที่สำเร็จสมบูรณ์แบบด้วยerr == nil— คุณต้องเช็กresp.StatusCodeเองก่อนจะเชื่อ body -
io.ReadAll(resp.Body)ไม่มีขีดจำกัดขนาด server ที่มุ่งร้ายหรือทำงานผิดปกติสามารถ stream response ที่ไม่จำกัดขนาดจนหน่วยความจำหมด ห่อ body ด้วยio.LimitReader(resp.Body, n)ก่อนเมื่อขนาดของ response ยังไม่น่าเชื่อถือ
http.NewRequestWithContext(ctx, method, url, body) คือ constructor ที่ควรใช้เมื่อ request ควรยกเลิกได้ — มันผูก lifetime ของ request เข้ากับ context.Context แทนที่จะพึ่งแค่ Timeout รวมของ client ทำให้ deadline ต่อการเรียกหรือการยกเลิกของ caller เองส่งต่อผ่านไปได้
Middleware
หัวข้อที่มีชื่อว่า “Middleware”Middleware ใน net/http ไม่ใช่อะไรมากไปกว่า function ที่รับ Handler แล้วคืน Handler ใหม่ที่ห่อมันไว้:
func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Printf("--> %s %s\n", r.Method, r.URL.Path) next.ServeHTTP(w, r) // call through, then (optionally) act again after })}เพราะรูปร่าง func(http.Handler) http.Handler รับและคืน interface เดียวกันเสมอ middleware จึงประกอบกันได้ด้วยการเรียก function ธรรมดา: loggingMiddleware(authMiddleware(mux)) รัน logging ก่อน แล้วค่อย auth แล้วค่อย mux — wrapper ชั้นนอกสุดรันก่อนตอนขาเข้า และรันหลังสุดตอนขาออก
Playground ด้านล่างเชื่อม handler, mux ที่ใช้ route แบบ 1.22, middleware chain และ client เข้าด้วยกัน — server กับ client รันใน process เดียวกันผ่าน httptest.NewServer จึงไม่ต้องใช้ network ภายนอกเลย:
package main
import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "time")
type item struct { ID string `json:"id"` Name string `json:"name"`}
var items = map[string]item{"1": {ID: "1", Name: "widget"}}
// itemHandler reads the {id} path value that ServeMux extracted for us.func itemHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") it, ok := items[id] if !ok { http.Error(w, "item not found", http.StatusNotFound) // sets status + Content-Type + body return } w.Header().Set("Content-Type", "application/json") // header before WriteHeader json.NewEncoder(w).Encode(it) // Encode calls WriteHeader(200) implicitly}
// createHandler decodes a JSON body with a size cap.func createHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MiB var in item if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } items[in.ID] = in w.WriteHeader(http.StatusCreated) // must come before any Write json.NewEncoder(w).Encode(in)}
// loggingMiddleware wraps a Handler and returns a Handler -- the chaining shape.func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Printf("--> %s %s\n", r.Method, r.URL.Path) next.ServeHTTP(w, r) })}
func main() { mux := http.NewServeMux() mux.HandleFunc("GET /items/{id}", itemHandler) mux.HandleFunc("POST /items", createHandler)
// httptest.NewServer starts a real listener on localhost so the client // below talks real HTTP, but everything stays inside this one process. ts := httptest.NewServer(loggingMiddleware(mux)) defer ts.Close()
client := &http.Client{Timeout: 5 * time.Second} // never use the default client without one
resp, err := client.Get(ts.URL + "/items/1") if err != nil { fmt.Println("GET error:", err) return } defer resp.Body.Close() // deferred AFTER the error check, not before body, _ := io.ReadAll(resp.Body) fmt.Println("GET /items/1:", resp.StatusCode, string(body))
resp2, err := client.Get(ts.URL + "/items/999") if err != nil { fmt.Println("GET error:", err) return } defer resp2.Body.Close() // Do never treats a 4xx/5xx as an error -- you must check StatusCode yourself. if resp2.StatusCode != http.StatusOK { body2, _ := io.ReadAll(resp2.Body) fmt.Println("GET /items/999:", resp2.StatusCode, string(body2)) }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() payload, _ := json.Marshal(item{ID: "2", Name: "gadget"}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/items", bytes.NewReader(payload)) if err != nil { fmt.Println("build request:", err) return } resp3, err := client.Do(req) if err != nil { fmt.Println("POST error:", err) return } defer resp3.Body.Close() body3, _ := io.ReadAll(resp3.Body) fmt.Println("POST /items:", resp3.StatusCode, string(body3))} output จริงจากโปรแกรมนี้บน Go 1.27.0:
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /items/1
Server-->>Client: 200 {"id":"1","name":"widget"}
Client->>Server: GET /items/999
Server-->>Client: 404 item not found
Client->>Server: POST /items
Server-->>Client: 201 {"id":"2","name":"gadget"} การยกเลิกด้วย context และ graceful shutdown
หัวข้อที่มีชื่อว่า “การยกเลิกด้วย context และ graceful shutdown”*Request ที่เข้ามาทุกตัวพก context.Context ที่เข้าถึงได้ผ่าน r.Context() ตามเอกสาร context นั้น “is canceled when the client’s connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns” handler ที่เริ่มงานที่ใช้เวลานานควรส่ง r.Context() ลงไปและเช็ก ctx.Err() (หรือ select บน ctx.Done()) เป็นระยะ — ไม่งั้น handler จะทำงานต่อไปและใช้ resource ต่อไปนานหลังจาก client เลิกรอและจากไปแล้ว
การปิด server ทั้งตัวอย่างสะอาดต้องใช้แนวคิดเดียวกันที่ระดับ process: หยุดรับ connection ใหม่ แต่ปล่อยให้ handler ที่กำลังทำงานอยู่ทำจนจบ signal.NotifyContext แปลง OS signal เป็น context.Context และ srv.Shutdown(ctx) คือตัวทำ graceful stop จริงๆ:
srv := &http.Server{Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()
go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }}()
<-ctx.Done() // blocks until SIGINT/SIGTERMstop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil { log.Println("forced shutdown:", err)}srv.Shutdown ปิด listener ทันที แล้วรอ (จนถึง deadline) ให้ handler ที่กำลังทำงานทุกตัวคืนค่าเองจนจบ ListenAndServe จะ unblock ด้วย http.ErrServerClosed พอดี — ผลลัพธ์ที่คาดไว้และไม่ใช่ error ของการ shutdown ที่ตั้งใจ ไม่ใช่ความล้มเหลวที่ต้อง log ตัวคู่แฝดแบบ deterministic ของสิ่งนี้ — ใช้ port ที่ OS สุ่มให้กับ timer แทน OS signal จริง — ยืนยันแล้วจริงในเครื่อง:
server shut down cleanlyServe returned ErrServerClosed: truesnippet นี้ต้องการ TCP listener จริงและ OS signal จริง ดังนั้น — เหมือนกับตัวอย่าง graceful shutdown ของโมดูล concurrency — มันไม่รันในหน้านี้บน Playground ที่ทำงานในเบราว์เซอร์
การ test ด้วย httptest
หัวข้อที่มีชื่อว่า “การ test ด้วย httptest”httptest.NewRecorder() implement http.ResponseWriter ทั้งหมดในหน่วยความจำ ดังนั้น handler จึงถูกทดสอบได้โดยตรงโดยไม่ต้องมี listener, goroutine หรือ network ใดๆ เลย:
package main
import ( "encoding/json" "fmt" "net/http" "net/http/httptest")
type item struct { ID string `json:"id"` Name string `json:"name"`}
var items = map[string]item{"1": {ID: "1", Name: "widget"}}
func itemHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") it, ok := items[id] if !ok { http.Error(w, "item not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(it)}
func main() { mux := http.NewServeMux() mux.HandleFunc("GET /items/{id}", itemHandler)
// httptest.NewRequest + httptest.NewRecorder test the handler directly -- // no listener, no goroutine, no network at all. req := httptest.NewRequest(http.MethodGet, "/items/1", nil) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req)
fmt.Println("status:", rec.Code) fmt.Println("content-type:", rec.Header().Get("Content-Type")) fmt.Println("body:", rec.Body.String())
// A request for a missing id exercises the error path the same way. req2 := httptest.NewRequest(http.MethodGet, "/items/999", nil) rec2 := httptest.NewRecorder() mux.ServeHTTP(rec2, req2) fmt.Println("missing status:", rec2.Code, "body:", rec2.Body.String())} output จริงจากโปรแกรมนี้บน Go 1.27.0:
status: 200content-type: application/jsonbody: {"id":"1","name":"widget"}
missing status: 404 body: item not foundhttptest.NewServer คือครึ่งที่เหลือของชุดเครื่องมือ — มันเปิด listener จริง (ที่ใช้โดย playground หลักด้านบน) สำหรับ test ที่ต้องการ HTTP round-trip จริงๆ เช่นการทดสอบ client library หรือเช็กว่า header รอดผ่าน socket จริงหรือไม่ ใช้ NewRecorder สำหรับ handler เดี่ยวๆ ที่แยกทดสอบ และใช้ NewServer เมื่อ client ที่กำลังทดสอบไม่สามารถชี้ไปที่อะไรอื่นนอกจาก URL จริงได้
วงจรชีวิตของ request
หัวข้อที่มีชื่อว่า “วงจรชีวิตของ request”flowchart TD C["Client"] -- "TCP connect + HTTP request" --> S["http.Server (ReadHeaderTimeout, ReadTimeout)"] S -- "ServeHTTP(w, r)" --> MW["Middleware chain"] MW -- "next.ServeHTTP(w, r)" --> H["Handler: r.PathValue, json.NewDecoder(r.Body)"] H -- "Header().Set, then WriteHeader, then Write" --> R["status line + headers + body"] R --> C Ctx["r.Context()"] -. "cancelled if the client disconnects" .-> H
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
Srv["http.Server{Addr, Handler, ReadTimeout, WriteTimeout, ...}"] --> DSM["DefaultServeMux (global, shared by every http.HandleFunc call)"]
Srv --> PSM["*ServeMux (private, created with http.NewServeMux())"]
DSM --> MW["middleware(middleware(handler)) -- outermost runs first in, last out"]
PSM --> MW
MW --> H["func(w http.ResponseWriter, r *http.Request)"]
H --> PV["r.PathValue('id') -- from the matched route pattern"]
H --> Ctx["r.Context() -- cancelled if the client disconnects"]
H --> Dec["json.NewDecoder(r.Body) -- server closes r.Body for you"]
H --> Resp["w.Header().Set(...) then w.WriteHeader(code) then w.Write(body)"]
Resp --> Note["must happen in this order -- headers are frozen at WriteHeader"] ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
http.DefaultServeMux ผ่าน http.HandleFunc | setup เป็นศูนย์ หนึ่งบรรทัดต่อ route | global mutable state ที่แชร์กับทุก package ที่ import มา |
http.NewServeMux() | routing แยกเดี่ยว ปลอดภัยใน library code | มีตัวแปรเพิ่มอีกหนึ่งตัวที่ต้องส่งผ่านโปรแกรม |
| Routing แบบ method + wildcard ของ 1.22 | ไม่ต้องใช้ third-party router สำหรับ API ส่วนใหญ่ | pattern ที่กำกวม panic ตอน startup; ไม่มีข้อจำกัดแบบ regex บน segment |
http.Server พร้อม timeout ที่ตั้งเอง | ป้องกันการโจมตีแบบ slow-client และ resource-exhaustion | ต้องคิดถึง field มากกว่า ListenAndServe(addr, nil) |
httptest.NewRecorder | test handler เร็ว ในหน่วยความจำ | ไม่ได้ทดสอบพฤติกรรม TCP จริง (chunking, connection reuse) |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”ListenAndServe(addr, nil)เป็นค่า default ที่ปลอดภัย — ทุก field ของ timeout บนhttp.Serverข้างใต้เป็น zero หมายความว่าไม่มีตัวไหนถูกบังคับใช้เลย; client ที่ช้าหรือมุ่งร้ายสามารถถือ connection ค้างไว้ได้ตลอดกาลresp.Body.Close()defer ได้ทันทีหลังDo/Getอย่างปลอดภัย —respเป็นnilได้เมื่อerr != nil; การ defer การปิดก่อนเช็กerrจะ panic ด้วย nil pointer dereference และ analyzerhttpresponseของgo vetจับ pattern นี้ได้เป๊ะๆ- การเรียก
Doที่สำเร็จหมายถึง request สำเร็จ —Doคืน error เฉพาะความล้มเหลวด้าน network/policy เท่านั้น;404หรือ500คือerr == nilพร้อม status code ที่คุณต้องเช็กเอง - การเรียก
WriteHeaderสองครั้ง หรือหลังWriteจะเปลี่ยนสิ่งที่ client เห็น — ไม่จริง byte แรกที่เขียน (หรือการเรียกWriteHeaderครั้งแรก) freeze status กับ header ไว้; อะไรก็ตามหลังจากนั้นแค่สร้าง log บรรทัดsuperfluous response.WriteHeader call - ต้องปิด
r.Bodyใน handler แบบสมมาตรกับการปิดresp.Bodyฝั่ง client — server ทำสิ่งนี้ให้คุณแล้ว มีแค่ฝั่ง client เท่านั้นที่ต้องCloseเอง
💡 ตัวอย่างจากของจริง
net/http/pprof: การ import เพื่อ side effect ของมันลงทะเบียน/debug/pprof/*บนhttp.DefaultServeMux— กลไกเดียวกันกับที่อธิบายด้านบนว่าทำไม global mux ถึงอันตรายในโปรแกรมที่เรียกhttp.ListenAndServe(addr, nil)ด้วย
http.ErrServerClosed: ถูกคืนโดยServe/ListenAndServeหลังเรียกServer.Shutdownและถือเป็นผลลัพธ์ที่คาดไว้ ไม่ใช่ error ของการ graceful shutdown แทนที่จะ log เป็นความล้มเหลว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ด net/http เป็นโค้ด Go ที่ AI assistant เขียนบ่อยที่สุดแบบหนึ่ง และก็เป็นแบบที่ผิดพลาดซ้ำๆ ตรงจุดข้างบนพอดี เช็กสิ่งเหล่านี้ก่อนเชื่อว่า review ผ่าน:
http.Clientทุกตัวที่ใช้เรียก network จริงมีTimeoutตั้งไว้หรือไม่ หรือเป็น default client แบบ zero-value?defer resp.Body.Close()วางไว้ หลัง การเช็กif err != nilเสมอหรือไม่ ห้ามมาก่อน?resp.StatusCodeถูกเช็กหลังDo/Getหรือไม่ หรือโค้ดสมมติว่า error เป็น nil แปลว่าสำเร็จ?- handler เรียก
w.Header().Set(...)ก่อนWriteหรือWriteHeaderครั้งแรกเสมอหรือไม่ ไม่ใช่หลัง? - โปรแกรมเรียก
http.HandleFunc/http.ListenAndServe(addr, nil)พร้อมกับ import package (อย่างnet/http/pprof) ที่ลงทะเบียน route บน global mux เดียวกันหรือไม่? - goroutine ที่ spawn ขึ้นภายใน handler ส่ง
r.Context()ต่อไปด้วยหรือไม่ หรือมันทำงานต่อ (และใช้ resource ต่อ) หลังจาก client หลุดการเชื่อมต่อไปแล้ว?
🐛 หา bug ในโค้ดนี้
func createHandler(w http.ResponseWriter, r *http.Request) { var in Item if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } body, _ := json.Marshal(in) w.Write(body) w.WriteHeader(http.StatusCreated)}เฉลย
w.Write(body) ส่ง response body ทันที และเนื่องจากยังไม่มีการตั้ง status ไว้ Go จะส่ง 200 OK โดยปริยายเป็นส่วนหนึ่งของการเขียนครั้งแรกนั้น w.WriteHeader(http.StatusCreated) บรรทัดถัดไปสายเกินไปแล้ว — status line ถูกส่งออกไปแล้ว ยืนยันแล้วจริงในเครื่อง: client เห็น status 200 ไม่ใช่ 201 เลย และ server log ว่า
http: superfluous response.WriteHeader call from main.createHandler (main.go:23)แก้ได้โดยเรียก WriteHeader ก่อนการ Write ครั้งแรก (หรือก่อนการเขียนภายในครั้งแรกของ encoder):
w.WriteHeader(http.StatusCreated)w.Write(body)หรือใช้ encoder ตรงๆ ก็คือ w.WriteHeader(http.StatusCreated) ตามด้วย json.NewEncoder(w).Encode(in)