Blog

用 net/http 编写 Go HTTP 服务器

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.Readerio.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/42DELETE /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”,带着函数名和行号。如果你在真实日志里看到这一行,就去找那条在响应体开始后还在写的错误处理路径。

一个请求的一生

请求从客户端到你的处理器,要经过好几道手,每一道只做一件事。看一个请求走完全程:

客户端 服务器 ServeMux 模式 GET /tasks GET /tasks/{id} POST /tasks GET /tasks/42 200 task 42 goroutine GET /tasks/{id} 的处理器 id := r.PathValue("id") // "42" w.Header().Set("Content-Type", "text/plain") w.WriteHeader(200) fmt.Fprintln(w, "task", id) 客户端即将发送 GET /tasks/42 服务器接受连接,为这个请求启动一个 goroutine ServeMux 比较模式,选出最具体的:GET /tasks/{id} 处理器运行,读取 r.PathValue("id"),得到 "42" 先设置头部,再 WriteHeader(200),最后写响应体 响应返回客户端,goroutine 的任务完成

一个请求从头到尾的过程。服务器给连接分配一个专属的 goroutine,ServeMux 选出匹配的模式中最具体的那个,处理器读取路径值,然后依次写头部、状态码和响应体,最后响应返回客户端。

如果动画无法播放,下面是这些步骤的文字版:

  1. 客户端发送 GET /tasks/42
  2. 服务器接受连接,启动一个新的 goroutine 来处理它。
  3. ServeMux 把请求和它的模式 GET /tasksGET /tasks/{id}POST /tasks 逐一比较,选中 GET /tasks/{id}
  4. 这个模式的处理器运行,调用 r.PathValue("id"),返回 "42"
  5. 处理器设置一个头部,调用 WriteHeader(200),然后写入响应体。
  6. 响应返回客户端,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 时至少要设置 ReadHeaderTimeouthttp.ListenAndServe 会对慢速客户端无限期地等下去。

不管 goroutine 是不是你启动的,你的处理器都在许多 goroutine 上同时运行。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。