HTTP 响应压缩是一个常见优化。JSON、HTML、CSS、文本日志这类内容通常能压缩很多,网络传输更省;图片、视频、已经压缩过的 zip 文件再压缩意义不大,还会浪费 CPU。Go 标准库有 compress/gzip,可以写一个简单中间件理解压缩流程。
本文不追求写一个覆盖所有边界的生产中间件,而是讲清楚 gzip 响应的基本机制:客户端通过 Accept-Encoding 表示支持,服务端压缩后设置 Content-Encoding: gzip,并注意哪些场景不该压。
判断客户端是否支持
func acceptsGzip(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}
真实解析可以更严格,入门阶段先够用。只有客户端声明支持 gzip,服务端才能返回 gzip 内容。否则旧客户端会把压缩字节当普通文本读,结果就是乱码。
gzip ResponseWriter
type gzipResponseWriter struct {
http.ResponseWriter
writer io.Writer
}
func (w gzipResponseWriter) Write(data []byte) (int, error) {
return w.writer.Write(data)
}
中间件:
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := gzipResponseWriter{ResponseWriter: w, writer: gz}
next.ServeHTTP(gzw, r)
})
}
Vary: Accept-Encoding 很重要。它告诉缓存系统:同一个 URL 会因为请求头不同返回不同内容。否则代理缓存可能把 gzip 版本发给不支持 gzip 的客户端。
测试压缩
func TestGzipMiddleware(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello hello hello")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatal("missing gzip encoding")
}
reader, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello hello hello" {
t.Fatalf("body = %q", data)
}
}
测试时要解压后比较内容,而不是直接比较响应体字节。gzip 里可能有时间戳等细节,直接比较压缩字节不稳定。
小响应不一定要压缩
压缩有 CPU 成本。几十字节的小响应,gzip 头本身就有额外开销,压完可能更大。生产中间件通常会在缓冲一定内容后判断是否压缩,或者只对特定 Content-Type 压缩。
入门版本可以通过路由选择:
mux.Handle("/api/", Gzip(apiHandler))
mux.Handle("/download/", downloadHandler)
API JSON 压缩,文件下载不压。不要把所有响应一刀切 gzip。
不要压已经压缩的内容
这些通常不需要 gzip:
- jpg、png、webp
- mp4、mp3
- zip、gz
- pdf,视内容而定
如果你用 Go 直接服务静态文件,可以根据扩展名跳过。很多情况下,CDN 或反向代理更适合做压缩,应用只负责生成正确内容。是否在 Go 应用层压缩,要看部署结构。
Flush 和接口转发
简单 gzipResponseWriter 只实现了 Write,没有转发 http.Flusher、http.Hijacker、http.Pusher 等接口。对普通 JSON 响应没问题,但对流式响应、WebSocket、SSE 就可能出问题。生产级中间件需要处理这些接口。
入门阶段要知道这个边界:中间件包装 ResponseWriter 后,可能改变它支持的能力。不要把简单 gzip 中间件直接套到所有路由上,尤其是流式接口。
Content-Length 问题
压缩后内容长度变了。如果下游 handler 提前设置了 Content-Length,gzip 中间件可能让它不准确。简单做法是在压缩时删除:
w.Header().Del("Content-Length")
标准库会使用 chunked 传输或在合适时处理长度。对于动态响应,不设置 Content-Length 通常没问题。
按 Content-Type 决定是否压缩
更实际的中间件会先看响应类型。问题是 Content-Type 往往在 handler 写响应时才知道,所以简单中间件很难提前判断。一个折中做法是只在明确的路由上启用 gzip,例如 API JSON、服务端渲染 HTML,不把它套在下载路由上。
如果你愿意写得更完整,可以用一个缓冲 writer 先缓存少量响应头和 body,等知道 Content-Type 和长度后再决定是否压缩。但这会让中间件复杂不少。入门阶段先用路由边界控制,通常更容易维护。
func shouldCompress(contentType string) bool {
return strings.HasPrefix(contentType, "application/json") ||
strings.HasPrefix(contentType, "text/html") ||
strings.HasPrefix(contentType, "text/plain")
}
这类函数最好配测试,避免后续把图片、压缩包也加进压缩列表。
反向代理和应用层不要重复压缩
如果 Nginx、Caddy、CDN 已经负责 gzip 或 brotli,Go 应用里再压一次没有意义,甚至可能造成错误。部署时要明确压缩发生在哪一层。一般来说,静态资源交给 CDN 或反向代理压缩更合适;动态 JSON 如果直接由 Go 暴露,也可以在应用层压缩。
排查时可以用 curl 看响应头:
curl -H 'Accept-Encoding: gzip' -I http://localhost:8080/api/items
看到 Content-Encoding: gzip 就说明当前链路某一层做了压缩。不要只看代码判断,实际响应头才是准确信息。
小结
Go 里用 compress/gzip 可以写出基础 HTTP 压缩中间件。核心流程是检查 Accept-Encoding,设置 Content-Encoding: gzip 和 Vary: Accept-Encoding,用 gzip writer 包装响应。
压缩不是越多越好。小响应、图片、压缩包、流式接口都要谨慎。入门阶段先在 JSON 和 HTML 这类文本响应上使用,理解边界后再考虑全站压缩。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。