用 httptest、假存储、日志断言、竞态检测器和模糊测试来测 Go REST API,再靠服务器超时、优雅关闭和一个静态二进制文件把它发布出去。
手动发几个 curl 请求能通过的 API,还不算完成。你需要测试来检查每一个状态码和每一条错误路径,还需要一个这样的服务器:扛得住慢客户端,停下时不丢请求,发布出来是一个复制到机器上就能跑的东西。
本文为 JSON REST API 那一部分搭建的任务列表 API 把这两件事都做了。先给那个模块加上真正的测试文件,再讲超时、优雅关闭和构建单个二进制文件。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
被测的 API
任务 API 是一个 Go 模块,里面有负责存储的 task 包、负责 HTTP 的 api 包,以及运行服务器的 tasksd 命令。它的五个路由返回从 200 到 500 的状态码,每个错误都是同一种结构的 JSON。本文给这个模块加了四个测试文件:
19-tasks-api/
├── go.mod
├── cmd/
│ └── tasksd/
│ ├── main.go
│ └── main_test.go
└── internal/
├── task/
│ ├── task.go
│ └── memstore.go
└── api/
├── api.go
├── api_test.go
├── handlers.go
├── json.go
├── json_test.go
├── validate.go
├── middleware.go
└── middleware_test.go
每个测试文件都和旁边的代码用同一个包名,所以测试能访问 decodeJSON 这样的未导出名字。包和测试那一部分讲过 go test、表格驱动测试和 t.Helper,本文直接使用,不再重复讲解。
httptest.NewRecorder 还是 httptest.NewServer
net/http/httptest 包提供了两种测试处理函数的方式,它们测的东西不一样。httptest.NewRecorder 返回一个 ResponseWriter,它会存下状态码、头部和响应体。你自己调用 ServeHTTP,不经过网络。httptest.NewServer 在一个空闲的回环端口上启动真实的服务器,你用真实的客户端和它通信。
API 的大部分测试用的是记录器,通过两个小辅助函数:
// newTestAPI returns the API over a store that already holds one task,
// "Buy milk", with ID 1. Its logs are thrown away.
func newTestAPI(t *testing.T) http.Handler {
t.Helper()
store := task.NewMemStore()
if _, err := store.Create(t.Context(), task.Task{Title: "Buy milk"}); err != nil {
t.Fatalf("seeding the store: %v", err)
}
return New(store, slog.New(slog.DiscardHandler))
}
// do sends one request straight to h, with no network, and returns
// the recorded response. A non-empty body is sent as JSON.
func do(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
newTestAPI 构建整个 API,包括中间件,底下的存储里已经有一个任务。slog.DiscardHandler 把日志丢掉。t.Context() 返回一个 context,它在测试的清理函数运行之前被取消。这样测试里的存储调用拿到的是它,就像处理函数里的存储调用拿到的是 r.Context()。这两者都是 Go 1.24 加入的。
用记录器测试处理函数做出的决定:状态码、头部、响应体。它很快,失败时直接指向你的代码。当网络本身是测试的一部分时,就用真实服务器:客户端、连接处理、超时或关闭。
一张表覆盖所有端点
表格驱动测试很适合 API,因为每一行就是一个请求加上你期望的响应。下面这个测试覆盖了每个路由,包括错误情况:
func TestEndpoints(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantHeader map[string]string
wantBody string
}{
{"list", "GET", "/tasks", "", 200, nil,
`[{"id":1,"title":"Buy milk","done":false}]`},
{"get", "GET", "/tasks/1", "", 200, nil,
`{"id":1,"title":"Buy milk","done":false}`},
{"get missing", "GET", "/tasks/99", "", 404, nil,
`{"error":"task not found"}`},
{"get bad id", "GET", "/tasks/abc", "", 404, nil,
`{"error":"task not found"}`},
{"create", "POST", "/tasks", `{"title":"Walk the dog"}`, 201,
map[string]string{"Location": "/tasks/2"},
`{"id":2,"title":"Walk the dog","done":false}`},
{"create blank title", "POST", "/tasks", `{"title":" "}`, 422, nil,
`{"error":"validation failed","fields":{"title":"must not be empty"}}`},
{"create unknown field", "POST", "/tasks", `{"title":"a","id":7}`, 400, nil,
`{"error":"unknown field \"id\""}`},
{"create too big", "POST", "/tasks", `{"title":"` + strings.Repeat("a", maxBodyBytes) + `"}`, 413, nil,
`{"error":"request body must not be larger than 1048576 bytes"}`},
{"update", "PUT", "/tasks/1", `{"title":"Buy milk","done":true}`, 200, nil,
`{"id":1,"title":"Buy milk","done":true}`},
{"update missing", "PUT", "/tasks/99", `{"title":"x"}`, 404, nil,
`{"error":"task not found"}`},
{"delete", "DELETE", "/tasks/1", "", 204, nil, ``},
{"delete missing", "DELETE", "/tasks/99", "", 404, nil,
`{"error":"task not found"}`},
{"wrong method", "PATCH", "/tasks/1", "", 405,
map[string]string{"Allow": "DELETE, GET, HEAD, PUT"},
`{"error":"method not allowed"}`},
{"unknown path", "GET", "/users", "", 404, nil,
`{"error":"not found"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := do(newTestAPI(t), tt.method, tt.path, tt.body)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
for name, want := range tt.wantHeader {
if got := rec.Header().Get(name); got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
if got := strings.TrimSuffix(rec.Body.String(), "\n"); got != tt.wantBody {
t.Errorf("body = %s, want %s", got, tt.wantBody)
}
if rec.Code != http.StatusNoContent {
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
})
}
}
每一行都用一个全新的 API,所以 delete 那一行不会弄坏 get 那一行。比较整个响应体,也顺带检查了错误的结构:fields 只出现在 422 里,而 405 是带 Allow 头部的 JSON。
加上 -v,每一行都显示为一个有名字的子测试。我删掉了 --- PASS 行里的耗时,因为每次运行都不一样:
$ go test -v -run TestEndpoints ./internal/api
=== RUN TestEndpoints
=== RUN TestEndpoints/list
=== RUN TestEndpoints/get
=== RUN TestEndpoints/get_missing
=== RUN TestEndpoints/get_bad_id
=== RUN TestEndpoints/create
=== RUN TestEndpoints/create_blank_title
=== RUN TestEndpoints/create_unknown_field
=== RUN TestEndpoints/create_too_big
=== RUN TestEndpoints/update
=== RUN TestEndpoints/update_missing
=== RUN TestEndpoints/delete
=== RUN TestEndpoints/delete_missing
=== RUN TestEndpoints/wrong_method
=== RUN TestEndpoints/unknown_path
--- PASS: TestEndpoints
--- PASS: TestEndpoints/list
--- PASS: TestEndpoints/get
--- PASS: TestEndpoints/get_missing
--- PASS: TestEndpoints/get_bad_id
--- PASS: TestEndpoints/create
--- PASS: TestEndpoints/create_blank_title
--- PASS: TestEndpoints/create_unknown_field
--- PASS: TestEndpoints/create_too_big
--- PASS: TestEndpoints/update
--- PASS: TestEndpoints/update_missing
--- PASS: TestEndpoints/delete
--- PASS: TestEndpoints/delete_missing
--- PASS: TestEndpoints/wrong_method
--- PASS: TestEndpoints/unknown_path
PASS
后面还有一行 ok,耗时每次不同。415 的情况单独写成一个小测试,因为它需要不同的 Content-Type。
用 t.Cleanup 和 t.Context 跑真实服务器
API 包里有一个测试走真实连接,检查客户端能不能顺着创建请求返回的 Location 头部找到新任务。两个辅助函数负责准备:
// startServer runs h on a real loopback port until the test ends.
func startServer(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
// send makes a real HTTP request and returns the response with its body read.
func send(t *testing.T, method, url, body string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("building request: %v", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
return resp, strings.TrimSuffix(string(b), "\n")
}
startServer 不能用 defer srv.Close(),因为那样辅助函数一返回,服务器就关了。t.Cleanup(srv.Close) 则是在测试结束时才运行它。send 用 t.Context() 构建每个请求,所以测试结束时还在等待的请求会被取消。
func TestCreateThenFollowLocation(t *testing.T) {
srv := startServer(t, New(task.NewMemStore(), slog.New(slog.DiscardHandler)))
if _, body := send(t, "GET", srv.URL+"/tasks", ""); body != "[]" {
t.Fatalf("empty list = %s, want []", body)
}
resp, _ := send(t, "POST", srv.URL+"/tasks", `{"title":"Buy milk"}`)
loc := resp.Header.Get("Location")
if resp.StatusCode != http.StatusCreated || loc == "" {
t.Fatalf("create: status %d, Location %q", resp.StatusCode, loc)
}
resp, body := send(t, "GET", srv.URL+loc, "")
if resp.StatusCode != http.StatusOK || body != `{"id":1,"title":"Buy milk","done":false}` {
t.Errorf("GET %s = %d %s", loc, resp.StatusCode, body)
}
}
第一个请求检查空列表是 [],而不是 null。后面的部分从响应里读出新任务的 URL,就像客户端会做的那样。
用假存储测 500 路径
500 路径最难测,因为 MemStore 从来不会失败。不过 Store 是一个接口,所以测试可以把任何带这五个方法的东西传给 New。下面这个假存储按测试指定的方式失败:
// fakeStore is a Store whose every method calls fail. The test decides
// what fail does: return an error, or panic.
type fakeStore struct {
fail func() error
}
func (f fakeStore) List(context.Context) ([]task.Task, error) { return nil, f.fail() }
func (f fakeStore) Get(context.Context, int64) (task.Task, error) { return task.Task{}, f.fail() }
func (f fakeStore) Create(context.Context, task.Task) (task.Task, error) {
return task.Task{}, f.fail()
}
func (f fakeStore) Update(context.Context, task.Task) (task.Task, error) {
return task.Task{}, f.fail()
}
func (f fakeStore) Delete(context.Context, int64) error { return f.fail() }
一个测试让 fail 返回错误,另一个让它 panic。处理函数分不清这是假存储还是真存储,这正是把存储放在接口后面的意义。
500 有两半都很重要。客户端对原因必须一无所知,而日志必须把原因完整记下来。为了检查日志,测试给 API 一个写入 bytes.Buffer 的 logger:
// newTestLogger returns a logger that writes JSON lines into buf. It drops
// the attributes that change on every run: time, duration and stack.
func newTestLogger(buf *bytes.Buffer) *slog.Logger {
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.TimeKey, "duration", "stack":
return slog.Attr{}
}
return a
},
}
return slog.New(slog.NewJSONHandler(buf, opts))
}
时间、请求耗时和 panic 的栈追踪每次运行都不一样,所以 ReplaceAttr 把它们去掉,剩下的就能精确比较。下面是这个测试和它的辅助函数:
// assertLog fails the test unless buf holds exactly the want lines.
func assertLog(t *testing.T, buf *bytes.Buffer, want ...string) {
t.Helper()
got := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
if len(got) != len(want) {
t.Fatalf("got %d log lines, want %d:\n%s", len(got), len(want), buf)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("log line %d:\n got %s\nwant %s", i+1, got[i], want[i])
}
}
}
func TestStoreErrorIs500(t *testing.T) {
var logs bytes.Buffer
store := fakeStore{fail: func() error {
return errors.New("dial tcp db.internal:5432: connection refused")
}}
h := New(store, newTestLogger(&logs))
rec := do(h, "GET", "/tasks/1", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if body := rec.Body.String(); body != `{"error":"internal server error"}`+"\n" {
t.Errorf("body = %q leaks more than it should", body)
}
assertLog(t, &logs,
`{"level":"ERROR","msg":"internal error","err":"dial tcp db.internal:5432: connection refused","request_id":"req-1"}`,
`{"level":"INFO","msg":"request","method":"GET","path":"/tasks/1","status":500,"request_id":"req-1"}`,
)
}
这个错误里有数据库的主机名和端口,攻击者很想看到这些。响应体只有 {"error":"internal server error"}。日志里先是真实的错误,然后是状态码为 500 的请求行,两行都带着 req-1,顺着它就能从用户的 bug 报告找到原因。
测试 panic 恢复和请求 ID
panic 恢复同样用 fakeStore 来测,这次 fail 会 panic:
func TestPanicIs500(t *testing.T) {
var logs bytes.Buffer
store := fakeStore{fail: func() error { panic("store exploded") }}
h := New(store, newTestLogger(&logs))
rec := do(h, "DELETE", "/tasks/1", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "exploded") {
t.Errorf("body = %q leaks the panic value", body)
}
if id := rec.Header().Get("X-Request-Id"); id != "req-1" {
t.Errorf("X-Request-Id = %q, want req-1", id)
}
assertLog(t, &logs,
`{"level":"ERROR","msg":"panic","value":"store exploded","request_id":"req-1"}`,
`{"level":"INFO","msg":"request","method":"DELETE","path":"/tasks/1","status":500,"request_id":"req-1"}`,
)
}
panic 从存储内部开始,经过 New 里真实的中间件链。如果有人调整了 New 的顺序,让 logRequests 位于 recoverPanics 里面,请求行就会从日志里消失,这个测试就会失败。
状态码已经发出之后再 panic,就是另一回事了:
func TestPanicAfterWriteHeader(t *testing.T) {
var logs bytes.Buffer
s := &server{logger: newTestLogger(&logs)}
h := s.recoverPanics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "[")
panic("encoder broke halfway")
}))
rec := do(h, "GET", "/tasks", "")
// The 200 is already on its way, so the recovery can't turn it into a 500.
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if want := `[{"error":"internal server error"}` + "\n"; rec.Body.String() != want {
t.Errorf("body = %q, want %q", rec.Body.String(), want)
}
}
这个测试直接调用 recoverPanics,传入的处理函数先发出 200 和一个字节,然后 panic。恢复逻辑照样调用 writeError,但 WriteHeader(500) 来得太晚,状态码仍然是 200。错误 JSON 被追加到已经发出的内容后面,客户端收到的是 [{"error":"internal server error"},这不是合法的 JSON。这就是 writeJSON 要先把整个响应体编码好、再写状态码的原因。这个测试把这种行为固定下来,免得有人以为恢复逻辑能处理这种情况。
同一个文件里的 TestRequestIDs 发送三个请求,检查 req-1、req-2 和 req-3,再检查第二个 New 会从 req-1 重新开始。
用竞态检测器测并发请求
竞态检测器在测试运行时监视内存,一旦两个 goroutine 不加锁地访问同一个变量,就报告出来。它只能看到真正同时运行的代码,所以测试必须让这种情况发生:
func TestConcurrentCreates(t *testing.T) {
h := New(task.NewMemStore(), slog.New(slog.DiscardHandler))
const n = 50
var wg sync.WaitGroup
for range n {
wg.Go(func() {
rec := do(h, "POST", "/tasks", `{"title":"Buy milk"}`)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want 201", rec.Code)
}
})
}
wg.Wait()
var tasks []task.Task
rec := do(h, "GET", "/tasks", "")
if err := json.NewDecoder(rec.Body).Decode(&tasks); err != nil {
t.Fatalf("decoding list: %v", err)
}
if len(tasks) != n {
t.Fatalf("got %d tasks, want %d", len(tasks), n)
}
for i, tk := range tasks {
if tk.ID != int64(i+1) {
t.Errorf("tasks[%d].ID = %d, want %d", i, tk.ID, i+1)
}
}
}
五十个 goroutine 同时向一个处理函数发送 POST /tasks,用的是 Go 1.25 的 sync.WaitGroup.Go。然后测试检查任务是否正好五十个,ID 从 1 到 50。在这些 goroutine 里调用 t.Errorf 是安全的,但 t.Fatalf 必须在测试自己的 goroutine 上运行。
为了看看这个测试是否管用,我删掉了 MemStore.Create 里给互斥锁加锁的两行,然后加上 -race 运行。下面是报告中每次运行都不变的那些行,--- FAIL 行里的耗时已删掉:
$ go test -race -run TestConcurrentCreates ./internal/api
==================
WARNING: DATA RACE
...
example.com/tasks/internal/task.(*MemStore).Create()
...
--- FAIL: TestConcurrentCreates
testing.go:1712: race detected during execution of test
FAIL
20 次运行里,竞态检测器 20 次都报了出来。不加 -race 时,19 次运行崩溃,报 fatal error: concurrent map writes,有一次通过了。正是这一次通过,说明每次发布之前都该跑 go test -race ./...:今天没崩溃的竞态,依然是 bug。
对解码器做模糊测试
模糊测试给函数喂成千上万个自动生成的输入,检查一个对所有输入都必须成立的性质。decodeJSON 读取的是陌生人发来的字节,所以它是天然的测试对象:
// FuzzDecodeJSON feeds decodeJSON random bodies. Whatever arrives, it must
// not panic, and every error must be a *requestError with a 4xx status.
func FuzzDecodeJSON(f *testing.F) {
f.Add(`{"title":"Buy milk","done":true}`)
f.Add(`{"title":`)
f.Add(`["Buy milk"]`)
f.Add(`{"title":"a"}{"title":"b"}`)
f.Fuzz(func(t *testing.T, body string) {
req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var in taskInput
err := decodeJSON(httptest.NewRecorder(), req, &in)
if err == nil {
return
}
var re *requestError
if !errors.As(err, &re) {
t.Fatalf("decodeJSON(%q) returned %T, want *requestError", body, err)
}
if re.status != http.StatusBadRequest && re.status != http.StatusRequestEntityTooLarge {
t.Fatalf("decodeJSON(%q) status = %d, want 400 or 413", body, re.status)
}
})
}
不管请求体是什么,decodeJSON 都不能 panic,而且任何错误都必须是状态码为 400 或 413 的 *requestError。f.Add 调用提供种子,供模糊测试器变异。普通的 go test 只运行这些种子。要生成新的输入,传入 -fuzz 并加上时间限制:
$ go test -fuzz=FuzzDecodeJSON -fuzztime=10s ./internal/api
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
...
PASS
中间的进度行取决于你的机器。失败的输入会保存到 testdata/fuzz/FuzzDecodeJSON/ 下,此后普通的 go test 会把它当作种子来运行。-fuzz 一次只接受一个包,所以 ./... 会被拒绝。
每个服务器超时防的是什么
没有设置超时的 http.Server 会永远等待慢客户端,而每个等待中的客户端都占着一个连接和一个 goroutine。cmd/tasksd 里的服务器设置了四个超时:
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
说白了,每个超时限制的是一种不同的等待:
ReadHeaderTimeout对付发送请求头非常慢、或者永远发不完的客户端。这就是 Slowloris 攻击:打开几千个连接,每个连接隔一会儿发几个字节,服务器还没见到一个完整的请求,连接就耗尽了。ReadTimeout限制读取整个请求(包括请求体)的时间,这样客户端也没法无休止地一点点发送请求体。WriteTimeout限制响应可以花多长时间,从请求头读完时开始计算。它对付读取响应太慢的客户端,也给整个请求设了一个上限。IdleTimeout关闭在两次请求之间一直闲置的长连接。
下面的程序发送半个请求头,然后等待。一次针对没有 ReadHeaderTimeout 的服务器,一次针对设为 50ms 的服务器。客户端等 2 秒,留足了余量:
package main
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"time"
)
// slowClient connects, sends half a request header, and then waits up to
// 2 seconds for the server to do anything.
func slowClient(timeout time.Duration) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
srv := httptest.NewUnstartedServer(h)
srv.Config.ReadHeaderTimeout = timeout
srv.Start()
defer srv.Close()
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: example.com\r\n") // no blank line: the header never ends
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = conn.Read(make([]byte, 1))
switch {
case errors.Is(err, os.ErrDeadlineExceeded):
fmt.Printf("ReadHeaderTimeout %v: after 2s the server is still holding the connection open\n", timeout)
default:
fmt.Printf("ReadHeaderTimeout %v: the server hung up: %v\n", timeout, err)
}
}
func main() {
slowClient(0)
slowClient(50 * time.Millisecond)
}
输出:
ReadHeaderTimeout 0s: after 2s the server is still holding the connection open
ReadHeaderTimeout 50ms: the server hung up: EOF
有了超时,服务器什么都没发就关闭了连接,连 408 都没有,然后收回了这个连接。
WriteTimeout 让我意外。我以为它能终止一个慢的处理函数,结果并不能:
package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
handlerDone := make(chan string, 1)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond) // slow work, 10 times the WriteTimeout
_, err := fmt.Fprintln(w, "report ready")
handlerDone <- fmt.Sprintf("handler: finished, write error = %v", err)
})
srv := httptest.NewUnstartedServer(h)
srv.Config.WriteTimeout = 50 * time.Millisecond
srv.Start()
defer srv.Close()
_, err := http.Get(srv.URL)
fmt.Println("client got EOF:", errors.Is(err, io.EOF))
fmt.Println(<-handlerDone)
}
输出:
client got EOF: true
handler: finished, write error = <nil>
处理函数完整地跑了 500ms,它的写入也没报错,因为这些字节只是进了服务器的缓冲区。超时是在服务器试图发出它们时才触发的,客户端收到的是一个被关闭的连接,而不是响应。WriteTimeout 保护的是连接,不是处理函数的时间。要放弃慢的工作,就把 r.Context() 传进去,或者用 http.TimeoutHandler 包装处理函数,它会在超时时返回 503。
优雅关闭
直接杀掉进程来停止服务器,会把所有请求拦腰切断。优雅关闭则是停止接受新连接,让正在处理的请求跑完,然后才退出。下面是 cmd/tasksd 里 serve 的后半部分:
errc := make(chan error, 1)
go func() {
logger.Info("listening", "addr", ln.Addr().String())
errc <- srv.Serve(ln)
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
整个过程分五步:
srv.Serve(ln)会一直阻塞到服务器停止,所以它在一个 goroutine 里运行。errc能放下一个值,因此这个 goroutine 总能发出结果并退出。select等待先发生的那件事:要么Serve出错,要么ctx被取消。按下 Ctrl+C 或收到SIGTERM时,run里的signal.NotifyContext会取消它。srv.Shutdown关闭监听器,于是新连接被拒绝;再关闭空闲连接,并等待活跃连接处理完手上的请求。- 这次等待用
context.Background()新建一个 10 秒的 context,因为ctx已经被取消,用它的话等待会立刻结束。如果到了截止时间还有请求在跑,Shutdown返回context.DeadlineExceeded。 Shutdown之后,Serve返回http.ErrServerClosed。这是正常的停止方式,所以serve遇到它时返回 nil。
这 10 秒要短于你的平台从发送 SIGTERM 到杀掉进程之间的等待时间。Kubernetes 默认等 30 秒,但 docker stop 只等 10 秒。
用十岁孩子能懂的话说
想象一家商店到了关门的时候。店主不会把所有人都推到街上。她先锁上前门,这样就没有新客人能进来。只是随便逛逛、不买东西的人,会被请出去。已经站在收银台前的顾客,可以把钱付完。
最后一位顾客付完钱,她就关灯回家。如果十分钟后还有人在数硬币,她也照样关门。
准确的说法
Shutdown 关闭注册在服务器上的所有监听器,于是操作系统会拒绝到那个端口的新连接。接着它关闭空闲的连接,也就是处在两次请求之间的连接,或者刚建立、几秒内还没开始请求的连接。然后它轮询剩下的连接,等到它们全部处理完当前请求并变为空闲,或者它的 context 结束,才返回。它不会取消请求的 context,也不会打断处理函数。一个永远不返回的处理函数,会让 Shutdown 一直等到截止时间。
这个比喻的局限:店主可以对顾客说“我们要关门了,请快一点”。Shutdown 什么也不会告诉正在运行的处理函数。如果处理函数要做很久的工作,它得通过别的途径得知服务器正在关闭,比如用 srv.RegisterOnShutdown 注册的函数。另外,在真实的网络上,锁门可不礼貌:试图连接的客户端会收到“connection refused”,所以负载均衡器应该在关闭开始之前就停止转发流量。
优雅关闭的时间过程。ctx 被取消时,监听器关闭,新连接被拒绝,空闲连接立即关闭,而 Shutdown 一直等到已在运行的请求发出响应。
在测试里证明关闭确实有效
测试里没法靠运行 main 再按 Ctrl+C 来检查关闭流程。但 serve 接收一个 context、一个监听器和一个处理函数,所以测试可以驱动每一步。它监听 127.0.0.1:0,也就是让操作系统随便分配一个空闲端口,再传给 serve 一个处理函数,这个处理函数会一直阻塞,直到测试放行:
func TestServeShutsDownGracefully(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
started := make(chan struct{})
release := make(chan struct{})
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
io.WriteString(w, "finished")
})
ctx, cancel := context.WithCancel(t.Context())
served := make(chan error, 1)
go func() {
served <- serve(ctx, ln, slow, slog.New(slog.DiscardHandler))
}()
// 1. Start a request, and wait until the handler is running.
type result struct {
body string
err error
}
inflight := make(chan result, 1)
go func() {
resp, err := http.Get("http://" + addr + "/slow")
if err != nil {
inflight <- result{err: err}
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
inflight <- result{string(b), err}
}()
<-started
// 2. Ask the server to stop, as SIGTERM would.
cancel()
// 3. Wait until new connections are refused. Shutdown closes the
// listener first, but nothing tells us when, so poll with a deadline.
deadline := time.Now().Add(5 * time.Second)
for {
conn, err := net.Dial("tcp", addr)
if err != nil {
break
}
conn.Close()
if time.Now().After(deadline) {
t.Fatal("server still accepts connections 5s after cancel")
}
time.Sleep(10 * time.Millisecond)
}
// 4. The request in flight has not been cut off. Let it finish.
select {
case r := <-inflight:
t.Fatalf("request ended before it was released: %+v", r)
default:
}
close(release)
if r := <-inflight; r.err != nil || r.body != "finished" {
t.Errorf("in-flight request: body %q, err %v; want finished", r.body, r.err)
}
if err := <-served; err != nil {
t.Errorf("serve returned %v, want nil", err)
}
}
决定顺序的是通道,不是 sleep。处理函数一开始运行就关闭 started,所以测试只在请求真正处理中时才取消;而在测试关闭 release 之前,处理函数无法结束。
有一步必须轮询,因为 Shutdown 不会通知监听器何时关闭。测试每 10 毫秒拨号一次,直到拨号失败,截止时间是 5 秒。每个连上的连接都会被关闭,免得 Shutdown 还要等它。
一个不可能失败的测试什么也证明不了,所以我把 srv.Shutdown(shutdownCtx) 改成了 srv.Close(),后者会立刻关闭所有连接。测试失败了:处理中的请求收到的是 EOF,而不是 finished。
在 go test -race -count=20 ./... 下,这个测试连续通过了 20 次。它没有用 Go 1.25 起稳定的 testing/synctest:那个包会伪造时钟,但它的文档说,不要在里面使用真实网络。
发布单个二进制文件
Go 程序会构建成一个可执行文件,里面包含运行时和用到的每个包,所以发布这个 API 就是复制一个文件。首先给它一个版本号:main.go 声明了一个构建时可以覆盖的变量。
// version is set at build time with -ldflags "-X main.version=v1.2.3".
var version = "dev"
func main() {
addr := flag.String("addr", "localhost:8080", "address to listen on")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println("tasksd", version)
return
}
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
if err := run(*addr, logger); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}
-X main.version=v1.0.0 在链接时设置这个字符串。下面是发布用的构建:
$ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=v1.0.0" -o tasksd ./cmd/tasksd
$ ./tasksd -version
tasksd v1.0.0
每个参数的作用:
CGO_ENABLED=0关闭 cgo。这一点让我意外:直接go build出来的服务器动态链接了 C 库,尽管模块里没有任何 C 代码。net包可以用 C 库做 DNS 查询,所以只要 cgo 可用,就会链接它。关闭 cgo 后,file显示这是一个静态链接的二进制文件,可以在同一架构的任何 Linux 上运行,哪怕是空容器里。-trimpath从二进制文件里去掉你机器上的目录路径,这样栈追踪显示的是example.com/tasks/internal/api/handlers.go,而不是你主目录下的某个路径。它还有助于两台机器从同样的代码构建出同样的字节。-ldflags="-s -w"去掉符号表和 DWARF 调试信息。发布构建比直接go build小了大约三分之一。panic 仍然会打印文件名和行号,因为运行时为此保留了自己的表,但调试器能用的信息就少了。
go version -m 可以从任何 Go 二进制文件里读回构建设置:
$ go version -m tasksd
...
path example.com/tasks/cmd/tasksd
mod example.com/tasks (devel)
build -buildmode=exe
build -compiler=gc
build -trimpath=true
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
我省略了第一行,它写的是具体的 Go 版本。列表里没有 -ldflags:使用 -trimpath 时 Go 不记录它,因为链接器参数里可能含有路径。程序可以用 debug.ReadBuildInfo 读到同样的信息,但对于你自己的版本号,-X 更简单。
要为别的系统构建,设置 GOOS 和 GOARCH 即可,不需要额外的工具链:
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o tasksd-linux-arm64 ./cmd/tasksd
$ GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o tasksd.exe ./cmd/tasksd
go tool dist list 会列出所有支持的组合。我用 kill 给 Linux 二进制文件发送 SIGTERM 时,它记录了 shutting down,并以状态码 0 退出。
静态二进制文件还能做出很小的容器镜像。下面这个 Dockerfile 只是示意,我没有在本文中构建或运行过它:
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tasksd ./cmd/tasksd
FROM scratch
COPY --from=build /tasksd /tasksd
USER 65532:65532
ENTRYPOINT ["/tasksd", "-addr", ":8080"]
最终镜像里只有这个二进制文件:没有 shell,没有 CA 证书,没有时区数据。这个服务器都用不上,但调用 HTTPS API 的服务就需要证书,或者改用 distroless 基础镜像。-addr :8080 很关键,因为 localhost:8080 只接受来自容器内部的连接。ENTRYPOINT 的 exec 形式让 tasksd 成为 1 号进程,所以 docker stop 会把 SIGTERM 直接发给它。
接下来可以做什么
任务 API 是一个小而完整的服务,下一步要做的每件事,在设计里都已经有了位置:
- 数据库。 基于
database/sql写一个Store,就像 JSON API 那一部分里的SQLStore草图,再用测试数据库对它跑同一张端点表。 - 身份验证。 写一个中间件检查令牌,并把用户放进请求的 context,和
withRequestID是同一种模式。对于浏览器客户端,可以看看 Go 1.25 加入的http.CrossOriginProtection,它会拒绝不安全的跨源请求。 - OpenAPI 描述,写清路由、请求体和状态码,让客户端可以据此生成代码。
- 性能分析。
net/http/pprof提供 CPU 和内存的 profile。把它放在单独的私有端口上,千万别挂到公开的 mux 上。
要点
- 用
httptest.NewRecorder测试处理函数的状态码、头部和响应体。当网络、客户端或服务器本身是测试的一部分时,用httptest.NewServer。 - 把存储放在接口后面,一个小小的假实现就能触发每一条错误路径,包括 panic。日志也要检查:用写入缓冲区的
slog处理器,再用ReplaceAttr去掉会变的字段。 - 在辅助函数里用
t.Cleanup代替defer,凡是需要 context 的地方都用t.Context()。配合真正同时发送请求的测试,运行go test -race ./...。 - 设置
ReadHeaderTimeout来对付慢客户端。WriteTimeout会关闭连接,但不会终止你的处理函数。 - 用
srv.Shutdown加截止时间来关闭,把http.ErrServerClosed当作成功。用一个在通道上阻塞的处理函数来测试它。 - 用
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"发布,用-X设置版本号,用GOOS和GOARCH交叉编译。
一个服务器,只有测过它怎么出错、怎么停下,才算完成。