Go JSON API 入门:Handler、请求解析和统一响应怎么组织

从一个创建任务接口出发,讲 Go HTTP JSON API 的请求解析、校验、响应结构、错误处理和可测试组织方式。

Go 标准库写 JSON API 并不难,难的是写到第十个接口时还能保持清楚。很多初学项目一开始把所有逻辑塞进 handler:解析 JSON、校验字段、查数据库、拼响应、写错误。前两个接口还行,接口多了以后,错误格式不统一,测试也不好写。

本文用一个“创建任务”的 API 做例子,展示一个轻量但足够实用的组织方式。它不依赖框架,也不追求过度分层,目标是让初学者理解 handler 该负责什么,业务逻辑该放哪里,JSON 响应怎样保持一致。

一个最小请求结构

假设接口是 POST /tasks,请求体如下:

{
  "title": "整理 Go 学习笔记",
  "priority": 2
}

Go 里可以定义请求结构:

type CreateTaskRequest struct {
	Title    string `json:"title"`
	Priority int    `json:"priority"`
}

响应结构:

type TaskResponse struct {
	ID       int64  `json:"id"`
	Title    string `json:"title"`
	Priority int    `json:"priority"`
	Done     bool   `json:"done"`
}

请求结构和响应结构不要偷懒共用一个类型。请求是用户提交的数据,响应是你愿意暴露给外部的数据。它们现在字段相似,不代表永远相同。比如创建任务时不能由用户传 id,响应里也许会多一个 created_at

解码 JSON 要限制大小

Handler 里第一件事是限制请求体大小,然后解码:

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		return err
	}
	return nil
}

MaxBytesReader 可以避免客户端上传一个巨大 body 把内存拖垮。DisallowUnknownFields 可以让拼错字段更早暴露,比如用户传了 pirority,接口会报错,而不是悄悄忽略。对公开 API 来说,这通常更友好。

如果你的 API 需要兼容老客户端,未知字段策略可以放宽。入门阶段先严格一点,更容易发现问题。

校验放在显式函数里

不要把校验散在 handler 的各个角落。可以给请求类型写一个方法:

func (r CreateTaskRequest) Validate() error {
	if strings.TrimSpace(r.Title) == "" {
		return errors.New("title is required")
	}
	if r.Priority < 1 || r.Priority > 5 {
		return errors.New("priority must be between 1 and 5")
	}
	return nil
}

实际项目里你可能会返回结构化错误码,而不是普通字符串。这里先保持简单。关键是校验逻辑有固定位置,测试时可以直接测 Validate,不用每次都启动 HTTP。

func TestCreateTaskRequestValidate(t *testing.T) {
	req := CreateTaskRequest{Title: "", Priority: 2}
	if err := req.Validate(); err == nil {
		t.Fatal("expected error")
	}
}

这样的测试很便宜,能覆盖大量边界条件。

Handler 只做编排

业务逻辑可以放到 service:

type TaskService interface {
	Create(ctx context.Context, input CreateTaskInput) (Task, error)
}

type CreateTaskInput struct {
	Title    string
	Priority int
}

Handler 依赖接口,方便测试:

type TaskHandler struct {
	service TaskService
}

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid_json", "请求 JSON 格式不正确")
		return
	}
	if err := req.Validate(); err != nil {
		writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
		return
	}

	task, err := h.service.Create(r.Context(), CreateTaskInput{
		Title:    strings.TrimSpace(req.Title),
		Priority: req.Priority,
	})
	if err != nil {
		writeError(w, http.StatusInternalServerError, "create_failed", "创建任务失败")
		return
	}

	writeJSON(w, http.StatusCreated, TaskResponse{
		ID:       task.ID,
		Title:    task.Title,
		Priority: task.Priority,
		Done:     task.Done,
	})
}

这段 handler 仍然很直白:解码、校验、调用 service、写响应。它没有直接操作数据库,也没有把 HTTP 细节传进业务层。r.Context() 传给 service,意味着客户端断开或请求超时可以继续向下传播。

统一写 JSON 响应

响应辅助函数可以保持小而明确:

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("write json response: %v", err)
	}
}

type ErrorResponse struct {
	Error ErrorBody `json:"error"`
}

type ErrorBody struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func writeError(w http.ResponseWriter, status int, code, message string) {
	writeJSON(w, status, ErrorResponse{
		Error: ErrorBody{Code: code, Message: message},
	})
}

不要在有些接口返回 {"error":"bad"},有些接口返回 {"message":"bad"}。统一格式能让前端和调用方少写很多判断。错误码也很重要,文案可以调整,错误码应该稳定。

测试 handler

因为 handler 依赖接口,所以可以写 fake service:

type fakeTaskService struct {
	task Task
	err  error
}

func (f fakeTaskService) Create(ctx context.Context, input CreateTaskInput) (Task, error) {
	return f.task, f.err
}

测试成功响应:

func TestCreateTask(t *testing.T) {
	h := &TaskHandler{service: fakeTaskService{
		task: Task{ID: 1, Title: "写测试", Priority: 2},
	}}

	body := strings.NewReader(`{"title":"写测试","priority":2}`)
	req := httptest.NewRequest(http.MethodPost, "/tasks", body)
	rec := httptest.NewRecorder()

	h.Create(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), `"id":1`) {
		t.Fatalf("body = %s", rec.Body.String())
	}
}

你也可以测试非法 JSON、缺 title、priority 越界、service 返回错误等场景。handler 测试不需要真实数据库,重点是验证 HTTP 层行为。

常见小坑

第一,不要忘记设置 Content-Type。虽然很多客户端能猜出来,但 API 应该明确告诉对方返回的是 JSON。

第二,不要把内部错误原样返回给用户。数据库错误、外部服务地址、堆栈信息都不应该出现在响应里。日志记录详细错误,响应返回稳定错误码和安全文案。

第三,不要忽略 Encode 错误。写响应失败通常是客户端断开,不能再补救,但至少要记录日志。它不应该让服务崩溃。

第四,路径参数、查询参数和 JSON body 要分清。比如 /tasks/{id} 的 id 来自 URL,分页参数来自 query,创建数据来自 body。每种输入都要独立校验。

小结

Go 标准库完全可以写清楚的 JSON API。一个实用结构是:请求和响应类型分开,解码时限制大小并处理未知字段,校验放在明确函数里,handler 只做编排,业务逻辑放到 service,响应格式统一。

入门阶段不要急着上复杂框架。先用标准库把边界写清楚,你会更理解 HTTP API 的基本形状。以后换框架时,这些习惯仍然有用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页