不用任何第三方包,用 Go 搭建一个任务列表 REST API。逐层实现存储、路由、安全的 JSON 解码、校验、状态码、中间件和 slog 日志。
JSON REST API 是一组 URL,程序调用它们来读取和修改数据,请求体和响应体都用 JSON。Go 标准库里有搭建它所需的一切:net/http 负责路由,encoding/json 负责请求体和响应体,log/slog 负责日志。
本文把一个小型任务列表 API 做成真正的模块,一层一层地搭起来。文中代码直接取自模块里的文件,这些文件都能通过 go vet 和 go test -race。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
这个 API 做什么
这个 API 维护一个任务列表,每个任务有 ID、标题和一个完成标记。五个路由负责读取、创建、替换和删除任务:
| 请求 | 作用 | 成功 | 客户端错误 |
|---|---|---|---|
GET /tasks |
列出所有任务 | 200 | |
POST /tasks |
创建一个任务 | 201,带 Location |
400, 413, 415, 422 |
GET /tasks/{id} |
获取一个任务 | 200 | 404 |
PUT /tasks/{id} |
替换一个任务 | 200 | 400, 404, 413, 415, 422 |
DELETE /tasks/{id} |
删除一个任务 | 204 | 404 |
任何路由都可能返回 500。路径存在但方法不对,返回 405;路径不存在,返回 404。
模块在 internal/ 下有两个包,另外还有一个命令:
19-tasks-api/
├── go.mod
├── cmd/
│ └── tasksd/
│ └── main.go
└── internal/
├── task/
│ ├── task.go
│ └── memstore.go
└── api/
├── api.go
├── handlers.go
├── json.go
├── validate.go
└── middleware.go
task 对 HTTP 一无所知,api 也不知道任务存在哪里。cmd/tasksd 选定一个存储,然后启动服务器。这一部分先不讲测试,因为下一部分讲的就是测试和发布。
存储:挡在 map 前面的接口
在这个 API 里,任何拥有这五个方法的类型都可以当任务存储,所以处理器(handler)依赖的是接口,而不是 map。下面是 task.go,这个包两个文件中的第一个:
// Package task defines a task and the storage behind it.
package task
import (
"context"
"errors"
)
// Task is one item on the list.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// ErrNotFound is returned when no task has the requested ID.
var ErrNotFound = errors.New("task not found")
// Store is everything the API needs from storage.
type Store interface {
List(ctx context.Context) ([]Task, error)
Get(ctx context.Context, id int64) (Task, error)
Create(ctx context.Context, t Task) (Task, error)
Update(ctx context.Context, t Task) (Task, error)
Delete(ctx context.Context, id int64) error
}
JSON 标签让字段在传输时使用小写名字。ErrNotFound 是一个哨兵错误,和讲错误那一部分里的 io.EOF 一样。每个方法的第一个参数都是 context.Context。内存存储用不到它,但数据库存储会把它传给每次查询,这样请求被取消时,查询也会停下。
本文用的实现是 MemStore:
// The build fails here if MemStore stops satisfying Store.
var _ Store = (*MemStore)(nil)
// MemStore keeps tasks in memory. It's safe for concurrent use.
type MemStore struct {
mu sync.RWMutex
lastID int64
tasks map[int64]Task
}
// NewMemStore returns an empty store. The first task gets ID 1.
func NewMemStore() *MemStore {
return &MemStore{tasks: map[int64]Task{}}
}
// List returns every task, ordered by ID. It never returns nil.
func (s *MemStore) List(_ context.Context) ([]Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]Task, 0, len(s.tasks))
for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
list = append(list, s.tasks[id])
}
return list, nil
}
// Get returns the task with the given ID, or ErrNotFound.
func (s *MemStore) Get(_ context.Context, id int64) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
// Create stores t under the next ID and returns it with the ID set.
// Any ID already in t is ignored.
func (s *MemStore) Create(_ context.Context, t Task) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastID++
t.ID = s.lastID
s.tasks[t.ID] = t
return t, nil
}
Update 和 Delete 的写法一样,用的是 Lock。每个请求都在自己的 goroutine 里运行,如果没有互斥锁,两个 POST 请求可能同时增加 lastID,拿到同一个 ID。读操作加的是读锁,所以很多请求可以同时列出任务。var _ Store 这一行就是讲接口那一部分里的编译期检查。
List 用 make 构造结果,而不是 var list []Task,注释里也写明它从不返回 nil。切片变成 JSON 之后,这一点就很重要:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
var none []Task
empty := []Task{}
one := []Task{{ID: 1, Title: "Buy milk"}}
for _, tasks := range [][]Task{none, empty, one} {
b, err := json.Marshal(tasks)
fmt.Println(string(b), err)
}
}
输出:
null <nil>
[] <nil>
[{"id":1,"title":"Buy milk","done":false}] <nil>
nil 切片编码成 null,空切片编码成 []。JavaScript 客户端遍历 null 会抛出错误,所以空任务列表必须是 []。
构造函数:路由和依赖
api 包只对外提供一个函数 New,它把整个 API 构建成一个 http.Handler。它用 Go 1.22 引入的“方法加路径”模式注册路由,上一部分讲 net/http 服务器时详细介绍过:
// server holds the dependencies every handler needs.
type server struct {
store task.Store
logger *slog.Logger
}
// New returns the API as an http.Handler. It keeps no global state:
// every call builds a fresh handler, with its own request ID counter.
func New(store task.Store, logger *slog.Logger) http.Handler {
s := &server{store: store, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", s.listTasks)
mux.HandleFunc("POST /tasks", s.createTask)
mux.HandleFunc("GET /tasks/{id}", s.getTask)
mux.HandleFunc("PUT /tasks/{id}", s.updateTask)
mux.HandleFunc("DELETE /tasks/{id}", s.deleteTask)
var h http.Handler = s.jsonRouteErrors(mux)
h = s.recoverPanics(h)
h = s.logRequests(h)
h = withRequestID(h)
return h
}
New 通过参数接收存储和 logger,把它们保存在 server 结构体里。没有包级变量,也没有 init 函数,所以每次调用都会构建一个独立的处理器。测试可以用一个全新的存储和一个写入缓冲区的 logger 来构建它。注册路由之后的四行用中间件把 mux 包起来,本文后面会讲到。
处理器:读请求,调存储,写响应
API 里的每个处理器都从请求中读取所需内容,调用存储,再把结果变成状态码和响应体。createTask 最长:
func (s *server) createTask(w http.ResponseWriter, r *http.Request) {
var in taskInput
if err := decodeJSON(w, r, &in); err != nil {
s.clientError(w, err)
return
}
if fields := in.validate(); len(fields) > 0 {
s.writeError(w, http.StatusUnprocessableEntity, "validation failed", fields)
return
}
t, err := s.store.Create(r.Context(), task.Task{Title: in.Title, Done: in.Done})
if err != nil {
s.internalError(w, r, err)
return
}
w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
s.writeJSON(w, http.StatusCreated, t)
}
每个可能失败的步骤都自己写响应然后返回,所以处理器绝不会写两次。创建成功后,它把 Location 设为新任务的 URL,返回 201 Created,响应体里带着这个任务,客户端不用再发一次请求就能知道 ID。
getTask 展示了存储错误怎样变成状态码:
func (s *server) getTask(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(r)
if !ok {
s.notFound(w)
return
}
t, err := s.store.Get(r.Context(), id)
if errors.Is(err, task.ErrNotFound) {
s.notFound(w)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
s.writeJSON(w, http.StatusOK, t)
}
这里用 errors.Is 检查,而不是 ==,所以即使数据库存储用 %w 包装了 ErrNotFound,检查依然有效。其他任何错误都是服务器的问题,所以变成 500。
ID 通过一个小辅助函数从路径中取出:
// parseID reads the {id} wildcard. Only positive integers name a task.
func parseID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
return id, err == nil && id > 0
}
/tasks/abc 和 /tasks/-3 都返回 404,和 /tasks/999 一样,因为这些 URL 上都没有任务。
为什么用 PUT 而不用 PATCH
这个 API 用 PUT 替换任务,不提供 PATCH。用 PUT 时,客户端发送完整的任务,发两次结果也一样,所以客户端在超时后可以放心重试。
PATCH 的意思是“只改我发过去的字段”,所以处理器得区分缺少 done 和 "done": false。在 Go 里这需要 *bool 这样的指针字段。任务只有两个字段,两个都发几乎没有成本。代价是省略 done 会把它设为 false。如果有二十个字段,PATCH 多出来的代码就值得了。
读取 JSON,但不轻信它
请求体来自发送请求的任何人,所以 API 通过一个辅助函数解码它,这个函数在读第一个字节之前就先设好限制。下面是 decodeJSON:
// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mediaType != "application/json" {
return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &tooBig):
msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
return &requestError{http.StatusRequestEntityTooLarge, msg}
case errors.Is(err, io.EOF):
return &requestError{http.StatusBadRequest, "request body must not be empty"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
case errors.As(err, &syntaxErr):
msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
return &requestError{http.StatusBadRequest, msg}
case errors.As(err, &typeErr) && typeErr.Field == "":
return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
case errors.As(err, &typeErr):
msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
return &requestError{http.StatusBadRequest, msg}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return &requestError{http.StatusBadRequest, "unknown field " + field}
default:
return &requestError{http.StatusBadRequest, "malformed JSON"}
}
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
}
return nil
}
它做了四个决定,每种失败都给出客户端能据此处理的消息:
Content-Type必须是application/json,带不带; charset=utf-8都行。其他值一律返回 415 Unsupported Media Type,而不是 400,因为请求体本身可能没问题,错的是标注。状态码告诉客户端该改什么。- 请求体用
http.MaxBytesReader限制在 1 MiB 以内。超过限制后,读取会失败并返回*http.MaxBytesError,辅助函数返回 413。如果没有上限,一个客户端就能让解码器占用好几 GB 内存。 - 用
DisallowUnknownFields拒绝未知字段,这样"titel"这种拼写错误会报错,而不是被悄悄忽略。encoding/json没有为这种情况定义错误类型,所以辅助函数匹配错误消息的前缀。 - 只允许一个 JSON 值。
Decode读完一个值就停下,所以第二次Decode必须遇到io.EOF。
下面的程序原样复制了 decodeJSON,用它处理十一个请求体:
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
)
// maxBodyBytes caps a request body at 1 MiB.
const maxBodyBytes = 1 << 20
// requestError is a problem with the request that the client can fix.
type requestError struct {
status int
message string
}
func (e *requestError) Error() string { return e.message }
// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mediaType != "application/json" {
return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &tooBig):
msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
return &requestError{http.StatusRequestEntityTooLarge, msg}
case errors.Is(err, io.EOF):
return &requestError{http.StatusBadRequest, "request body must not be empty"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
case errors.As(err, &syntaxErr):
msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
return &requestError{http.StatusBadRequest, msg}
case errors.As(err, &typeErr) && typeErr.Field == "":
return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
case errors.As(err, &typeErr):
msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
return &requestError{http.StatusBadRequest, msg}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return &requestError{http.StatusBadRequest, "unknown field " + field}
default:
return &requestError{http.StatusBadRequest, "malformed JSON"}
}
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
}
return nil
}
type taskInput struct {
Title string `json:"title"`
Done bool `json:"done"`
}
func try(name, contentType, body string) {
req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
var in taskInput
err := decodeJSON(httptest.NewRecorder(), req, &in)
var re *requestError
if errors.As(err, &re) {
fmt.Printf("%-13s %d %s\n", name, re.status, re.message)
return
}
fmt.Printf("%-13s ok %+v\n", name, in)
}
func main() {
const ct = "application/json"
try("valid", ct, `{"title":"Buy milk"}`)
try("charset", ct+"; charset=utf-8", `{"title":"Buy milk","done":true}`)
try("form", "application/x-www-form-urlencoded", "title=Buy+milk")
try("empty", ct, "")
try("broken", ct, `{"title" "Buy milk"}`)
try("cut off", ct, `{"title":"Buy`)
try("unknown", ct, `{"title":"Buy milk","id":7}`)
try("wrong type", ct, `{"title":"Buy milk","done":"yes"}`)
try("array", ct, `["Buy milk"]`)
try("two values", ct, `{"title":"a"}{"title":"b"}`)
try("too big", ct, `{"title":"`+strings.Repeat("a", 2<<20)+`"}`)
}
输出:
valid ok {Title:Buy milk Done:false}
charset ok {Title:Buy milk Done:true}
form 415 Content-Type must be application/json
empty 400 request body must not be empty
broken 400 malformed JSON at byte 10
cut off 400 request body ends in the middle of the JSON
unknown 400 unknown field "id"
wrong type 400 field "done" has the wrong type
array 400 request body must be a JSON object
two values 400 request body must hold a single JSON value
too big 413 request body must not be larger than 1048576 bytes
看 unknown 这一行。输入类型没有 ID 字段,所以客户端没法通过发送 "id": 7 自己指定 ID。ID 由存储分配,由 URL 指明。
Go 1.26 的源码树里已经有 encoding/json/v2,但只有设置 GOEXPERIMENT=jsonv2 才能用,所以这个模块继续用 encoding/json。
校验,以及为什么是 422
校验检查的是已经顺利解码的请求里的值,并按字段报告问题。下面是完整的 validate.go:
// maxTitleLen is the longest title allowed, counted in characters.
const maxTitleLen = 200
// taskInput is what a client sends for POST and PUT. It has no ID field:
// the ID comes from the store or the URL, never from the body.
type taskInput struct {
Title string `json:"title"`
Done bool `json:"done"`
}
// validate trims the title in place, then returns one message per
// invalid field, or nil if the input is fine.
func (in *taskInput) validate() map[string]string {
fields := map[string]string{}
in.Title = strings.TrimSpace(in.Title)
switch {
case in.Title == "":
fields["title"] = "must not be empty"
case utf8.RuneCountInString(in.Title) > maxTitleLen:
fields["title"] = "must be at most 200 characters"
}
if len(fields) == 0 {
return nil
}
return fields
}
长度用 utf8.RuneCountInString 计算,而不是按字节计数的 len。所以中文标题同样可以有 200 个字符。
当 validate 返回字段时,处理器返回 422 Unprocessable Content。decodeJSON 返回的 400 表示“我读不懂你的请求”。422 表示“我读懂了,但这些值违反了规则”。客户端可以把字段消息显示在对应的输入框旁边,而把 400 当作自己的 bug。Go 的常量是 http.StatusUnprocessableEntity,它的状态文本仍然是“Unprocessable Entity”,这是同一个状态码的旧名字。
所有错误用同一种 JSON 结构
API 发出的每个错误,从 400 到 500,都是一个 JSON 对象,包含 error 消息;校验错误还会带一个 fields 对象。客户端只需要一段代码就能读取所有错误:
// errorBody is the one shape every error response uses.
type errorBody struct {
Error string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
// writeJSON encodes v before writing anything, so an encoding failure
// can still become a clean 500.
func (s *server) writeJSON(w http.ResponseWriter, status int, v any) {
body, err := json.Marshal(v)
if err != nil {
s.logger.Error("encode response", "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `{"error":"internal server error"}`+"\n")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(append(body, '\n'))
}
没有字段时,omitempty 会去掉 fields,所以 404 就只是 {"error":"task not found"}。
writeJSON 在写任何东西之前先调用 json.Marshal。WriteHeader 一旦执行,状态码就收不回来了,如果编码器写到一半失败,客户端拿到的就是 200 加半个响应体。先序列化,失败时就还能变成一个干净的 500。
500 从不告诉客户端原因:
// internalError logs the real error and tells the client nothing about it.
func (s *server) internalError(w http.ResponseWriter, r *http.Request, err error) {
s.logger.ErrorContext(r.Context(), "internal error", "err", err, "request_id", requestIDFrom(r.Context()))
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}
真正的错误可能包含数据库主机名或文件路径,它只写进日志。客户端拿到的是 internal server error,以及 X-Request-Id 头里的请求 ID,这样 bug 报告就能对应到那一行日志。
状态码,从头到尾
状态码是客户端最先读到的东西,所以 API 的每个响应都是有意选定状态码的。独立程序不能导入模块的 internal/ 包,所以下面的程序是 API 的精简版:用一个 map 当存储,解码步骤也更短,但状态码和错误结构都一样。它跑在 httptest.NewServer 上,这是一个监听本地随机端口的真实 HTTP 服务器:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// A trimmed version of the module's API: a map for a store, and a short
// decode step in place of decodeJSON.
type api struct {
mu sync.Mutex
lastID int64
tasks map[int64]Task
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
writeJSON(w, status, errorBody{Error: msg, Fields: fields})
}
func (a *api) decode(w http.ResponseWriter, r *http.Request) (Task, bool) {
var in struct {
Title string `json:"title"`
Done bool `json:"done"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "malformed JSON", nil)
return Task{}, false
}
if strings.TrimSpace(in.Title) == "" {
writeError(w, http.StatusUnprocessableEntity, "validation failed",
map[string]string{"title": "must not be empty"})
return Task{}, false
}
return Task{Title: in.Title, Done: in.Done}, true
}
func (a *api) create(w http.ResponseWriter, r *http.Request) {
t, ok := a.decode(w, r)
if !ok {
return
}
a.mu.Lock()
a.lastID++
t.ID = a.lastID
a.tasks[t.ID] = t
a.mu.Unlock()
w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
writeJSON(w, http.StatusCreated, t)
}
func (a *api) get(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
a.mu.Lock()
t, ok := a.tasks[id]
a.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) update(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
t, ok := a.decode(w, r)
if !ok {
return
}
t.ID = id
a.mu.Lock()
_, found := a.tasks[id]
if found {
a.tasks[id] = t
}
a.mu.Unlock()
if !found {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) remove(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
a.mu.Lock()
_, found := a.tasks[id]
delete(a.tasks, id)
a.mu.Unlock()
if !found {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
a := &api{tasks: map[int64]Task{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /tasks", a.create)
mux.HandleFunc("GET /tasks/{id}", a.get)
mux.HandleFunc("PUT /tasks/{id}", a.update)
mux.HandleFunc("DELETE /tasks/{id}", a.remove)
srv := httptest.NewServer(mux)
defer srv.Close()
send := func(method, path, body string) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(method, path, "->", resp.Status)
if loc := resp.Header.Get("Location"); loc != "" {
fmt.Println(" Location:", loc)
}
if len(out) > 0 {
fmt.Print(" ", string(out))
}
}
send("POST", "/tasks", `{"title":"Buy milk"}`)
send("GET", "/tasks/1", "")
send("PUT", "/tasks/1", `{"title":"Buy milk","done":true}`)
send("POST", "/tasks", `{"title":" "}`)
send("DELETE", "/tasks/1", "")
send("GET", "/tasks/1", "")
send("PATCH", "/tasks/1", `{"done":false}`)
}
输出:
POST /tasks -> 201 Created
Location: /tasks/1
{"id":1,"title":"Buy milk","done":false}
GET /tasks/1 -> 200 OK
{"id":1,"title":"Buy milk","done":false}
PUT /tasks/1 -> 200 OK
{"id":1,"title":"Buy milk","done":true}
POST /tasks -> 422 Unprocessable Entity
{"error":"validation failed","fields":{"title":"must not be empty"}}
DELETE /tasks/1 -> 204 No Content
GET /tasks/1 -> 404 Not Found
{"error":"task not found"}
PATCH /tasks/1 -> 405 Method Not Allowed
Method Not Allowed
这就是一个任务的一生。DELETE 返回 204 No Content,这种响应不能有响应体,所以处理器只调用 WriteHeader,什么也不写。之后再访问这个任务就是 404。
最后一个响应是纯文本 Method Not Allowed,因为精简版把 405 交给了 mux。真正的模块用下一节里的一个中间件解决了这个问题。
中间件:包住处理器的处理器
Go 里的中间件是类型为 func(http.Handler) http.Handler 的函数:它接收一个处理器,返回一个新的处理器,新处理器在调用原处理器之前或之后做一些事。下面的程序给一个处理器包了三层,请求经过时每层都会打印:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// layer returns a middleware that prints when a request passes in and out.
func layer(name, indent string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(indent + name + ": in")
next.ServeHTTP(w, r)
fmt.Println(indent + name + ": out")
})
}
}
func main() {
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(" handler")
})
h = layer("recover", " ")(h)
h = layer("log", " ")(h)
h = layer("request id", "")(h)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/tasks", nil))
}
输出:
request id: in
log: in
recover: in
handler
recover: out
log: out
request id: out
最后套上的一层 request id 在最外面,所以最先运行。next.ServeHTTP 之后的代码在返回的路上运行,顺序相反。
用十岁孩子能懂的话说
想象你寄一封信到一个大办公室。在大门口,有人在信封上盖一个编号。接着,一个办事员记下信到达的时间。然后信经过一位急救员,他在那儿是为了防止出事。最后,信才送到负责写回信的人手里。
回信沿着同一条路反向送出去。急救员挥手放行。办事员记下“7 号信:已回复”。大门口把它寄出。
每个人只知道下一步该把信交给谁。
准确的说法
中间件返回一个捕获了 next 的 http.HandlerFunc 闭包。a(b(c(h))) 构建出一条链:a 的处理器调用 b 的,依此类推,一直到 h。next.ServeHTTP 之前的代码从外向内运行。之后的代码,包括延迟调用的函数,从内向外运行。所以某一层里延迟调用的 recover 能捕获它内部所有层的 panic。
某一层可以自己直接响应,根本不调用 next;也可以在进来的路上用 r.WithContext 修改请求。但除非它包装了 http.ResponseWriter,否则在出去的路上读不到响应。
这个比喻的局限: 响应并不是第二封信,要从每个人面前再经过一遍。处理器直接写入 http.ResponseWriter,字节可能在外层的“之后”代码运行前就已经到达客户端。这就是为什么下面的日志层要包装 writer 才能知道状态码。
New 构建出的层次。请求进来时依次经过每一层,出去时每层 next.ServeHTTP 之后的代码从最内层开始运行。
模块里的第一个中间件给每个请求分配一个 ID:
// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}
// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
var counter atomic.Uint64
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-" + strconv.FormatUint(counter.Add(1), 10)
w.Header().Set("X-Request-Id", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
计数器是 withRequestID 的局部变量,所以每次调用 New 都会从 req-1 重新开始,测试里的 ID 就是可预测的。它用 atomic.Uint64,因为请求是并行运行的。ID 以一个未导出的键类型存放在 context 里传递,这是讲 context 那一部分里的模式。
最内层的中间件让 mux 自己产生的错误也符合 API 的错误结构:
// jsonRouteErrors lets the mux decide 404 and 405 for requests that match no
// pattern, including the Allow header, but sends the body as JSON.
func (s *server) jsonRouteErrors(mux *http.ServeMux) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h, pattern := mux.Handler(r)
if pattern != "" {
mux.ServeHTTP(w, r)
return
}
// No pattern matched. h is the mux's own 404 or 405 handler.
// Run it against a writer that keeps the status and drops the text.
probe := &statusOnly{header: w.Header()}
h.ServeHTTP(probe, r)
s.writeError(w, probe.status, strings.ToLower(http.StatusText(probe.status)), nil)
})
}
// statusOnly is a ResponseWriter that shares the real headers but keeps
// only the status code, discarding the body.
type statusOnly struct {
header http.Header
status int
}
func (p *statusOnly) Header() http.Header { return p.header }
func (p *statusOnly) WriteHeader(status int) { p.status = status }
func (p *statusOnly) Write(b []byte) (int, error) { return len(b), nil }
mux.Handler(r) 询问 mux 哪个模式会处理这个请求,但不真正执行。模式为空说明没有路由匹配,这时返回的处理器是 mux 自带的 404 或 405 响应器。让它写入 statusOnly,就能保留状态码和 Allow 头,丢掉文本。客户端收到的是 {"error":"method not allowed"}。
用 log/slog 记日志
log/slog 包写的是结构化日志:一条消息加上机器可以检索的键值对。它的 JSON 和文本 handler 会给每一行加上当前时间。这会让这里的输出每次运行都不一样,所以本文的程序用 ReplaceAttr 去掉了 time 键:
package main
import (
"errors"
"log/slog"
"os"
"time"
)
func main() {
opts := &slog.HandlerOptions{
// Drop the time so the output is the same on every run.
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)
reqLogger := logger.With("request_id", "req-7")
reqLogger.Error("internal error", "err", errors.New("disk full"))
text := slog.New(slog.NewTextHandler(os.Stdout, opts))
text.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)
}
输出:
{"level":"INFO","msg":"request","method":"GET","status":200,"duration":1500000}
{"level":"ERROR","msg":"internal error","request_id":"req-7","err":"disk full"}
level=INFO msg=request method=GET status=200 duration=1.5ms
ReplaceAttr 会在每个属性写出之前看到它,返回空的 slog.Attr 就把它删掉。len(groups) == 0 这个检查会保留分组里的 time 字段。logger.With 返回一个新 logger,它会给每一行加上 request_id。
注意 duration 的值。JSON handler 把 time.Duration 写成整数纳秒 1500000,而文本 handler 写成 1.5ms。检索日志的程序得知道自己读的是哪一种。
日志中间件需要知道状态码,而状态码是处理器写进 ResponseWriter 的。所以它交给处理器一个能记住状态码的包装器:
// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(status int) {
if rec.status == 0 {
rec.status = status
}
rec.ResponseWriter.WriteHeader(status)
}
func (rec *statusRecorder) Write(b []byte) (int, error) {
if rec.status == 0 {
rec.status = http.StatusOK
}
return rec.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
return rec.ResponseWriter
}
// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
slog.String("request_id", requestIDFrom(r.Context())),
)
})
}
statusRecorder 嵌入了 http.ResponseWriter,并重写了两个方法。没有调用 WriteHeader 就 Write 会发出 200,所以 Write 也要记录下来。Unwrap 让 http.NewResponseController 能拿到原始的 writer,用上包装器没有的功能,比如刷新(flush)。
从 panic 中恢复
处理器里的 panic 不会让 Go HTTP 服务器崩溃,但客户端收不到响应。net/http 会恢复它,记下栈跟踪,然后关闭连接。我试的时候,客户端的 http.Get 返回了一个 EOF 错误,没有状态码。模块里的中间件把它变成一个规范的 500:
// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
slog.Any("value", v),
slog.String("stack", string(debug.Stack())),
slog.String("request_id", requestIDFrom(r.Context())),
)
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}()
next.ServeHTTP(w, r)
})
}
panic 的值和栈跟踪写进日志,客户端拿到的 500 响应体和其他内部错误一样通用。http.ErrAbortHandler 是故意重新 panic 的:处理器用它 panic,是为了有意中止响应,net/http 会悄悄处理它。
下面的程序原样复制了这三个中间件,把它们包在一个不检查边界就索引切片的处理器外面。日志 handler 还去掉了 duration 和 stack,因为它们每次运行都会变:
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"runtime/debug"
"strconv"
"sync/atomic"
"time"
)
type server struct {
logger *slog.Logger
}
func (s *server) writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, "{\"error\":%q}\n", msg)
}
// The middleware below is copied unchanged from internal/api/middleware.go.
// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}
// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
var counter atomic.Uint64
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-" + strconv.FormatUint(counter.Add(1), 10)
w.Header().Set("X-Request-Id", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(status int) {
if rec.status == 0 {
rec.status = status
}
rec.ResponseWriter.WriteHeader(status)
}
func (rec *statusRecorder) Write(b []byte) (int, error) {
if rec.status == 0 {
rec.status = http.StatusOK
}
return rec.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
return rec.ResponseWriter
}
// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
slog.String("request_id", requestIDFrom(r.Context())),
)
})
}
// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
slog.Any("value", v),
slog.String("stack", string(debug.Stack())),
slog.String("request_id", requestIDFrom(r.Context())),
)
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}()
next.ServeHTTP(w, r)
})
}
func main() {
// Drop the attributes that change on every run: time, duration, stack.
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
},
}
s := &server{logger: slog.New(slog.NewJSONHandler(os.Stdout, opts))}
titles := []string{"Buy milk", "Walk the dog"}
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
fmt.Fprintln(w, titles[id-1]) // a bug: no bounds check
})
var h http.Handler = s.recoverPanics(mux)
h = s.logRequests(h)
h = withRequestID(h)
for _, path := range []string{"/tasks/2", "/tasks/5"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("client got %d %s: %s", rec.Code, rec.Header().Get("X-Request-Id"), rec.Body)
}
}
输出:
{"level":"INFO","msg":"request","method":"GET","path":"/tasks/2","status":200,"request_id":"req-1"}
client got 200 req-1: Walk the dog
{"level":"ERROR","msg":"panic","value":"runtime error: index out of range [4] with length 2","request_id":"req-2"}
{"level":"INFO","msg":"request","method":"GET","path":"/tasks/5","status":500,"request_id":"req-2"}
client got 500 req-2: {"error":"internal server error"}
第二个请求触发了 panic。recoverPanics 带着 req-2 记下这个 panic,写出 500,然后正常返回,所以 logRequests 仍然用同一个 ID 记下 status: 500。客户端收到的响应体里完全没提切片。这取决于 New 里的顺序:如果 recoverPanics 在 logRequests 外面,panic 就会跳过这行日志。
有一个限制:如果处理器 panic 时已经发出了状态码,就来不及返回 500 了。
运行服务器
cmd/tasksd 里的命令把各个部件组装起来,启动一个 http.Server。main 读取 -addr 参数,创建一个输出到标准错误的 JSON logger,然后调用 run;run 打开监听器,交给 serve:
func run(addr string, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return serve(ctx, ln, api.New(task.NewMemStore(), logger), logger)
}
// serve answers requests on ln until ctx is cancelled, then shuts down
// gracefully: no new connections, and requests in flight get to finish.
func serve(ctx context.Context, ln net.Listener, handler http.Handler, logger *slog.Logger) error {
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),
}
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
}
这是唯一选择 MemStore 的地方。serve 接收的是监听器和处理器,而不是地址,所以测试可以在一个空闲端口上用自己的处理器运行它。超时设置沿用上一部分,其中 ReadHeaderTimeout 绝不能省。按下 Ctrl+C 时,signal.NotifyContext 会取消 ctx,然后 srv.Shutdown 等待进行中的请求处理完。下一部分会详细讲超时和关闭。
我在一个终端里运行 go run ./cmd/tasksd,在另一个终端里发请求:
$ curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title":"Buy milk"}'
{"id":1,"title":"Buy milk","done":false}
$ curl -s -X PUT localhost:8080/tasks/1 -H 'Content-Type: application/json' -d '{"title":"Buy milk","done":true}'
{"id":1,"title":"Buy milk","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":true}]
$ curl -s -X PATCH localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"method not allowed"}
405
$ curl -s -X POST localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
这次的 405 是 JSON,这要归功于 jsonRouteErrors。最后一个请求发送的是表单请求体,这是 curl -d 的默认行为,所以得到 415。服务器终端里每个请求都有一行 JSON 日志。我没有贴出来,因为时间和耗时每次运行都不一样。
数据库该放在哪里
真正的任务列表重启后不能丢数据,而 Store 接口就是接入数据库的地方,处理器一行都不用改。database/sql 在标准库里,但每个数据库驱动都是第三方模块,所以本系列只讲到结构为止。SQL 存储的 Get 大致是这样:
// SQLStore keeps tasks in a SQL database. With the other four methods
// written the same way, it satisfies Store just as MemStore does.
type SQLStore struct {
db *sql.DB
}
func (s *SQLStore) Get(ctx context.Context, id int64) (Task, error) {
var t Task
err := s.db.QueryRowContext(ctx,
"SELECT id, title, done FROM tasks WHERE id = $1", id,
).Scan(&t.ID, &t.Title, &t.Done)
if errors.Is(err, sql.ErrNoRows) {
return Task{}, ErrNotFound
}
if err != nil {
return Task{}, fmt.Errorf("get task %d: %w", id, err)
}
return t, nil
}
QueryRowContext 接收请求的 context,所以客户端断开连接时,它的查询也会被取消。sql.ErrNoRows 变成 ErrNotFound,所以 getTask 仍然返回 404;其他错误会被包装,变成 500。$1 占位符是 PostgreSQL 的风格,MySQL 和 SQLite 的驱动用 ?。在 run 里,SQL 存储会取代 task.NewMemStore(),internal/api 里什么都不用改。
要点
- 把存储放在一个小接口后面,返回哨兵错误,让处理器用
errors.Is检查。 - 用一个辅助函数解码请求体:检查
Content-Type(415),用http.MaxBytesReader限制大小(413),调用DisallowUnknownFields,只允许一个 JSON 值(400)。 - 格式正确但违反规则的输入,返回 422 并附上按字段的消息;创建成功返回 201 并带
Location;删除成功返回不带响应体的 204。 - 所有错误用同一种 JSON 结构发送,先序列化再写状态码,内部错误记进日志,不要返回给客户端。
- 中间件就是
func(http.Handler) http.Handler。最后套上的最先运行,所以请求 ID 放在日志外面,日志放在 panic 恢复外面。通过包装过的ResponseWriter用slog记日志。 - 用构造函数构建处理器,通过参数传入依赖,不用全局变量。
每个状态码都要有意选择,每个错误都用同一种结构发送。