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 就说明当前链路某一层做了压缩。不要只看代码判断,实际响应头才是准确信息。
常见问题 FAQ
Q: gzip 中间件影响 Content-Length 怎么办?
A: 压缩后内容长度不确定,应删除原始 Content-Length,标准库会自动使用 chunked 传输。
Q: 压缩级别怎么选?
A: gzip.DefaultCompression 通常是最佳平衡。BestCompression 更省流量但 CPU 更高,BestSpeed 则相反。benchmark 后可以按需调整。
Q: 流式响应能用 gzip 吗?
A: 简单包装可能丢失 http.Flusher 接口。生产中间件需要显式转发 Flusher、Hijacker 等子接口,否则 SSE、WebSocket 会出问题。
常见陷阱
- 流式接口被中间件截断:检查被包装的 ResponseWriter 是否完整转发了子接口。SSE、WebSocket 需要特殊处理。
- 反向代理重复压缩:CDN 或 Nginx 已压缩时,后端再 gzip 一次没有意义,甚至可能出错。部署时确认压缩层位置。
- 对所有 Content-Type 压缩:图片、压缩包、PDF 再压一次往往浪费 CPU,应根据 Content-Type 筛选。
对比表
| 情况 | 是否压缩 | 原因 |
|---|---|---|
| JSON 响应 | 是 | 文本格式,压缩率高 |
| HTML 页面 | 是 | 文本格式,压缩率高 |
| jpg/png 图片 | 否 | 已压缩,再压无意义 |
| 小响应(<1KB) | 否 | gzip 头开销可能更大 |
| 文件下载 | 否 | 由对象存储或 CDN 处理 |
小结
Go 里用 compress/gzip 可以写出基础 HTTP 压缩中间件。核心流程是检查 Accept-Encoding,设置 Content-Encoding: gzip 和 Vary: Accept-Encoding,用 gzip writer 包装响应。
压缩不是越多越好。小响应、图片、压缩包、流式接口都要谨慎。入门阶段先在 JSON 和 HTML 这类文本响应上使用,理解边界后再考虑全站压缩。部署时确认 CDN 或反向代理是否已做压缩,避免重复工作。流式接口需要显式转发 Flusher 等子接口,不要直接把简单中间件套在所有路由上。
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 error 返回值
- 并发代码是否有明确的退出路径和 WaitGroup
- 用户输入是否经过校验和清洗
- 敏感配置是否通过环境变量或加密存储注入
- 测试是否覆盖了正常路径和至少一个错误路径
- 日志是否包含足够的上下文信息但不泄露敏感数据
- 接口设计是否符合最小接口原则
CI/CD 集成建议
- 每次提交前运行
go fmt ./... - CI 中运行
go vet ./...和golangci-lint run - 单元测试使用
go test -race ./...检测数据竞争 - 关键路径的 benchmark 加入回归测试
- 使用
go mod verify确保依赖完整性
性能调优检查点
- 使用 pprof 分析 CPU 和内存使用
- 关注 benchmark 的 allocs/op,减少高频路径的堆分配
- 检查数据库查询是否使用索引
- 确认外部 HTTP 调用有合理的超时设置
- 缓存热点数据,但注意缓存一致性和过期策略
面试高频考点
如果你正在准备 Go 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- sync.Mutex vs sync.RWMutex vs atomic
掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。
常见问题(FAQ)
Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。
Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。
Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。
Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。
Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 Go 官方博客,关注语言演进和最佳实践更新
参考资源
- Go 官方网站:https://go.dev/
- Go 标准库文档:https://pkg.go.dev/std
- Go by Example:https://gobyexample.com/
- Effective Go:https://go.dev/doc/effective_go
- Go 常见问题:https://go.dev/doc/faq
- Go 项目实战社区案例和开源项目源码
本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
生产级 gzip 中间件
进阶的 gzip 中间件需要处理更多边界:
type compressResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
status int
buf *bytes.Buffer
}
func (w *compressResponseWriter) WriteHeader(status int) {
w.status = status
}
func (w *compressResponseWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
if w.buf.Len() < 1024 {
w.buf.Write(b)
return len(b), nil
}
if w.gz == nil {
w.ResponseWriter.Header().Del("Content-Length")
w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
w.gz = gzip.NewWriter(w.ResponseWriter)
}
return w.gz.Write(b)
}
这个版本先缓冲 1KB,如果内容很小就跳过压缩。动态决定是否压缩比一刀切换策更合理。
压缩级别调优
gzip 提供了不同压缩级别:
| 级别 | 速度 | 压缩率 | 适用场景 |
|---|---|---|---|
| BestSpeed | 最快 | 最低 | CPU 受限 |
| Default | 平衡 | 平衡 | 通用 |
| BestCompression | 最慢 | 最高 | 带宽受限 |
gz, _ := gzip.NewWriterLevel(w, gzip.BestCompression)
通常情况下 Default 已经足够优秀,不需要为了一点压缩率牺牲 CPU。
与其他压缩算法对比
| 算法 | 压缩率 | 速度 | 浏览器支持 |
|---|---|---|---|
| gzip | 中 | 快 | 全部 |
| deflate | 中 | 快 | 大部分 |
| brotli | 高 | 中 | 现代浏览器 |
| zstd | 很高 | 很快 | 较少 |
如果性能允许,应用层做 gzip,CDN 做 brotli。Go 标准库 gzip 已经很成熟,除非有特殊需求否则不需要引入第三方压缩库。
中间件完整示例
package middleware
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"strings"
)
type GzipMiddleware struct {
Level int
}
func (m *GzipMiddleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
wrapped := &gzipResponseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
defer func() {
if wrapped.gz != nil {
wrapped.gz.Close()
}
}()
next.ServeHTTP(wrapped, r)
})
}
这个设计提供了更灵活的控制和缓存支持。实践中要注意接口转发(Flusher/Hijacker/Pusher)问题,生产级中间件需要显式支持这些子接口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。