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"
}
实践练习
完成以下练习以巩固所学知识:
- 阅读 Go 官方文档相关章节
- 编写一个完整的示例程序
- 为示例程序编写单元测试
- 使用
go test和go benchmark验证实现 - 尝试优化内存分配和运行时间
推荐阅读
- Go 官方博客: https://go.dev/blog/
- Effective Go: https://go.dev/doc/effective_go
- Go by Example: https://gobyexample.com/
- Go 标准库文档: https://pkg.go.dev/std
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 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 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- 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 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 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 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。