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

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

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 的基本形状。以后换框架时,这些习惯仍然有用。

常见问题与解答

请求和响应结构体要不要共用?

不要。请求结构体校验用户输入,响应结构体控制对外暴露的数据。即使字段一样,也建议分开定义。未来需求变化时,改动的影响范围更小。

handler 里要不要加 recover?

Go HTTP server 默认会 recover handler panic,但只会打印日志,不会返回友好错误。如果你想自定义 panic 处理,可以包装 Recovery 中间件:

func recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic: %v", err)
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

怎么统一处理 not found?

自定义 ServeMux 可以捕获未匹配路由:

func customMux() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/" {
            writeError(w, http.StatusNotFound, "not_found", "resource not found")
            return
        }
        // handle root
    })
    return mux
}

请求 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 = generateID()
        }
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), requestIDKey{}, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

请求 ID 贯穿整个链路,日志和错误排查时非常有用。

内容协商

根据 Accept header 返回不同格式:

func negotiateContentType(r *http.Request) string {
    accept := r.Header.Get("Accept")
    if strings.Contains(accept, "application/json") {
        return "json"
    }
    if strings.Contains(accept, "text/csv") {
        return "csv"
    }
    return "json"
}

实践练习

完成以下练习以巩固所学知识:

  1. 阅读 Go 官方文档相关章节
  2. 编写一个完整的示例程序
  3. 为示例程序编写单元测试
  4. 使用 go test 和 go benchmark 验证实现
  5. 尝试优化内存分配和运行时间

推荐阅读

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./... 和 golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroup 和 context 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南