Go:写 HTTP 服务时,路由、参数校验、错误返回和日志怎么一起设计

第一次用 Go 写 HTTP 服务时,最容易走进一种看起来很顺、后面却越来越乱的路径:

  • 先写一个 handler
  • 在里面直接读参数
  • 校验参数
  • 调业务逻辑
  • 出错就 http.Error
  • 顺手打几行日志

第一版通常很快就能跑起来。
但只要接口稍微多一点,很快就会出现这些问题:

  • 每个 handler 都在自己解析参数
  • 校验错误的返回格式不一致
  • 有的接口返回字符串,有的接口返回 JSON
  • 日志里能看到“失败了”,但看不出是哪条请求、哪个参数、哪一层失败
  • 路由、业务、错误和日志混在一个函数里,测试也很难补

这类问题通常不是因为 Go 写 HTTP 太麻烦,而是因为边界没有先立起来

写 HTTP 服务时,至少要先把四件事分清:

  1. 路由负责把请求导向正确 handler
  2. 参数校验负责把非法输入挡在边界层
  3. 错误返回负责把内部失败转换成稳定响应
  4. 日志负责留下排障信息,而不是每层都重复打印

这一篇就围绕一个实际小场景来讲:做一个最小任务触发服务

这个服务要提供几个接口:

  • POST /jobs:触发任务
  • GET /jobs/{id}:查询任务状态
  • GET /health:健康检查

同时要满足这些要求:

  1. 参数校验要统一
  2. 错误返回格式要稳定
  3. 日志里要能看到请求路径、状态码和错误上下文
  4. handler 本身不能变成一团流程脚本

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. Go 的 HTTP handler 该承担哪些职责
  2. 路由、参数校验、错误返回、日志分别放哪一层更合适
  3. 什么时候应该返回 400、404、409、500
  4. 错误如何从业务层映射成 HTTP 响应
  5. 日志应该在哪打,打什么,不该打什么
  6. 怎么给 handler 补最小测试

如果这些问题能答清,后面再写真正的 API 服务,结构会稳很多。

二、先看这篇文章里的实际场景

假设现在要做一个最小任务平台接口,需求很直接:

  1. 用户提交任务名和目标环境
  2. 服务生成任务 ID
  3. 重复任务名不能重复创建
  4. 可以按任务 ID 查询状态
  5. 每次请求都要留下可查日志

这里最容易写成一个大 handler:

  • 自己解析 JSON
  • 自己判断字段
  • 自己构造错误文本
  • 自己决定状态码
  • 自己打印所有日志

这种写法短期能跑,长期最容易出现:

  • 同类错误在不同接口里返回不一致
  • 一改响应结构就要改很多处
  • 日志格式散乱,排障全靠猜
  • 服务层和 HTTP 层纠缠,单元测试很难写

三、先给一个最小可运行示例

先不要一上来就拆太多层,先搭一条最小请求链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"encoding/json"
"log"
"net/http"
)

type CreateJobRequest struct {
Name string `json:"name"`
Env string `json:"env"`
}

func createJobHandler(w http.ResponseWriter, r *http.Request) {
var req CreateJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Name == "" || req.Env == "" {
http.Error(w, "name and env are required", http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"id": "job-1001",
})
}

