在服务部署中,如果 Golang 服务有一个前置的 Nginx,可以通过 Nginx 设置资源缓存和资源压缩,如果没有前置服务,服务的一些资源又通过 HTTP 框架接口提供,那么可以通过中间件来设置浏览器缓存及 Gzip 压缩,以下是代码示例
设置缓存中间件
代码示例
package main
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const (
staticPathPrefix = "/static/"
cacheControlHeader = "Cache-Control"
cacheControlValue = "public, max-age=2592000" // 缓存一个月
eTagHeader = "ETag"
ifNoneMatchHeader = "If-None-Match"
)
// CacheMiddleware 是一个中间件,用于设置缓存控制头
func CacheMiddleware(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, staticPathPrefix) {
// 设置缓存控制头
c.Header(cacheControlHeader, cacheControlValue)
// 生成并设置 ETag 头
eTag := generateETag(c.Request.URL.Path)
c.Header(eTagHeader, eTag)
// 检查 If-None-Match 头与生成的 ETag 是否匹配,若匹配则返回 304 Not Modified
if match := c.GetHeader(ifNoneMatchHeader); match != "" {
if match == eTag {
log.Printf("Cache hit for: %s", c.Request.URL.Path)
c.Status(http.StatusNotModified)
c.Abort()
return
}
}
}
c.Next()
}
// generateETag 根据文件路径生成一个简单的 ETag
func generateETag(filePath string) string {
h := sha1.New()
_, err := h.Write([]byte(filePath)) // 这里只是为了示例,实际可以基于文件内容
if err != nil {
log.Printf("Error generating ETag for %s: %v", filePath, err)
return ""
}
return fmt.Sprintf("\"%s\"", hex.EncodeToString(h.Sum(nil)))
}
func main() {
// 初始化 Gin 引擎
r := gin.Default()
// 添加缓存中间件
r.Use(CacheMiddleware)
// 静态文件路由
r.Static("/static", "./static")
// 开始服务
r.Run(":8080")
}
启动程序
$ go run main.go
访问 main.go 同目录下的 static 目录下的资源:http://127.0.0.1:8080/static/1.jpg
第二次访问,就可以看到 Transferred 处显示 “cached”,图像右侧的 Response Headers 显示了 Cache-Control 和 Etag
这里补充一些重点:以上示例代码仅适合文件不会经常变动且需要即时生效的场景,因为 ETag 的计算使用的是路径,路径不变,浏览器请求资源就会得到 304 状态,无法获取替换后的文件。
有两个办法能缓解/解决这个问题
- 设置更短的 max-age 缓存时间,这样在服务器替换文件后,待浏览器缓存失效后会重新获取。
- 服务器每次发布的文件,名称中带有随机内容,这种适合前端静态资源,如:app.cb9a2b12.js
如果你不希望修改文件名,同时希望更新文件后能立即生效,解决办法是依赖文件的内容计算 ETag,修改部分代码如下:
// CacheMiddleware 是一个中间件,用于设置缓存控制头
func CacheMiddleware(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, staticPathPrefix) {
filePath := filepath.Join(".", c.Request.URL.Path)
// 检查文件是否存在
if _, err := os.Stat(filePath); err == nil {
// 设置缓存控制头
c.Header(cacheControlHeader, cacheControlValue)
// 生成并设置 ETag 头
eTag := generateETag(filePath)
c.Header(eTagHeader, eTag)
// 检查 If-None-Match 头与生成的 ETag 是否匹配,若匹配则返回 304 Not Modified
if match := c.GetHeader(ifNoneMatchHeader); match != "" {
if match == eTag {
log.Printf("Cache hit for: %s", c.Request.URL.Path)
c.Status(http.StatusNotModified)
c.Abort()
return
}
}
}
}
c.Next()
}
// generateETag 根据文件内容生成一个 ETag
func generateETag(filePath string) string {
fileContent, err := os.ReadFile(filePath)
if err != nil {
log.Printf("Error reading file %s: %v", filePath, err)
return ""
}
h := sha1.New()
h.Write(fileContent)
return fmt.Sprintf("\"%s\"", hex.EncodeToString(h.Sum(nil)))
}
这样使用 Chrome 系的浏览器每次访问资源时,文件未被更新会返回 304,否则返回替换后的内容,符合预期,需要留意的是通过文件内容的方式计算 ETag 会加重接口的资源性能损耗,不适合对性能要求高和资源受限的场景。
这里为什么强调 Chrome 系是因为在使用 Firefox 测试时发现已缓存的文件,在缓存有效期内不会再调用服务器接口,即无法得知服务器的资源已经发生了变更,不再受到服务器控制,此时只能依赖浏览器的强制刷新,需要特别注意。
一个可行的思路是对于需要经常变化的同名资源,不同的浏览器设置不同的策略,Chrome 系使用文件内容,Firefox 则基于 Path 计算 ETag,设置更短的缓存时间
资源压缩中间件
代码示例
package main
import (
"compress/gzip"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// GzipMiddleware 是一个中间件,用于 gzip 压缩响应数据
func GzipMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
// 如果客户端不支持 gzip,则直接调用下一个处理器
c.Next()
return
}
// 设置响应头,告知客户端采用 gzip 压缩
c.Header("Content-Encoding", "gzip")
// 创建一个 gzip.Writer
gz := gzip.NewWriter(c.Writer)
defer gz.Close() // 确保在响应结束时关闭 gzip.Writer
// 包装 ResponseWriter
c.Writer = &gzipResponseWriter{ResponseWriter: c.Writer, Writer: gz}
c.Next()
}
}
// gzipResponseWriter 包装了 gin.ResponseWriter 和 gzip.Writer
type gzipResponseWriter struct {
gin.ResponseWriter
io.Writer
}
// Write 方法用于压缩并写出数据
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", http.DetectContentType(b))
}
return w.Writer.Write(b)
}
func main() {
r := gin.Default()
// 使用 Gzip 中间件
r.Use(GzipMiddleware())
// 示例路由
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})
// 静态文件路由
r.Static("/static", "./static")
r.Run(":8080")
}
这里使用的 dpcq.txt 文件作为示例,不设置 Gzip 压缩时文件 18MB 大小,很浪费网络带宽,压缩后减少了 61% 的大小,如果是已压缩后的二进制内容,效果不明显,对于文本压缩比很高。
压缩中间件发散补充
以 Firefox 浏览器为例,浏览器除了支持 gzip,还支持 deflate, br, zstd
Accept-Encoding: gzip, deflate, br, zstd
相对于前两者,br 和 zstd 是压缩率更高的算法,现代浏览器也都支持,服务器可以优先判断是否支持 br 和 zstd,相应的,压缩率更高,也会对服务器的性能有更高的要求,需要跟带宽做适当的权衡,是一个 CPU 多出力还是带宽多出力的问题,以下是支持多压缩类型的代码示例:
package main
import (
"compress/flate"
"compress/gzip"
"io"
"net/http"
"strings"
"github.com/andybalholm/brotli"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
)
// MultiCompressionMiddleware 是一个中间件,根据客户端的 Accept-Encoding 头部选择相应的压缩方式
func MultiCompressionMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
acceptEncoding := c.GetHeader("Accept-Encoding")
var encoderWriter io.WriteCloser
var encodingType string
if strings.Contains(acceptEncoding, "zstd") {
encodingType = "zstd"
encoder, err := zstd.NewWriter(c.Writer)
if err != nil {
c.Next()
return
}
encoderWriter = encoder
} else if strings.Contains(acceptEncoding, "br") {
encodingType = "br"
encoderWriter = brotli.NewWriter(c.Writer)
} else if strings.Contains(acceptEncoding, "gzip") {
encodingType = "gzip"
encoderWriter = gzip.NewWriter(c.Writer)
} else if strings.Contains(acceptEncoding, "deflate") {
encodingType = "deflate"
encoderWriter, _ = flate.NewWriter(c.Writer, flate.DefaultCompression)
}
// 如果没有找到匹配的编码格式则继续执行下一个处理器
if encoderWriter == nil {
c.Next()
return
}
defer encoderWriter.Close()
c.Header("Content-Encoding", encodingType)
c.Writer = &compressionResponseWriter{ResponseWriter: c.Writer, Writer: encoderWriter}
c.Next()
}
}
// compressionResponseWriter 包装 gin.ResponseWriter 和 io.Writer
type compressionResponseWriter struct {
gin.ResponseWriter
io.Writer
}
// Write 方法用于压缩并写出数据
func (w *compressionResponseWriter) Write(b []byte) (int, error) {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", http.DetectContentType(b))
}
return w.Writer.Write(b)
}
func main() {
r := gin.Default()
// 使用 MultiCompressionMiddleware 中间件
r.Use(MultiCompressionMiddleware())
// 静态文件路由
r.Static("/static", "./static")
r.Run(":8080")
}
一般来说,Brotli(br)的压缩率更高,对服务器的性能要求也更高,Zstd 在压缩率和资源消耗上比较平衡,Gzip 的兼容性最好、应用最广泛,Deflate 使用的场景很小,一般都被 Gzip 所替代。
原文件:18.01 MB
Gzip 压缩后:6.9 MB
Deflate 压缩后:6.9 MB
Zstd 压缩后 6.3 MB
Brotli 压缩后 5.72 MB
文件压缩可以只针对特定类型进行处理,例如已经是 ZIP 压缩包类型、以及 EXE 可执行程序等二进制内容,再次压缩的收益很小,对资源消耗也大,而针对 css、js、txt、json 文件的压缩就更有作用。
最后的补充
无论是压缩还是设置客户端缓存中间件,本篇介绍的方案都是适合小型 Web 服务(静态资源由服务提供路由访问),更严谨和正规的做法是有专门的网关对资源进行统一的控制,同时对于访问量较大的静态资源,应使用 CDN 进行分发,提升效率同时减少服务器带宽的占用。