Go 实战:写一个 HTTP 服务

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Go 1.24

不用框架也能写出结构清晰的 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 是上生产的最低要求。

下一步