
HTTP 服务
net/http 是 Go 标准库里最容易"几行代码跑起来"的包:创建一个 handler,调用 ListenAndServe,服务就能接受请求。但能启动不等于能长期运行。
一个高可用 HTTP 服务还要回答这些问题:
- 请求如何路由到正确的插件?
- 一个中间件发生 panic,是否会拖垮整个进程?
- 客户端迟迟不发完请求,服务端是否一直占着连接?
- 上游服务卡住时,当前请求什么时候结束?
- 发布新版本时,如何停止接收新请求并等待旧请求完成?
/healthz和/readyz分别表达什么状态?- 如何用
httptest测试 handler,而不是启动真实端口?
Go 官方将 net/http 描述为 HTTP 客户端和服务端实现。它提供 Handler、ServeMux、Server、Client、Transport 等核心类型。本文会从最小服务讲起,最后实现一个可插拔的高可用服务骨架:插件注册路由,中间件统一处理请求 ID、日志、恢复和超时,服务支持健康检查、就绪状态和优雅关闭。
本文的核心判断是:高可用 HTTP 代码首先要控制生命周期和边界,其次才是添加业务路由。
本文代码按 Go 1.27 标准库整理。端口、退出耗时和日志时间会因运行环境不同而变化。
最小 HTTP 服务
package main
import (
"fmt"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello, http")
}
func main() {
http.HandleFunc("/", hello)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
HandleFunc 把函数转换成 HandlerFunc,默认注册到 http.DefaultServeMux。ListenAndServe 创建服务并阻塞等待请求。
简单程序可以这样写,但实际服务更建议显式创建 ServeMux 和 Server:
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", hello)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
显式对象更容易测试,也避免全局路由表被不同包偷偷修改。
Handler 和请求作用域
Handler 接口只有一个方法:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
自定义 handler:
type HealthHandler struct{}
func (HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
}
handler 返回后,请求生命周期通常就结束了。不要在 ServeHTTP 返回后继续读写 Request.Body 或 ResponseWriter。需要异步任务时,应先复制必要数据,或者把任务提交给独立的队列。
方法和路径匹配
Go 1.22 之后,ServeMux 支持带 HTTP 方法的模式:
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
路径参数可以从 Request.PathValue 获取:
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "user=%s", id)
}
路由冲突会在注册时 panic。程序启动阶段注册固定路由,能尽早发现配置错误。
请求、响应和资源关闭
一个请求包含方法、URL、headers、body 和 context:
func echo(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
defer r.Body.Close()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write(body)
}
读取请求体时需要设置上限。io.ReadAll(r.Body) 没有大小边界,客户端可以用一个超大 body 消耗内存。更直接的写法是:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
响应头必须在 WriteHeader 或第一次 Write 前设置:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))
写入响应后不要再尝试修改状态码。HTTP 状态一旦写出,header 就提交了。
中间件
中间件接收一个 handler,返回另一个 handler:
type Middleware func(http.Handler) http.Handler
请求 ID
type requestIDKey struct{}
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 = strconv.FormatInt(time.Now().UnixNano(), 10)
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
真实项目可以使用 UUID 或雪花 ID。这里用时间戳只是为了展示 context 数据如何向下游传递。
日志中间件
func accessLog(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(start))
})
}
}
如果要记录状态码,需要包装 ResponseWriter 捕获 WriteHeader 和 Write:
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Write(body []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(body)
}
包装 ResponseWriter 时要注意 Flusher、Hijacker、ReaderFrom 等可选接口。只实现最小方法可能让流式响应或 WebSocket 行为改变。对于普通 JSON API,最小包装通常足够;需要代理、流式下载或 WebSocket 时应该使用 http.NewResponseController 或完整转发接口。
恢复 panic
func recoverPanic(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if value := recover(); value != nil {
logger.Printf("panic=%v", value)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
恢复 panic 只能保护当前请求。它不是修复 bug 的方式,panic 仍然应该记录堆栈并触发告警。
组合中间件
func chain(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
执行顺序从左到右包裹,从右到左进入:
handler := chain(mux,
recoverPanic(logger),
requestID,
accessLog(logger),
)
超时和取消
HTTP 服务至少要考虑四种超时:
| 配置 | 作用 |
|---|---|
ReadHeaderTimeout |
读取请求头的最大时间 |
ReadTimeout |
读取完整请求的最大时间 |
WriteTimeout |
写响应的最大时间 |
IdleTimeout |
keep-alive 空闲连接时间 |
服务端配置:
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
handler 内部还可以通过 http.TimeoutHandler 设置单个路由超时:
slow := http.TimeoutHandler(handler, 2*time.Second, "timeout\n")
更推荐让业务代码监听 r.Context().Done():
func query(w http.ResponseWriter, r *http.Request) {
select {
case result := <-doWork(r.Context()):
fmt.Fprintln(w, result)
case <-r.Context().Done():
return
}
}
客户端断开、服务器关闭或上游超时后,context 会被取消。业务函数必须把 context 传给数据库、HTTP client 和下游调用,取消才有机会真正传递。
HTTP 客户端
http.Get 适合一次性示例,但生产代码应该复用 http.Client。官方文档特别强调,Client 和 Transport 内部维护连接缓存,应该复用而不是每次请求创建。
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("upstream status: %s", resp.Status)
}
无论状态码是什么,都要关闭 response body。读取完 body 后再关闭,Transport 才更容易复用连接。
不要只设置客户端总超时就结束。连接建立、TLS 握手、读取 header、读取 body 可能需要不同边界;复杂客户端可以自定义 Transport 和 context deadline。
可插拔项目:插件式高可用 HTTP 服务
现在实现一个服务框架。插件只需要实现一个接口:
type Plugin interface {
Name() string
Register(*http.ServeMux)
}
主程序负责:
- 创建
ServeMux; - 注册插件;
- 加载中间件;
- 暴露
/healthz和/readyz; - 启动 HTTP server;
- 接收 SIGINT、SIGTERM;
- 调用
Shutdown优雅退出。
插件注册
type UserPlugin struct{}
func (UserPlugin) Name() string { return "users" }
func (UserPlugin) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "plugin=users id=%s\n", r.PathValue("id"))
})
}
再写一个 metrics 插件:
type MetricsPlugin struct{}
func (MetricsPlugin) Name() string { return "metrics" }
func (MetricsPlugin) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintln(w, "http_plugin_up 1")
})
}
插件不需要知道 server 如何启动,也不应该自己创建监听端口。端口、生命周期和中间件属于宿主程序。
健康和就绪
健康检查回答"进程是否还活着";就绪检查回答"实例是否可以接收流量"。两者不能混为一谈。
type State struct {
mu sync.RWMutex
ready bool
}
func (s *State) SetReady(value bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.ready = value
}
func (s *State) Ready() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ready
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
}
func readyHandler(state *State) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !state.Ready() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ready\n"))
}
}
优雅关闭
Server.Shutdown 停止接受新连接,并等待已有连接结束,直到 context 到期:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() {
logger.Printf("listen on %s", server.Addr)
serverErr <- server.ListenAndServe()
}()
select {
case err := <-serverErr:
if !errors.Is(err, http.ErrServerClosed) {
logger.Fatal(err)
}
case <-ctx.Done():
state.SetReady(false)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Printf("shutdown failed: %v", err)
}
}
先把 readiness 设为 false,再调用 Shutdown,给负载均衡器一点时间停止转发新请求。真正的部署系统还应该配合 preStop、连接排空和进程退出时间配置。
完整项目
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Middleware func(http.Handler) http.Handler
type Plugin interface {
Name() string
Register(*http.ServeMux)
}
type State struct { mu sync.RWMutex; ready bool }
func (s *State) SetReady(v bool) { s.mu.Lock(); s.ready = v; s.mu.Unlock() }
func (s *State) Ready() bool { s.mu.RLock(); defer s.mu.RUnlock(); return s.ready }
type UserPlugin struct{}
func (UserPlugin) Name() string { return "users" }
func (UserPlugin) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "plugin=users id=%s\n", r.PathValue("id"))
})
}
type MetricsPlugin struct{}
func (MetricsPlugin) Name() string { return "metrics" }
func (MetricsPlugin) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "http_plugin_up 1")
})
}
func chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms)-1; i >= 0; i-- { h = ms[i](h) }
return h
}
func recoverPanic(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { if value := recover(); value != nil { logger.Printf("panic=%v", value); http.Error(w, "internal error", 500) } }()
next.ServeHTTP(w, r)
})
}
}
func accessLog(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(start))
})
}
}
func main() {
logger := log.New(os.Stdout, "http-demo ", log.Ldate|log.Ltime|log.Lmicroseconds)
state := &State{}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if !state.Ready() { http.Error(w, "not ready", http.StatusServiceUnavailable); return }
fmt.Fprintln(w, "ready")
})
for _, plugin := range []Plugin{UserPlugin{}, MetricsPlugin{}} { logger.Printf("register plugin=%s", plugin.Name()); plugin.Register(mux) }
handler := chain(mux, recoverPanic(logger), accessLog(logger))
server := &http.Server{Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5*time.Second, ReadTimeout: 15*time.Second, WriteTimeout: 15*time.Second, IdleTimeout: 60*time.Second}
state.SetReady(true)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() { serverErr <- server.ListenAndServe() }()
select {
case err := <-serverErr:
if !errors.Is(err, http.ErrServerClosed) { logger.Fatal(err) }
case <-ctx.Done():
state.SetReady(false)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil { logger.Printf("shutdown failed: %v", err) }
}
}
测试插件路由
不需要真实端口:
func TestUserPlugin(t *testing.T) {
mux := http.NewServeMux()
(UserPlugin{}).Register(mux)
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK { t.Fatalf("status=%d", recorder.Code) }
if got := recorder.Body.String(); got != "plugin=users id=42\n" { t.Fatalf("body=%q", got) }
}
httptest.NewRecorder 适合断言状态码、响应头和 body;httptest.NewServer 适合测试完整的 client/server 交互。
高可用项目的边界
Server的 timeout 是第一层防护,业务 context 是第二层防护;Shutdown只负责服务生命周期,不负责等待你自己启动的后台 goroutine;- readiness 变为 false 后,仍然需要给流量系统留出排空时间;
- 复用
http.Client和Transport,不要每次请求创建一个 client; - 请求 body、response body 都要关闭,并限制可读大小;
- 中间件包装
ResponseWriter时要注意可选接口; - 插件只注册能力,不掌管端口和进程生命周期;
- 测试 handler 时优先使用
httptest,避免依赖真实网络。