Blog

测试并发布 Go REST API

用 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.Cleanupt.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) 则是在测试结束时才运行它。sendt.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-1req-2req-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 的 *requestErrorf.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/tasksdserve 的后半部分:

	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
}

整个过程分五步:

  1. srv.Serve(ln) 会一直阻塞到服务器停止,所以它在一个 goroutine 里运行。errc 能放下一个值,因此这个 goroutine 总能发出结果并退出。
  2. select 等待先发生的那件事:要么 Serve 出错,要么 ctx 被取消。按下 Ctrl+C 或收到 SIGTERM 时,run 里的 signal.NotifyContext 会取消它。
  3. srv.Shutdown 关闭监听器,于是新连接被拒绝;再关闭空闲连接,并等待活跃连接处理完手上的请求。
  4. 这次等待用 context.Background() 新建一个 10 秒的 context,因为 ctx 已经被取消,用它的话等待会立刻结束。如果到了截止时间还有请求在跑,Shutdown 返回 context.DeadlineExceeded
  5. Shutdown 之后,Serve 返回 http.ErrServerClosed。这是正常的停止方式,所以 serve 遇到它时返回 nil。

这 10 秒要短于你的平台从发送 SIGTERM 到杀掉进程之间的等待时间。Kubernetes 默认等 30 秒,但 docker stop 只等 10 秒。

用十岁孩子能懂的话说

想象一家商店到了关门的时候。店主不会把所有人都推到街上。她先锁上前门,这样就没有新客人能进来。只是随便逛逛、不买东西的人,会被请出去。已经站在收银台前的顾客,可以把钱付完。

最后一位顾客付完钱,她就关灯回家。如果十分钟后还有人在数硬币,她也照样关门。

准确的说法

Shutdown 关闭注册在服务器上的所有监听器,于是操作系统会拒绝到那个端口的新连接。接着它关闭空闲的连接,也就是处在两次请求之间的连接,或者刚建立、几秒内还没开始请求的连接。然后它轮询剩下的连接,等到它们全部处理完当前请求并变为空闲,或者它的 context 结束,才返回。它不会取消请求的 context,也不会打断处理函数。一个永远不返回的处理函数,会让 Shutdown 一直等到截止时间。

这个比喻的局限:店主可以对顾客说“我们要关门了,请快一点”。Shutdown 什么也不会告诉正在运行的处理函数。如果处理函数要做很久的工作,它得通过别的途径得知服务器正在关闭,比如用 srv.RegisterOnShutdown 注册的函数。另外,在真实的网络上,锁门可不礼貌:试图连接的客户端会收到“connection refused”,所以负载均衡器应该在关闭开始之前就停止转发流量。

ctx 取消:Shutdown 开始 最后的请求完成:Shutdown 返回 监听器 接受连接 已关闭 拒绝 空闲连接 长连接 立即关闭 请求 A 处理函数运行中 响应已发出 serve() srv.Serve(ln) Shutdown 等待 返回 nil

优雅关闭的时间过程。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 更简单。

要为别的系统构建,设置 GOOSGOARCH 即可,不需要额外的工具链:

$ 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 设置版本号,用 GOOSGOARCH 交叉编译。

一个服务器,只有测过它怎么出错、怎么停下,才算完成。

这篇文章对你有帮助吗?

点一颗爱心来评分!

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

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