ข้ามไปยังเนื้อหา

net/http

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 type Request พก body (io.ReadCloser), context และ field อื่นๆ ที่ handler ไม่ควร copy โดยไม่ตั้งใจ — การ copy Request โดยค่าจะ duplicate struct ที่ตั้งใจแทนหนึ่ง request ที่เข้ามา พร้อมเสี่ยง share (หรือแย่กว่านั้นคือแยก fork เงียบๆ) body reader ที่อยู่ข้างใต้ มีแค่ *Request เดียวต่อการเรียกหนึ่งครั้ง และ pointer ทำให้มันเป็นแบบนั้นต่อไป

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 มันเข้าไป

ตั้งแต่ Go 1.22 pattern ของ ServeMux ใส่ HTTP method และ path segment แบบ wildcard ได้:

mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", itemHandler) // method + wildcard segment
mux.HandleFunc("POST /items", createHandler) // method, no wildcard
mux.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.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()

กฎสามข้อควบคุมทุกอย่างที่ handler เขียนไปยัง w:

  1. ตั้ง header ก่อนเรียก WriteHeader หรือ Write w.Header().Set(...) แก้ map ที่ถูกอ่านแค่ครั้งเดียว — ตอนที่ status line ถูกส่งออกไป header ใดที่ตั้งหลังจุดนั้นจะถูกละเลยเงียบๆ
  2. WriteHeader(code) ส่ง status line และ header เรียกมันมากกว่าหนึ่งครั้ง หรือเรียกหลัง Write ที่ส่ง 200 OK โดยปริยายไปแล้ว จะไม่เปลี่ยนสิ่งที่ client เห็น — มันแค่พิมพ์ warning ลง log ของ server (http: superfluous response.WriteHeader call from ...)
  3. 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 Encode
json.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 เอง

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 MiB
var in item
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

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 การ defer resp.Body.Close() ก่อนเช็ก err คือการเรียก .Body.Close() ผ่าน pointer ที่เป็น nil analyzer httpresponse ของ 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.” response 404 หรือ 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 ใน 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"}
สอง GET (เจอหนึ่ง ไม่เจอหนึ่ง) และหนึ่ง POST ที่สร้างไอเทมใหม่

*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/SIGTERM
stop()
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 cleanly
Serve returned ErrServerClosed: true

snippet นี้ต้องการ TCP listener จริงและ OS signal จริง ดังนั้น — เหมือนกับตัวอย่าง graceful shutdown ของโมดูล concurrency — มันไม่รันในหน้านี้บน Playground ที่ทำงานในเบราว์เซอร์

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: 200
content-type: application/json
body: {"id":"1","name":"widget"}
missing status: 404 body: item not found

httptest.NewServer คือครึ่งที่เหลือของชุดเครื่องมือ — มันเปิด listener จริง (ที่ใช้โดย playground หลักด้านบน) สำหรับ test ที่ต้องการ HTTP round-trip จริงๆ เช่นการทดสอบ client library หรือเช็กว่า header รอดผ่าน socket จริงหรือไม่ ใช้ NewRecorder สำหรับ handler เดี่ยวๆ ที่แยกทดสอบ และใช้ NewServer เมื่อ client ที่กำลังทดสอบไม่สามารถชี้ไปที่อะไรอื่นนอกจาก URL จริงได้

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
เส้นทางของ request หนึ่งตัว จาก TCP connection ไปจนถึง response
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"]
เส้นทางของ request ผ่าน http.Server, ServeMux, middleware, ไปจนถึงลำดับการเขียน response ของ handler
สิ่งที่ได้ประโยชน์ต้นทุน
http.DefaultServeMux ผ่าน http.HandleFuncsetup เป็นศูนย์ หนึ่งบรรทัดต่อ routeglobal 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.NewRecordertest 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 และ analyzer httpresponse ของ 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 เป็นความล้มเหลว

โค้ด 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)

ทำไม *http.Request ถึงเป็น pointer ในขณะที่ http.ResponseWriter เป็น interface?
โค้ดนี้เปิดเผยอะไร และเพราะอะไร?
import _ "net/http/pprof"

func main() {
	http.HandleFunc("/hello", helloHandler)
	http.ListenAndServe(":8080", nil)
}
handler ตัวไหนรัน GET request สำหรับ /items/special?
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", h1)
mux.HandleFunc("GET /items/special", h2)
client ตัวนี้ผิดตรงไหน และอะไรเกิดขึ้นได้บ้างจากมัน?
client := &http.Client{}
resp, err := client.Get("https://example.com/slow")
เกิดอะไรขึ้นเมื่อ request นี้เชื่อมต่อไม่สำเร็จ?
resp, err := client.Get(url)
defer resp.Body.Close()
if err != nil {
	return err
}
โค้ดนี้ลืมเช็กอะไร และทำไมมันถึงสำคัญ?
resp, err := client.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
processJSON(data)