Go 标准库自带能上生产的 HTTP 服务器。学习处理器、用 ServeMux 的方法和路径模式做路由、ResponseWriter 的规则、每个请求经历了什么,以及 http.Server 为什么要设超时。
Go 提供 HTTP 服务不需要 Web 框架。标准库里的 net/http 包自带真正的服务器、路由器和客户端,很多大型生产服务就直接跑在它上面。
本文讲处理器(handler)、用 http.ServeMux 做路由、写响应的规则、一个请求从头到尾经历了什么,以及为什么应该自己构建 http.Server,而不是调用 http.ListenAndServe。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
处理器只有一个方法
在 Go 里,任何带 ServeHTTP 方法的值都是处理器。net/http 包把它定义成一个接口:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
这和讲接口那一篇的思路一样:接口很小,满足它不需要声明。服务器对每个请求调用一次 ServeHTTP。*http.Request 装着客户端发来的内容,http.ResponseWriter 是你写回复的地方。
下面是一个用结构体实现的处理器。为了在经过验证的示例里跑一个真正的服务器,我们用 httptest.NewServer。它在你本机随机找一个空闲端口启动处理器,把地址放在 srv.URL 里,你调用 Close 时再把它关掉。客户端用 http.Get,它通过真实的连接发送真实的请求。
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
type greeter struct {
greeting string
}
func (g greeter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s, you asked for %s\n", g.greeting, r.URL.Path)
}
func main() {
var h http.Handler = greeter{greeting: "Hello"}
srv := httptest.NewServer(h)
defer srv.Close()
res, err := http.Get(srv.URL + "/tasks")
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res.Status)
fmt.Println(res.Header.Get("Content-Type"))
fmt.Print(string(body))
}
输出:
200 OK
text/plain; charset=utf-8
Hello, you asked for /tasks
greeter 从没说过自己实现了 http.Handler。它有这个方法,所以赋值能通过编译。fmt.Fprintf 能用在 w 上,是因为 ResponseWriter 同时也是 io.Writer,就是你在讲 io.Reader 和 io.Writer 时见过的那个接口。
处理器没有设置状态码,也没有设置 Content-Type,客户端却两样都收到了。你不指定时,服务器发送 200,并根据你写入的前几个字节猜测内容类型。下文会讲清楚这件事具体在什么时候发生。
http.HandlerFunc 把函数变成处理器
大多数处理器用不着结构体,所以 net/http 提供了一个适配器。http.HandlerFunc 是一个函数类型,它的 ServeHTTP 方法只是调用这个函数本身:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
把普通函数转换成这个类型,它就有了这个方法,也就成了 http.Handler。这个程序还用到了 httptest 的第二个工具。httptest.NewRecorder 是一个 ResponseWriter,它把处理器写入的内容存下来,所以你可以直接调用处理器,完全不走网络:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func health(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}
func main() {
h := http.HandlerFunc(health)
fmt.Printf("%T\n", h)
req := httptest.NewRequest("GET", "/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(rec.Code)
fmt.Print(rec.Body.String())
}
输出:
http.HandlerFunc
200
ok
http.HandlerFunc(health) 是类型转换,不是调用。在调用 ServeHTTP 之前,什么都不会执行。httptest.NewServer 测试的是经过连接的完整往返,httptest.NewRecorder 只测试处理器,速度更快。讲测试和发布 API 的那一篇会深入讲解这两者。
用 http.ServeMux 做路由
真正的服务器不止一个处理器,所以得有东西决定每个请求交给谁。在 net/http 里,这个角色是 http.ServeMux,一个本身也是处理器的路由器。你在它上面注册模式,它自己的 ServeHTTP 会选出正确的处理器并调用它。
从 Go 1.22 开始,模式除了路径,还可以指定 HTTP 方法,路径里也可以用花括号写通配符。在处理器里,r.PathValue 返回通配符匹配到的内容:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "deleted task %s\n", r.PathValue("id"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
send := func(method, path string) {
req, _ := http.NewRequest(method, srv.URL+path, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Printf("%-6s %-12s %d %q", method, path, res.StatusCode, body)
if allow := res.Header.Get("Allow"); allow != "" {
fmt.Printf(" Allow: %s", allow)
}
fmt.Println()
}
send("GET", "/tasks/42")
send("DELETE", "/tasks/7")
send("PUT", "/tasks/42")
send("GET", "/tasks")
send("GET", "/tasks/a%2Fb")
send("GET", "/users/1")
}
输出:
GET /tasks/42 200 "task 42\n"
DELETE /tasks/7 200 "deleted task 7\n"
PUT /tasks/42 405 "Method Not Allowed\n" Allow: DELETE, GET, HEAD
GET /tasks 404 "404 page not found\n"
GET /tasks/a%2Fb 200 "task a/b\n"
GET /users/1 404 "404 page not found\n"
逐行来看:
GET /tasks/42和DELETE /tasks/7各自到达自己的处理器,{id}捕获了数字。PUT /tasks/42的路径存在,但没有哪个模式允许PUT。ServeMux 自己回复了 405 Method Not Allowed,并带上Allow头,列出可用的方法。HEAD也在列表里,因为GET模式同样匹配HEAD请求。GET /tasks是 404,不是 405。{id}必须匹配一个路径段,而这里没有,所以根本没有模式匹配这个路径。/tasks/a%2Fb让我们吃了一惊。%2F是转义后的斜杠,所以通配符看到的是一个路径段。但PathValue返回的是解码后的a/b。路径值要和客户端的其他输入一样对待,用之前先检查。- 没有任何模式认识的路径得到 404,响应体是
404 page not found。
模式里的方法写在路径前面,中间隔一个空格。不写方法的模式匹配所有方法。Go 1.22 之前这些都没有,大家要么手动检查 r.Method,要么找第三方路由器。现在大多数 API 已经用不着了。
用十岁孩子能懂的话说
想象邮局的分拣室。一整面墙的格子,每个格子上方写着一个地址:”主街 12 号””主街,任意门牌””城里任何地方”。一封信进来,分拣员读它的地址,把它投进一个格子。投递员把格子里的信全部取走,送出去。
如果一个地址能放进好几个格子,分拣员会选最精确的那个。寄往主街 12 号的信放进”主街 12 号”格子,虽然”主街,任意门牌”也能收。如果没有格子合适,信会盖上”地址不详”退回去。
准确的说法
ServeMux 保存一组模式,每个模式对应一个处理器。对每个请求,它找出所有与方法和路径匹配的模式。如果匹配了好几个,最具体的那个胜出。如果一个模式匹配的请求是另一个模式所匹配请求的真子集,它就比另一个更具体。注册顺序无关紧要。
如果没有模式匹配路径,ServeMux 调用内置的 not-found 处理器,写出 404。如果路径匹配但方法不匹配,它写出 405,并带上 Allow 头。
这个比喻的局限: 邮局只按地址分拣。ServeMux 还读方法,相当于既按地址,又按信封上写的是”投递””揽收”还是”取消”来分拣。通配符也比格子做得多:它会抄下地址的一部分,比如 42,交给投递员。还有,两个格子一样精确时,邮局会凑合着处理,ServeMux 则直接拒绝启动,下一节会看到。
通配符:{name...} 和 {$}
像 {id} 这样的普通通配符正好匹配一个路径段。另外两种特殊形式处理其他情况。模式末尾的 {path...} 匹配剩下的所有路径段,斜杠也包括在内。{$} 只匹配路径的结尾,这样注册根路径时就不会把所有请求都吞掉:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "home page")
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "file %q\n", r.PathValue("path"))
})
for _, path := range []string{"/", "/about", "/files/notes/2026/todo.txt", "/files/"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("%-27s %d %s", path, rec.Code, rec.Body.String())
}
}
输出:
/ 200 home page
/about 404 404 page not found
/files/notes/2026/todo.txt 200 file "notes/2026/todo.txt"
/files/ 200 file ""
没有 {$} 的话,模式 GET / 以斜杠结尾,而以斜杠结尾的模式会匹配它下面的所有路径。/about 就会拿到首页,而不是 404。{path...} 通配符还可以什么都不匹配,所以 /files/ 到达处理器时拿到的是空字符串。
最具体的模式胜出
一个请求匹配多个模式时,ServeMux 不会选最先注册的那个,而是选能匹配的请求最少的那个。这个程序注册了四个互相重叠的模式,还故意按”错误”的顺序注册:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func reply(text string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, text)
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/tasks/{id}", reply(`"/tasks/{id}"`))
mux.HandleFunc("GET /tasks/{id}", reply(`"GET /tasks/{id}"`))
mux.HandleFunc("GET /tasks/new", reply(`"GET /tasks/new"`))
mux.HandleFunc("/", reply(`"/"`))
for _, t := range []struct{ method, path string }{
{"GET", "/tasks/new"},
{"GET", "/tasks/42"},
{"PUT", "/tasks/42"},
{"GET", "/tasks/42/notes"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(t.method, t.path, nil))
fmt.Printf("%-4s %-16s -> %s", t.method, t.path, rec.Body.String())
}
}
输出:
GET /tasks/new -> "GET /tasks/new"
GET /tasks/42 -> "GET /tasks/{id}"
PUT /tasks/42 -> "/tasks/{id}"
GET /tasks/42/notes -> "/"
字面量路径段 new 胜过通配符 {id}。带方法的模式胜过同样路径但不带方法的模式,所以 GET 交给指定了方法的处理器,PUT 退回到接受任意方法的那个。/ 以斜杠结尾,匹配所有路径,/tasks/42/notes 没有别的模式能匹配,就落到了这里。这也是这个程序里没有 405 的原因:兜底模式总能匹配上。
有时两个模式谁也不比谁更具体。GET /{kind}/42 和 /tasks/{id} 都匹配 /tasks/42,但各自也匹配对方不匹配的路径。同时注册这两个,HandleFunc 会在启动时 panic。报错信息会列出两个模式和各自注册所在的行,然后解释:
GET /{kind}/42 and /tasks/{id} both match some paths, like "/tasks/42".
But neither is more specific than the other.
启动时 panic 是有益的那种。你第一次运行服务器就会发现歧义,而不是等到某个倒霉的请求到来时。
写响应:头部、状态码、响应体
HTTP 响应按固定顺序发出:先是状态行,然后是头部,最后是响应体。ResponseWriter 强制你遵守这个顺序,因为某一部分一旦发出,就不能再改。三个调用分别对应这三部分:
w.Header()返回头部 map。先修改它。w.WriteHeader(code)发送状态行和头部。w.Write发送响应体的字节。如果还没调用过WriteHeader,第一次Write会替你调用WriteHeader(200)。
下面这个处理器按正确的顺序来做:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func create(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Location", "/tasks/43")
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "created task 43")
}
func main() {
rec := httptest.NewRecorder()
create(rec, httptest.NewRequest("POST", "/tasks", nil))
res := rec.Result()
fmt.Println(res.Status)
fmt.Println("Location:", res.Header.Get("Location"))
fmt.Print(rec.Body.String())
}
输出:
201 Created
Location: /tasks/43
created task 43
用 rec.Result() 读取客户端实际会看到的内容。rec.Header() 是处理器正在用的那个 map,设置得太晚、没能发出去的头部也会显示在里面。
再看错误的顺序。这个处理器先写响应体,然后才尝试设置头部和 404。为了看到服务器的抱怨,程序用 httptest.NewUnstartedServer 构建测试服务器,把它的 ErrorLog 指向 stdout 并去掉时间戳,然后才启动它:
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
)
func lateHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "task 42")
w.Header().Set("X-Task-Id", "42")
w.WriteHeader(http.StatusNotFound)
}
func main() {
srv := httptest.NewUnstartedServer(http.HandlerFunc(lateHandler))
// Send the server's error log to stdout, with no timestamp, so we can see it.
srv.Config.ErrorLog = log.New(os.Stdout, "server log: ", 0)
srv.Start()
defer srv.Close()
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println("status:", res.StatusCode)
fmt.Printf("X-Task-Id: %q\n", res.Header.Get("X-Task-Id"))
fmt.Print("body: ", string(body))
}
输出:
server log: http: superfluous response.WriteHeader call from main.lateHandler (main.go:15)
status: 200
X-Task-Id: ""
body: task 42
客户端收到的是 200,不是 404,也没有 X-Task-Id 头。第一次 Fprintln 就把状态码定成了 200,并冻结了头部。后面的 Header().Set 改的是一个再也没人读的 map,而且悄无声息。后面的 WriteHeader 什么也没做,只在服务器日志里留下一行 “superfluous”,带着函数名和行号。如果你在真实日志里看到这一行,就去找那条在响应体开始后还在写的错误处理路径。
一个请求的一生
请求从客户端到你的处理器,要经过好几道手,每一道只做一件事。看一个请求走完全程:
一个请求从头到尾的过程。服务器给连接分配一个专属的 goroutine,ServeMux 选出匹配的模式中最具体的那个,处理器读取路径值,然后依次写头部、状态码和响应体,最后响应返回客户端。
如果动画无法播放,下面是这些步骤的文字版:
- 客户端发送
GET /tasks/42。 - 服务器接受连接,启动一个新的 goroutine 来处理它。
- ServeMux 把请求和它的模式
GET /tasks、GET /tasks/{id}、POST /tasks逐一比较,选中GET /tasks/{id}。 - 这个模式的处理器运行,调用
r.PathValue("id"),返回"42"。 - 处理器设置一个头部,调用
WriteHeader(200),然后写入响应体。 - 响应返回客户端,goroutine 再没有别的事可做。
第 2 步比看上去更重要。服务器在循环里执行 Accept,每来一个新连接就启动一个 goroutine,就像讲 goroutine 那一篇里那样。同一个连接上到达的请求在这个 goroutine 里轮流处理,而在 HTTP/2 下,每个请求都有自己的 goroutine。无论哪种情况,一百个客户端就意味着一百个 goroutine 同时运行你的处理器,而你一次 go 都没写过。
这个程序证明两个处理器确实在同时运行。/wait 会一直阻塞,直到某个通道被关闭,而只有 /release 会关闭它:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func get(url string) string {
res, err := http.Get(url)
if err != nil {
return "error: " + err.Error()
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return string(body)
}
func main() {
release := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("GET /wait", func(w http.ResponseWriter, r *http.Request) {
<-release // blocks until another request closes the channel
fmt.Fprint(w, "wait: released")
})
mux.HandleFunc("GET /release", func(w http.ResponseWriter, r *http.Request) {
close(release)
fmt.Fprint(w, "release: done")
})
srv := httptest.NewServer(mux)
defer srv.Close()
waitResult := make(chan string)
go func() { waitResult <- get(srv.URL + "/wait") }()
fmt.Println(get(srv.URL + "/release"))
fmt.Println(<-waitResult)
}
输出:
release: done
wait: released
如果服务器一次只处理一个请求,/wait 会永远占着唯一的工作者,/release 永远轮不到运行,程序就会卡住。它正常结束了,说明两个处理器是同时在运行的。
处理器里的共享状态需要加锁
同时运行的处理器访问同一个变量,就会产生数据竞争,和讲 sync 那一篇里的 goroutine 完全一样。这里的竞争很容易被忽略,因为你的代码里没有任何地方启动 goroutine。这个处理器不加锁地统计访问次数,同时有 50 个请求到达:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
)
type visits struct {
count int
}
func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
v.count++ // no lock: every request runs in its own goroutine
fmt.Fprint(w, v.count)
}
func main() {
srv := httptest.NewServer(&visits{})
defer srv.Close()
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
})
}
wg.Wait()
fmt.Println("done")
}
用 go run -race . 运行,会输出类似下面的报告(... 代表那些含地址、文件路径和 goroutine 编号、每次运行都不同的行):
WARNING: DATA RACE
...
main.(*visits).ServeHTTP()
...
net/http.(*conn).serve()
...
done
exit status 66
看报告里的调用栈。你的 ServeHTTP 下面是 net/http.(*conn).serve(),也就是服务器为每个连接启动的 goroutine。竞态检测器发现有两个这样的 goroutine 在写 count,中间没有任何东西把它们隔开。
修复方法和讲 sync 那一篇里的一样:在结构体里放一个互斥锁,并使用指针接收者,让所有请求共用同一把锁:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
)
type visits struct {
mu sync.Mutex
count int
}
func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
v.mu.Lock()
v.count++
n := v.count
v.mu.Unlock()
fmt.Fprint(w, n)
}
func main() {
v := &visits{}
srv := httptest.NewServer(v)
defer srv.Close()
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
})
}
wg.Wait()
v.mu.Lock()
fmt.Println("visits:", v.count)
v.mu.Unlock()
}
输出:
visits: 50
处理器在持有锁的时候把计数复制到 n,然后先解锁,再写响应。给一个慢速客户端写数据可能要很久,其他人不该陪着等。处理器在自身请求之外读写的任何东西,比如任务 map、缓存或计数器,都需要同样小心。
客户端离开时,r.Context() 就结束了
每个请求都带着一个 context,客户端断开连接时,服务器会取消它。这就是讲并发模式那一篇里的 context,已经替你接好了。做慢活的处理器应该盯着 r.Context().Done(),一有信号就停下,因为已经没人在等结果了。
在这个程序里,处理器一开始运行,客户端就放弃了。处理器的工作要 10 秒,留足了余量,所以结果总是一样的:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
started := make(chan struct{})
outcome := make(chan string)
slow := func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-time.After(10 * time.Second):
fmt.Fprintln(w, "report ready")
outcome <- "handler: finished the work"
case <-r.Context().Done():
outcome <- "handler: stopped early: " + r.Context().Err().Error()
}
}
srv := httptest.NewServer(http.HandlerFunc(slow))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-started
cancel() // the client gives up once the handler is running
}()
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
_, err := http.DefaultClient.Do(req)
fmt.Println("client: canceled:", errors.Is(err, context.Canceled))
fmt.Println(<-outcome)
}
输出:
client: canceled: true
handler: stopped early: context canceled
取消客户端的 context 关闭了连接。服务器察觉到后取消了 r.Context(),处理器的 select 立刻走了 Done 分支,而不是等满 10 秒。在真实的处理器里,你会把 r.Context() 传给数据库查询或对外的调用,它们也会跟着停下。ServeHTTP 返回时这个 context 同样会结束,所以不要把它留给应该比请求活得更久的工作。
构建 http.Server,不要调用 http.ListenAndServe
几乎每个教程里的第一个 Go 服务器都是 http.ListenAndServe(":8080", mux)。它能用,但它构建的 http.Server 所有字段都是零值,而对超时来说,零意味着”永远等下去”。
这在互联网上是个实实在在的问题。客户端可以打开一个连接,每隔几秒才发送一个字节的请求头。没有 ReadHeaderTimeout,服务器就耐心地等着,占着一个 goroutine 和一个打开的连接。这样的客户端一多,服务器能维持的连接数就耗尽了,而它连一个完整的请求都没见到。这种攻击老到有了名字,叫 Slowloris,攻击者几乎不用付出任何代价。
解决办法是自己构建服务器,并设置超时:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
})
srv := &http.Server{
Addr: "localhost:8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on", srv.Addr)
log.Fatal(srv.ListenAndServe())
}
我们的检查器不运行这个程序,因为它会一直监听固定端口,直到你停止它。你自己运行它,然后在第二个终端里执行:
curl localhost:8080/tasks/42
它会输出 task 42。下面是每个超时限制的内容:
ReadHeaderTimeout:客户端发送请求头的时间上限。挡住 Slowloris 靠的就是它,永远不要漏掉。ReadTimeout:读取整个请求(包括请求体)的时间上限。WriteTimeout:读完请求头之后,服务器写出响应的时间上限。IdleTimeout:保持连接(keep-alive)在两次请求之间可以空闲多久。
上面的数字是合理的起点,不是规定。接受大文件上传或长时间流式响应的服务器需要不同的值。ListenAndServe 还会一直阻塞到服务器出错,然后 log.Fatal 直接退出,不给正在处理的请求留完成的机会。生产环境的超时怎么选、怎么优雅关闭,都在讲测试和发布 API 的那一篇里。
要点
http.Handler是任何带有ServeHTTP(http.ResponseWriter, *http.Request)方法的类型。http.HandlerFunc能把普通函数变成处理器。- 从 Go 1.22 开始,ServeMux 的模式可以带方法和通配符:
"GET /tasks/{id}",用r.PathValue("id")读取。{rest...}匹配路径的剩余部分,{$}只匹配结尾。 - 最具体的模式胜出,与注册顺序无关。有歧义的模式在注册时 panic。路径已知但方法不对,得到 405 和
Allow头;路径未知,得到 404。 - 先设置头部,再调用
WriteHeader,最后写响应体。第一次Write会发送 200 并冻结头部,之后的修改都被忽略。 - 每个请求在自己的 goroutine 里运行,所以处理器里的共享状态需要互斥锁,
-race会找出没加锁的地方。 - 客户端离开时,
r.Context()会被取消。把它传给耗时的工作。 - 构建
http.Server时至少要设置ReadHeaderTimeout。http.ListenAndServe会对慢速客户端无限期地等下去。
不管 goroutine 是不是你启动的,你的处理器都在许多 goroutine 上同时运行。