Go HTTP gzip 压缩入门:什么时候压缩响应,什么时候不要压

用 HTTP 中间件示例讲 Go 服务里的 gzip 响应压缩,包括 Accept-Encoding、Content-Encoding、小响应跳过和测试。

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.Flusherhttp.Hijackerhttp.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: gzipVary: Accept-Encoding,用 gzip writer 包装响应。

压缩不是越多越好。小响应、图片、压缩包、流式接口都要谨慎。入门阶段先在 JSON 和 HTML 这类文本响应上使用,理解边界后再考虑全站压缩。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页