Go 实战:写一个 HTTP 服务
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
不用框架也能写出结构清晰的 HTTP 服务。Go 1.22 起 ServeMux 支持方法和路径参数,标准库已经覆盖了大部分需求。
目标
一个文章接口:
| 方法 | 路径 | 行为 |
|---|---|---|
| GET | /healthz |
健康检查 |
| GET | /api/posts |
列表 |
| POST | /api/posts |
创建(201 + Location) |
| GET | /api/posts/{id} |
单条(404 用 JSON) |
数据先用内存 + sync.RWMutex 存,后面换数据库只改 store 层。
目录
posts/
go.mod
main.go
internal/post/store.go
internal/post/handler.go
internal/httpx/respond.go
internal 保证这些包不被外部模块导入。
统一响应
// internal/httpx/respond.go
package httpx
import (
"encoding/json"
"net/http"
)
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func JSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func Error(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
JSON(w, status, body)
}
先 Set 头再 WriteHeader,顺序反了头就不生效;WriteHeader 之后也无法再改状态码。
数据层
// internal/post/store.go
package post
import (
"errors"
"sync"
)
var ErrNotFound = errors.New("文章不存在")
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
type Store struct {
mu sync.RWMutex
seq int
items map[int]Post
}
func NewStore() *Store {
return &Store{items: make(map[int]Post)}
}
func (s *Store) Create(title, body string) Post {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
p := Post{ID: s.seq, Title: title, Body: body}
s.items[p.ID] = p
return p
}
func (s *Store) List() []Post {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Post, 0, len(s.items))
for _, p := range s.items {
out = append(out, p)
}
return out
}
func (s *Store) Get(id int) (Post, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.items[id]
if !ok {
return Post{}, ErrNotFound
}
return p, nil
}
返回 []Post 而不是内部 map,避免调用方在锁外遍历共享状态。
路由
// main.go
func newRouter(store *post.Store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /api/posts", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{"data": store.List()})
})
mux.HandleFunc("POST /api/posts", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid_json", "请求体不是合法 JSON")
return
}
if strings.TrimSpace(input.Title) == "" {
httpx.Error(w, http.StatusUnprocessableEntity, "validation_failed", "title 不能为空")
return
}
p := store.Create(strings.TrimSpace(input.Title), input.Body)
w.Header().Set("Location", fmt.Sprintf("/api/posts/%d", p.ID))
httpx.JSON(w, http.StatusCreated, map[string]any{"data": p})
})
mux.HandleFunc("GET /api/posts/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid_id", "id 必须是整数")
return
}
p, err := store.Get(id)
if errors.Is(err, post.ErrNotFound) {
httpx.Error(w, http.StatusNotFound, "not_found", "文章不存在")
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"data": p})
})
return mux
}
要点:
"METHOD /path"是 Go 1.22 起的新语法,方法不匹配时ServeMux自动返回 405。{id}通过r.PathValue("id")取,不用自己切字符串。http.MaxBytesReader限制请求体大小,防止内存被打满。DisallowUnknownFields让拼错的字段直接报错,而不是静默忽略。
中间件
func withRequestID(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 = rand.Text() // Go 1.24 起的快捷随机字符串
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r)
})
}
func withLogging(logger *slog.Logger, 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)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", w.Header().Get("X-Request-Id"),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
包装 ResponseWriter 才能拿到真实状态码。如果需要 Flush / Hijack,也要转发对应接口,否则流式响应会失效。
服务启动与超时
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := post.NewStore()
handler := withRequestID(withLogging(logger, newRouter(store)))
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("启动失败", "err", err)
os.Exit(1)
}
}
四个超时都要设:
ReadHeaderTimeout防慢速 header 攻击;ReadTimeout限制整个请求体读取;WriteTimeout限制响应写出;IdleTimeout控制 keep-alive 空闲连接。
默认全是 0(不限制),慢客户端可以一直占着连接。
优雅停机
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("启动失败", "err", err)
stop()
}
}()
<-ctx.Done()
logger.Info("收到退出信号,开始停机")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("停机超时", "err", err)
}
Shutdown 会停止接受新连接,等待进行中的请求完成(最多 10 秒)。容器环境里这段时间要小于编排系统的 terminationGracePeriodSeconds。
验收
go run .
curl -s localhost:8080/healthz
curl -s localhost:8080/api/posts
curl -s -i -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"第一篇","body":"内容"}'
curl -s -i localhost:8080/api/posts/1
curl -s -i localhost:8080/api/posts/999
curl -s -i -X POST localhost:8080/api/posts -H 'Content-Type: application/json' -d '{"title":""}'
curl -s -i -X DELETE localhost:8080/api/posts/1
逐项确认:201 带 Location、404 是 JSON、422 带 validation_failed、未定义的方法返回 405、响应头有 X-Request-Id。
单元测试:
func TestCreatePost(t *testing.T) {
srv := httptest.NewServer(newRouter(post.NewStore()))
defer srv.Close()
body := strings.NewReader(`{"title":"hello"}`)
res, err := http.Post(srv.URL+"/api/posts", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("状态码 = %d,期望 201", res.StatusCode)
}
}
常见错误与注意事项
1. 忘记 Content-Type
客户端按文本处理 JSON,前端 res.json() 直接失败。
2. handler 里 panic 导致连接被断
标准库会 recover 并打印堆栈,但连接直接关闭且没有响应体。加一层 recover 中间件返回 500 JSON。
3. 直接读 r.Body 不限制大小
恶意请求可以传几个 GB。用 http.MaxBytesReader。
4. 在 handler 里用 context.Background()
丢掉了客户端断开与超时信号。始终用 r.Context() 派生。
5. 关闭服务用 os.Exit
正在处理的请求被硬切断,用户看到连接重置。用 Shutdown。
6. 忘了 srv.Close()(测试里)
httptest.NewServer 必须 defer srv.Close(),否则测试进程会积累监听端口。
7. 把 http.ErrServerClosed 当错误
正常停机时 ListenAndServe 返回它,别记成 error 日志。
8. 在 handler 里长时间持锁
RLock 期间做 I/O 会阻塞写操作。锁只保护内存读写,业务逻辑放锁外。
小结
ServeMux的"METHOD /path"+PathValue已能满足大部分路由需求。- 响应统一走
httpx.JSON/httpx.Error,状态码与 JSON 结构一次定好。 - 请求体限制大小、拒绝未知字段、错误输入返回 422。
- 中间件负责请求 ID 与日志,需要真实状态码时包装
ResponseWriter。 - 四个超时 +
Shutdown是上生产的最低要求。