Gin Web 框架设置客户端缓存 & 启用 Gzip 压缩中间件

Published: 2024-07-24

Tags: Gin Golang

本文总阅读量

在服务部署中,如果 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 状态,无法获取替换后的文件。

有两个办法能缓解/解决这个问题

  1. 设置更短的 max-age 缓存时间,这样在服务器替换文件后,待浏览器缓存失效后会重新获取。
  2. 服务器每次发布的文件,名称中带有随机内容,这种适合前端静态资源,如: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 进行分发,提升效率同时减少服务器带宽的占用。