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 传播。

生产检查清单

  1. 所有请求体是否有大小限制。
  2. JSON 是否拒绝未知字段和尾随对象。
  3. 错误响应是否具有稳定代码且不泄露内部信息。
  4. Handler 是否把 r.Context() 传给数据库和下游服务。
  5. Server 是否设置请求头、读取、写入和空闲超时。
  6. 是否具有 Panic 恢复、结构化日志和 Request ID。
  7. 健康检查是否区分存活与就绪状态。
  8. 关闭时是否停止新流量并等待活跃请求。
  9. Handler 的正常、边界和错误路径是否经过测试。

标准库可以构建清晰、依赖少的 HTTP 服务。真正决定服务质量的不是路由库,而是输入边界、超时传播、错误协议、可观测性和生命周期管理是否完整。