func main() {
http.HandleFunc("/jobs", createJobHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}

这个版本能跑,但已经暴露出三个问题:

  1. 参数解析和参数校验混在 handler 里
  2. 错误返回还是裸字符串
  3. 日志只有启动日志,根本看不到请求链路

所以它适合做起点,不适合做终点。

四、先把一句话结论说清楚

Go HTTP 服务更稳的结构通常不是“一个 handler 包办一切”,而是:

  1. handler 只处理 HTTP 边界
  2. service 只处理业务规则
  3. 错误在边界层统一映射成响应
  4. 日志在靠近入口的位置统一记录

如果这四层没分开,服务一长大就会开始互相污染。

五、路由到底在解决什么问题

路由解决的是:某个请求该交给哪个 handler。

它不应该顺手承担这些事:

  • 业务逻辑
  • 复杂参数校验
  • 数据库存取
  • 随意拼响应结构

例如:

1
2
3
4
mux := http.NewServeMux()
mux.HandleFunc("POST /jobs", app.handleCreateJob)
mux.HandleFunc("GET /jobs/", app.handleGetJob)
mux.HandleFunc("GET /health", app.handleHealth)

从这个层面上,路由只应该表达:

  • 路径
  • 方法
  • 入口函数

先把这一层守住,后面接口变多时不会太乱。

六、handler 到底该做什么,不该做什么

handler 更合适承担这些职责:

  1. 读取请求
  2. 解析参数
  3. 调用 service
  4. 把 service 的结果写回 HTTP 响应

不太适合直接承担这些职责:

  1. 写复杂业务判断
  2. 直接拼所有错误文本
  3. 在每个分支都自己打日志
  4. 自己决定所有状态码映射细节

一个简单判断方式是:

如果一段逻辑去掉 HTTP 以后依然成立,那它多半不该写在 handler 里。

七、参数校验为什么不能只靠 if req.Name == ""

参数校验最常见的第一版,通常就是:

1
2
3
4
if req.Name == "" || req.Env == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

问题不是这句不能用,而是它很快会不够:

  • 字段为空和字段格式非法要不要区分
  • 环境名是不是只允许 teststagingprod
  • 长度超限要不要单独报错
  • 错误返回结构是不是统一

如果这些都散落在 handler 里,后面每多一个接口就多一套校验分支。

八、先把请求结构和校验结构分开

更稳的方式通常是:

  1. request struct 只负责承接输入
  2. Validate() 或单独 validator 负责校验

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type CreateJobRequest struct {
Name string `json:"name"`
Env string `json:"env"`
}

func (r CreateJobRequest) Validate() error {
if r.Name == "" {
return ErrInvalidName
}
if r.Env == "" {
return ErrInvalidEnv
}
switch r.Env {
case "test", "staging", "prod":
return nil
default:
return ErrInvalidEnv
}
}

这样至少先把“承接输入”和“校验规则”分开了。

九、错误返回为什么要先统一格式

如果错误返回没有统一格式,最常见的后果是:

  • 前端或调用方很难稳定处理
  • 排障时同类错误表现完全不同
  • 每个 handler 都在自己发明错误协议

一个最小统一格式通常就够用了:

1
2
3
4
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}

例如返回:

1
2
3
4
{
"code": "invalid_argument",
"message": "env is invalid"
}

这比直接返回 "bad request" 要稳定得多。

十、为什么不能在 service 里直接写 HTTP 状态码

这是 Go HTTP 服务里非常高频的一种混乱。

例如 service 里直接返回:

1
return nil, 400, fmt.Errorf("invalid env")

或者:

1
return &HTTPError{StatusCode: 409, Message: "duplicate"}

这种写法不是完全不能跑,但会很快把业务层绑死在 HTTP 上。

更稳的边界通常是:

  • service 返回业务错误
  • handler 或错误映射层把业务错误转换成 HTTP 状态码

十一、先定义业务错误,再做 HTTP 映射

例如 service 层只关心业务语义:

1
2
3
4
5
6
var (
ErrInvalidName = errors.New("invalid job name")
ErrInvalidEnv = errors.New("invalid env")
ErrJobDuplicated = errors.New("job already exists")
ErrJobNotFound = errors.New("job not found")
)

然后在 HTTP 层统一映射:

1
2
3
4
5
6
7
8
9
10
11
12
func statusFromError(err error) int {
switch {
case errors.Is(err, ErrInvalidName), errors.Is(err, ErrInvalidEnv):
return http.StatusBadRequest
case errors.Is(err, ErrJobDuplicated):
return http.StatusConflict
case errors.Is(err, ErrJobNotFound):
return http.StatusNotFound
default:
return http.StatusInternalServerError
}
}

