Go net/http 实战:构建生产级 REST API
Go 标准库 net/http 已经具备构建 REST API 所需的路由、服务器、客户端、Context 和测试能力。生产级服务还需要明确的超时、输入限制、错误协议、日志和优雅退出。
本文使用 Go 1.22 及以上版本的 ServeMux 路由模式。
定义 API 与数据模型
示例服务提供两个接口:
POST /users:创建用户。GET /users/{id}:查询用户。
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type UserService interface {
Create(context.Context, CreateUserRequest) (User, error)
Find(context.Context, int64) (User, error)
}
Handler 依赖业务接口,不直接操作数据库,便于测试并保持分层边界。
统一 JSON 响应
type errorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("encode response: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
response := errorResponse{}
response.Error.Code = code
response.Error.Message = message
writeJSON(w, status, response)
}
错误响应应提供稳定机器码和安全的人类提示。不要把 SQL、文件路径或堆栈直接返回给客户端。
必须在 WriteHeader 前设置响应头。响应开始写出后,状态码无法再修改。
安全解析 JSON 请求
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one JSON object")
}
return nil
}
关键点:
- 使用
MaxBytesReader限制请求体,防止超大输入占用内存。 - 使用
DisallowUnknownFields尽早发现字段拼写错误。 - 确保请求体只有一个 JSON 对象,拒绝尾随数据。
- 业务层仍需执行字段长度、格式和领域规则校验。
创建用户 Handler
type Handler struct {
users UserService
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var input CreateUserRequest
if err := decodeJSON(w, r, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "请求体格式错误")
return
}
input.Name = strings.TrimSpace(input.Name)
input.Email = strings.TrimSpace(strings.ToLower(input.Email))
if input.Name == "" || input.Email == "" {
writeError(w, http.StatusUnprocessableEntity, "invalid_input", "姓名和邮箱不能为空")
return
}
user, err := h.users.Create(r.Context(), input)
if err != nil {
handleServiceError(w, err)
return
}
w.Header().Set("Location", fmt.Sprintf("/users/%d", user.ID))
writeJSON(w, http.StatusCreated, user)
}
解析错误使用 400 Bad Request,语法正确但字段不满足要求可以使用 422 Unprocessable Entity。团队应建立统一约定。
路径参数与查询 Handler
Go 1.22 的 ServeMux 支持方法和路径变量:
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid_id", "用户 ID 无效")
return
}
user, err := h.users.Find(r.Context(), id)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
业务错误映射:
func handleServiceError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "资源不存在")
case errors.Is(err, ErrConflict):
writeError(w, http.StatusConflict, "conflict", "资源已存在")
case errors.Is(err, context.DeadlineExceeded):
writeError(w, http.StatusGatewayTimeout, "timeout", "请求处理超时")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "服务器内部错误")
}
}
日志应在系统边界记录,错误映射函数不应泄露内部错误内容。
注册路由
func routes(handler *Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", handler.CreateUser)
mux.HandleFunc("GET /users/{id}", handler.GetUser)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
return requestID(logging(recovery(mux)))
}
404 表示路径不存在,405 表示路径存在但方法不允许。方法路由可以由 ServeMux 正确区分。
中间件模式
中间件接收并返回 http.Handler:
type Middleware func(http.Handler) http.Handler
func chain(handler http.Handler, middleware ...Middleware) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
handler = middleware[i](handler)
}
return handler
}
Request ID
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newRequestID()
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
如果接受客户端提供的 Request ID,应限制长度和字符集,避免日志注入。
Panic 恢复
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if value := recover(); value != nil {
log.Printf("panic: %v\n%s", value, debug.Stack())
writeError(w, http.StatusInternalServerError, "internal_error", "服务器内部错误")
}
}()
next.ServeHTTP(w, r)
})
}
如果 Handler 已经写出部分响应,恢复中间件无法可靠改写状态码。响应流式写出前应尽量完成可能失败的准备工作。
访问日志与状态码
包装 ResponseWriter 捕获状态码和响应大小:
type responseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *responseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *responseWriter) Write(data []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
n, err := w.ResponseWriter.Write(data)
w.bytes += n
return n, err
}
生产环境可使用 log/slog 输出结构化字段:请求 ID、方法、路径模板、状态码、耗时和响应大小。不要默认记录敏感请求体。
配置 HTTP Server 超时
不要直接使用没有超时配置的 http.ListenAndServe:
server := &http.Server{
Addr: ":8080",
Handler: routes(handler),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ReadHeaderTimeout限制读取请求头的时间。ReadTimeout限制读取整个请求的时间,上传接口要根据业务调整。WriteTimeout限制响应写出时间,流式接口需要单独设计。IdleTimeout控制 Keep-Alive 空闲连接。
应用层对数据库和下游调用还需要更短的 Context 超时。
优雅启动与退出
func serve(ctx context.Context, server *http.Server) error {
errCh := make(chan error, 1)
go func() {
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
return nil
}
Shutdown 停止接受新连接并等待活跃请求结束。消息消费者、定时任务和数据库连接还需要按依赖顺序单独关闭。
Handler 测试
func TestGetUser(t *testing.T) {
service := &fakeUserService{
user: User{ID: 7, Name: "Alice", Email: "[email protected]"},
}
handler := &Handler{users: service}
server := routes(handler)
request := httptest.NewRequest(http.MethodGet, "/users/7", nil)
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, request)
response := recorder.Result()
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", response.StatusCode)
}
var got User
if err := json.NewDecoder(response.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.ID != 7 {
t.Fatalf("ID = %d, want 7", got.ID)
}
}
继续添加以下测试:
- 非法 JSON 和超大请求体。
- 未知字段和缺少必填字段。
- 无效路径 ID。
- 资源不存在、冲突和内部错误。
- Handler 超时和请求取消。
- Panic 恢复与 Request ID 传播。
生产检查清单
- 所有请求体是否有大小限制。
- JSON 是否拒绝未知字段和尾随对象。
- 错误响应是否具有稳定代码且不泄露内部信息。
- Handler 是否把
r.Context()传给数据库和下游服务。 - Server 是否设置请求头、读取、写入和空闲超时。
- 是否具有 Panic 恢复、结构化日志和 Request ID。
- 健康检查是否区分存活与就绪状态。
- 关闭时是否停止新流量并等待活跃请求。
- Handler 的正常、边界和错误路径是否经过测试。
标准库可以构建清晰、依赖少的 HTTP 服务。真正决定服务质量的不是路由库,而是输入边界、超时传播、错误协议、可观测性和生命周期管理是否完整。