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 的基本形状。以后换框架时,这些习惯仍然有用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。