这一步的价值是:

  • 业务层不用知道 HTTP
  • HTTP 层可以统一控制状态码策略

十二、日志为什么更适合在边界层统一打

最常见的坏味道之一是每一层都写:

1
log.Printf("create job failed: %v", err)

最后请求失败一次,日志出现三到五条,看起来很热闹,实际排障却更慢。

更合适的边界通常是:

  • 底层返回错误
  • 入口中间件或 handler 最终统一记录请求日志

也就是说:

  • 错误值负责携带信息
  • 边界日志负责记录这次请求发生了什么

十三、日志里最值得先保留什么字段

入门阶段不用先搞很重的日志平台,但至少要把这几类信息打齐:

  1. 请求方法
  2. 请求路径
  3. 状态码
  4. 耗时
  5. 错误信息
  6. 请求 ID

例如:

1
request_id=req-1001 method=POST path=/jobs status=409 duration=12ms err="job already exists"

这样至少能回答:

  • 哪个接口出问题
  • 返回了什么状态
  • 慢不慢
  • 错误是什么

十四、一个更完整的最小服务骨架

先把路由、handler、service 和错误映射串起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package app

import (
"context"
"encoding/json"
"errors"
"net/http"
)

var (
ErrInvalidName = errors.New("invalid job name")
ErrInvalidEnv = errors.New("invalid env")
ErrJobDuplicated = errors.New("job already exists")
ErrJobNotFound = errors.New("job not found")
)

type CreateJobRequest struct {
Name string `json:"name"`
Env string `json:"env"`
}

func (r CreateJobRequest) Validate() error {
if r.Name == "" {
return ErrInvalidName
}
switch r.Env {
case "test", "staging", "prod":
return nil
default:
return ErrInvalidEnv
}
}

type Job struct {
ID string `json:"id"`
Name string `json:"name"`
Env string `json:"env"`
Status string `json:"status"`
}

type Service interface {
CreateJob(ctx context.Context, req CreateJobRequest) (Job, error)
GetJob(ctx context.Context, id string) (Job, error)
}

type App struct {
service Service
}

func New(service Service) *App {
return &App{service: service}
}

func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /jobs", a.handleCreateJob)
mux.HandleFunc("GET /jobs/", a.handleGetJob)
mux.HandleFunc("GET /health", a.handleHealth)
return loggingMiddleware(mux)
}

func (a *App) handleCreateJob(w http.ResponseWriter, r *http.Request) {
var req CreateJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "request body is invalid")
return
}
if err := req.Validate(); err != nil {
writeMappedError(w, err)
return
}

job, err := a.service.CreateJob(r.Context(), req)
if err != nil {
writeMappedError(w, err)
return
}

writeJSON(w, http.StatusCreated, job)
}

func (a *App) handleGetJob(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "invalid_argument", "id is required")
return
}

job, err := a.service.GetJob(r.Context(), id)
if err != nil {
writeMappedError(w, err)
return
}

writeJSON(w, http.StatusOK, job)
}

func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

这个骨架的重点不在于“已经很完整”,而在于边界开始清楚了。

十五、把响应写出动作也统一掉

如果每个 handler 都自己写:

  • w.Header().Set(...)
  • w.WriteHeader(...)
  • json.NewEncoder(w).Encode(...)

后面很快会出现细节漂移。

更稳的做法通常是统一成两个辅助函数:

1
2
3
4
5
6
7
8
9
10
11
12
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}

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

这样接口多了以后,响应格式不容易漂掉。

十六、错误映射层到底该长什么样

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func writeMappedError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrInvalidName):
writeError(w, http.StatusBadRequest, "invalid_name", "name is invalid")
case errors.Is(err, ErrInvalidEnv):
writeError(w, http.StatusBadRequest, "invalid_env", "env is invalid")
case errors.Is(err, ErrJobDuplicated):
writeError(w, http.StatusConflict, "job_duplicated", "job already exists")
case errors.Is(err, ErrJobNotFound):
writeError(w, http.StatusNotFound, "job_not_found", "job not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
}

