第一次用 Go 写 HTTP 服务时,最容易走进一种看起来很顺、后面却越来越乱的路径:
先写一个 handler
在里面直接读参数
校验参数
调业务逻辑
出错就 http.Error
顺手打几行日志
第一版通常很快就能跑起来。 但只要接口稍微多一点,很快就会出现这些问题:
每个 handler 都在自己解析参数
校验错误的返回格式不一致
有的接口返回字符串,有的接口返回 JSON
日志里能看到“失败了”,但看不出是哪条请求、哪个参数、哪一层失败
路由、业务、错误和日志混在一个函数里,测试也很难补
这类问题通常不是因为 Go 写 HTTP 太麻烦,而是因为边界没有先立起来 。
写 HTTP 服务时,至少要先把四件事分清:
路由负责把请求导向正确 handler
参数校验负责把非法输入挡在边界层
错误返回负责把内部失败转换成稳定响应
日志负责留下排障信息,而不是每层都重复打印
这一篇就围绕一个实际小场景来讲:做一个最小任务触发服务 。
这个服务要提供几个接口:
POST /jobs:触发任务
GET /jobs/{id}:查询任务状态
GET /health:健康检查
同时要满足这些要求:
参数校验要统一
错误返回格式要稳定
日志里要能看到请求路径、状态码和错误上下文
handler 本身不能变成一团流程脚本
一、这篇文章要解决什么问题 读完这一篇,应该能独立回答这些问题:
Go 的 HTTP handler 该承担哪些职责
路由、参数校验、错误返回、日志分别放哪一层更合适
什么时候应该返回 400、404、409、500
错误如何从业务层映射成 HTTP 响应
日志应该在哪打,打什么,不该打什么
怎么给 handler 补最小测试
如果这些问题能答清,后面再写真正的 API 服务,结构会稳很多。
二、先看这篇文章里的实际场景 假设现在要做一个最小任务平台接口,需求很直接:
用户提交任务名和目标环境
服务生成任务 ID
重复任务名不能重复创建
可以按任务 ID 查询状态
每次请求都要留下可查日志
这里最容易写成一个大 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 mainimport ( "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 )) }
这个版本能跑,但已经暴露出三个问题:
参数解析和参数校验混在 handler 里
错误返回还是裸字符串
日志只有启动日志,根本看不到请求链路
所以它适合做起点,不适合做终点。
四、先把一句话结论说清楚 Go HTTP 服务更稳的结构通常不是“一个 handler 包办一切”,而是:
handler 只处理 HTTP 边界
service 只处理业务规则
错误在边界层统一映射成响应
日志在靠近入口的位置统一记录
如果这四层没分开,服务一长大就会开始互相污染。
五、路由到底在解决什么问题 路由解决的是:某个请求该交给哪个 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 更合适承担这些职责:
读取请求
解析参数
调用 service
把 service 的结果写回 HTTP 响应
不太适合直接承担这些职责:
写复杂业务判断
直接拼所有错误文本
在每个分支都自己打日志
自己决定所有状态码映射细节
一个简单判断方式是:
如果一段逻辑去掉 HTTP 以后依然成立,那它多半不该写在 handler 里。
七、参数校验为什么不能只靠 if req.Name == "" 参数校验最常见的第一版,通常就是:
1 2 3 4 if req.Name == "" || req.Env == "" { http.Error(w, "bad request" , http.StatusBadRequest) return }
问题不是这句不能用,而是它很快会不够:
字段为空和字段格式非法要不要区分
环境名是不是只允许 test、staging、prod
长度超限要不要单独报错
错误返回结构是不是统一
如果这些都散落在 handler 里,后面每多一个接口就多一套校验分支。
八、先把请求结构和校验结构分开 更稳的方式通常是:
request struct 只负责承接输入
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 最终统一记录请求日志
也就是说:
错误值负责携带信息
边界日志负责记录这次请求发生了什么
十三、日志里最值得先保留什么字段 入门阶段不用先搞很重的日志平台,但至少要把这几类信息打齐:
请求方法
请求路径
状态码
耗时
错误信息
请求 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 appimport ( "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}
线上出现了三个现象:
创建任务时偶发 409
查询接口经常有人反馈 404
某些请求整体耗时升高,但看不出卡在哪
如果当前代码是每个 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) { }
这种写法的问题是:
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,很快就会失控。 至少应该补这几类测试:
非法 JSON
参数缺失
业务冲突
正常成功
一个最小测试示例:
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 appimport ( "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) } }
这组测试虽然很小,但已经把最关键的边界测住了。
二十二、怎么验证日志和状态码是不是一起工作 很多服务的问题不是“没日志”,而是日志和响应根本对不上。
一个最直接的验证方式是:
发一条非法请求
确认响应状态码是 400
再看日志里是不是同样出现了这次请求的路径和状态
如果响应已经是 409,但日志还都写成 status=200,那中间件或 recorder 通常就有问题。
二十三、一个高频排错场景:为什么用户看到 500,但日志里只有一句 decode failed 这类问题通常说明边界混了。
排查顺序通常是:
先看错误是在 JSON 解析、参数校验还是 service 执行阶段发生的
再看错误映射层是不是把某类业务错误误归成了 500
再看日志里有没有请求路径、状态码和原始错误上下文
如果最终发现:
service 返回的是 ErrJobDuplicated
但 writeMappedError 没处理这类错误
那根因就不是“数据库偶发异常”,而是错误映射漏了分支。
二十四、这篇文章的边界在哪里 这一篇重点讲的是最小 HTTP 服务骨架:
先不展开这些更大的话题:
认证鉴权中间件
tracing 和 metrics
OpenAPI 生成
更复杂的路由参数和版本治理
这些更适合放到后面的工程实践或平台文章里继续展开。
二十五、什么时候不该过度设计 这也是 HTTP 服务入门里很重要的边界。
如果当前服务只有:
1 到 2 个接口
很简单的参数
没有复杂错误分类
那不需要一上来就搭很多目录和抽象层。
但即使是最小服务,也最好先守住这几件事:
handler 不要包办业务
响应格式先统一
错误码映射先统一
请求日志先统一
只要这四件事先立住,后面接口增长时不会太痛苦。
二十六、一个实际练习 可以直接把这一篇改造成一个完整练习。
练习目标:做一个最小任务触发服务。
要求:
提供 POST /jobs 和 GET /jobs/{id}
请求参数统一用 struct 承接
校验逻辑独立出来
错误统一映射成 JSON 响应
用日志中间件记录方法、路径、状态码、耗时
至少补 3 组 handler 测试
如果这个练习能独立做完,说明 HTTP 服务已经不只是“会用 net/http”,而是开始有边界设计意识了。
二十七、这篇文章学完以后,下一步应该补什么 这一篇解决的是:
HTTP 边界层怎么立
参数校验、错误返回和日志怎么一起工作
接下来最适合继续补的是:
配置管理、依赖注入和初始化顺序怎么处理
Go 单元测试怎么写才不是只测 happy path
表驱动测试、mock 和集成测试怎么分层
因为到这一步,服务已经能接请求了。 后面真正会复杂起来的,是启动阶段怎么接线、测试怎么补、结构怎么维持。
二十八、结语 Go 写 HTTP 服务最容易犯的错,不是语法写错,而是把所有事情都塞进 handler。
只要先把四层分开:
路由只做导向
handler 只管 HTTP 边界
service 只管业务规则
错误映射和日志统一收口
后面的接口即使继续长大,也不会那么容易变成一堆互相粘住的流程脚本。
HTTP 服务真正难的,不是把请求收进来,而是让输入、输出、错误和排障都保持一致。