这一步最关键的不是 switch 本身,而是把“业务错误”和“HTTP 响应”这两个关注点清晰地连接起来。

十七、日志中间件为什么比在每个 handler 手写更稳

如果每个 handler 都手写日志,最容易出现:

  • 字段不统一
  • 漏记状态码
  • 漏记耗时
  • 正常请求和错误请求口径不同

更合适的起点通常是一个最小日志中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type statusRecorder struct {
http.ResponseWriter
status int
}

func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}

func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{
ResponseWriter: w,
status: http.StatusOK,
}

next.ServeHTTP(rec, r)

log.Printf(
"method=%s path=%s status=%d duration=%s",
r.Method,
r.URL.Path,
rec.status,
time.Since(start),
)
})
}

这一步至少能把请求级日志统一起来。

十八、一个更接近项目现场的小案例

假设现在有两个接口:

  • POST /jobs
  • GET /jobs/{id}

线上出现了三个现象:

  1. 创建任务时偶发 409
  2. 查询接口经常有人反馈 404
  3. 某些请求整体耗时升高,但看不出卡在哪

如果当前代码是每个 handler 自己随意打印日志,就很难快速回答:

  • 409 是重复任务,还是参数问题
  • 404 是路径不对,还是任务真的不存在
  • 是某一类请求慢,还是所有请求都慢

如果一开始就把日志字段、错误码和状态码映射统一起来,就至少能快速看出:

  • status=409 code=job_duplicated
  • status=404 code=job_not_found
  • method=POST path=/jobs duration=780ms

这时候排查就不再是纯猜。

十九、一个常见错误示例:handler 直接操作仓储层

例如:

1
2
3
4
5
6
7
func (a *App) handleCreateJob(w http.ResponseWriter, r *http.Request) {
// decode
// validate
// repo.Insert(...)
// if duplicate ...
// write response
}

这种写法的问题是:

  • HTTP 层知道了太多底层细节
  • 业务规则没地方沉淀
  • 后面想加 CLI、定时任务或别的入口时无法复用

更稳的方式通常是:

  • handler 调 service
  • service 再调 repo

这样 HTTP 只是其中一个入口,不是整个业务本体。

二十、一个常见错误示例:返回码全是 200

有些服务会写成:

1
2
3
4
{
"success": false,
"message": "job not found"
}

但 HTTP 状态还是 200。

这会直接导致:

  • 网关和监控看不出失败比例
  • 调用方要自己再解析一层业务成功与否
  • 错误语义和 HTTP 语义彻底脱节

对这类服务,更合理的做法通常是:

  • 参数错就 400
  • 找不到就 404
  • 冲突就 409
  • 内部异常就 500

二十一、怎么给 handler 补最小测试

HTTP 服务如果只靠手工 curl,很快就会失控。
至少应该补这几类测试:

  1. 非法 JSON
  2. 参数缺失
  3. 业务冲突
  4. 正常成功

一个最小测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package app

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
)

type fakeService struct {
createFunc func(context.Context, CreateJobRequest) (Job, error)
}

func (f fakeService) CreateJob(ctx context.Context, req CreateJobRequest) (Job, error) {
return f.createFunc(ctx, req)
}

func (f fakeService) GetJob(context.Context, string) (Job, error) {
return Job{}, nil
}

func TestCreateJobBadRequest(t *testing.T) {
app := New(fakeService{
createFunc: func(context.Context, CreateJobRequest) (Job, error) {
return Job{}, nil
},
})

req := httptest.NewRequest(http.MethodPost, "/jobs", bytes.NewBufferString(`{"name":"","env":"test"}`))
rec := httptest.NewRecorder()

app.Routes().ServeHTTP(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}

func TestCreateJobConflict(t *testing.T) {
app := New(fakeService{
createFunc: func(context.Context, CreateJobRequest) (Job, error) {
return Job{}, ErrJobDuplicated
},
})

req := httptest.NewRequest(http.MethodPost, "/jobs", bytes.NewBufferString(`{"name":"daily-sync","env":"test"}`))
rec := httptest.NewRecorder()

app.Routes().ServeHTTP(rec, req)

if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d", rec.Code)
}
}

这组测试虽然很小,但已经把最关键的边界测住了。

二十二、怎么验证日志和状态码是不是一起工作

很多服务的问题不是“没日志”,而是日志和响应根本对不上。

一个最直接的验证方式是:

  1. 发一条非法请求
  2. 确认响应状态码是 400
  3. 再看日志里是不是同样出现了这次请求的路径和状态

如果响应已经是 409,但日志还都写成 status=200,那中间件或 recorder 通常就有问题。

二十三、一个高频排错场景:为什么用户看到 500,但日志里只有一句 decode failed

这类问题通常说明边界混了。

排查顺序通常是:

  1. 先看错误是在 JSON 解析、参数校验还是 service 执行阶段发生的
  2. 再看错误映射层是不是把某类业务错误误归成了 500
  3. 再看日志里有没有请求路径、状态码和原始错误上下文

如果最终发现:

  • service 返回的是 ErrJobDuplicated
  • writeMappedError 没处理这类错误

那根因就不是“数据库偶发异常”,而是错误映射漏了分支。

二十四、这篇文章的边界在哪里

这一篇重点讲的是最小 HTTP 服务骨架:

  • 路由
  • 参数校验
  • 错误返回
  • 请求日志

先不展开这些更大的话题:

  • 认证鉴权中间件
  • tracing 和 metrics
  • OpenAPI 生成
  • 更复杂的路由参数和版本治理

这些更适合放到后面的工程实践或平台文章里继续展开。

二十五、什么时候不该过度设计

这也是 HTTP 服务入门里很重要的边界。

如果当前服务只有:

  • 1 到 2 个接口
  • 很简单的参数
  • 没有复杂错误分类

那不需要一上来就搭很多目录和抽象层。

但即使是最小服务,也最好先守住这几件事:

  • handler 不要包办业务
  • 响应格式先统一
  • 错误码映射先统一
  • 请求日志先统一

只要这四件事先立住,后面接口增长时不会太痛苦。

二十六、一个实际练习

可以直接把这一篇改造成一个完整练习。

练习目标:做一个最小任务触发服务。

要求:

  1. 提供 POST /jobsGET /jobs/{id}
  2. 请求参数统一用 struct 承接
  3. 校验逻辑独立出来
  4. 错误统一映射成 JSON 响应
  5. 用日志中间件记录方法、路径、状态码、耗时
  6. 至少补 3 组 handler 测试

如果这个练习能独立做完,说明 HTTP 服务已经不只是“会用 net/http”,而是开始有边界设计意识了。

二十七、这篇文章学完以后,下一步应该补什么

这一篇解决的是:

  • HTTP 边界层怎么立
  • 参数校验、错误返回和日志怎么一起工作

接下来最适合继续补的是:

  1. 配置管理、依赖注入和初始化顺序怎么处理
  2. Go 单元测试怎么写才不是只测 happy path
  3. 表驱动测试、mock 和集成测试怎么分层

因为到这一步,服务已经能接请求了。
后面真正会复杂起来的,是启动阶段怎么接线、测试怎么补、结构怎么维持。

二十八、结语

Go 写 HTTP 服务最容易犯的错,不是语法写错,而是把所有事情都塞进 handler。

只要先把四层分开:

  • 路由只做导向
  • handler 只管 HTTP 边界
  • service 只管业务规则
  • 错误映射和日志统一收口

后面的接口即使继续长大,也不会那么容易变成一堆互相粘住的流程脚本。

HTTP 服务真正难的,不是把请求收进来,而是让输入、输出、错误和排障都保持一致。