使用 sync.Map 缓存预压缩文件的存在性检查结果,与 nginx gzip_static 行为一致采用永不过期策略,减少高频请求下的 文件系统调用开销。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
230 lines
6.6 KiB
Go
230 lines
6.6 KiB
Go
// Package compression 提供预压缩静态文件支持。
|
||
//
|
||
// 该文件实现预压缩文件的检测和发送,避免实时压缩开销。
|
||
//
|
||
// 主要功能:
|
||
// - 检测 .gz 和 .br 预压缩文件
|
||
// - 按优先级选择编码(brotli > gzip)
|
||
// - 客户端 Accept-Encoding 协商
|
||
// - 扩展名过滤,仅对文本类资源启用预压缩
|
||
//
|
||
// 主要用途:
|
||
//
|
||
// 用于发送预压缩的静态资源文件,减少服务器 CPU 开销,提升响应速度。
|
||
//
|
||
// 使用场景:
|
||
// - 静态资源预先压缩(如 CSS、JS、HTML 文件)
|
||
// - 构建时生成 .gz 或 .br 文件
|
||
// - 运行时直接发送预压缩文件,避免实时压缩
|
||
//
|
||
// 注意事项:
|
||
// - 需要客户端支持对应的 Content-Encoding
|
||
// - 预压缩文件需要与源文件放在同一目录
|
||
//
|
||
// 作者:xfy
|
||
package compression
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/valyala/fasthttp"
|
||
"rua.plus/lolly/internal/mimeutil"
|
||
)
|
||
|
||
// existResult 预压缩文件存在性缓存结果。
|
||
//
|
||
// 采用永不过期策略,与 nginx gzip_static 行为一致:
|
||
// 预压缩文件通常在构建时生成,运行时不会频繁增删。
|
||
// 如需更新缓存,重启服务即可。
|
||
type existResult struct {
|
||
// exists 预压缩文件是否存在
|
||
exists bool
|
||
}
|
||
|
||
// GzipStatic 预压缩文件支持中间件。
|
||
//
|
||
// 检查是否存在预压缩的 .gz 或 .br 文件,如果存在且客户端支持对应编码,
|
||
// 则直接发送,避免实时压缩的 CPU 开销。
|
||
type GzipStatic struct {
|
||
// root 静态文件根目录路径
|
||
root string
|
||
// precompressedExtensions 预压缩文件扩展名列表(如 .br, .gz)
|
||
precompressedExtensions []string
|
||
// extensions 支持预压缩的源文件扩展名列表(如 .html, .css, .js)
|
||
extensions []string
|
||
// enabled 是否启用预压缩支持
|
||
enabled bool
|
||
// existCache 预压缩文件存在性缓存,避免重复 os.Stat 调用
|
||
existCache sync.Map
|
||
}
|
||
|
||
// NewGzipStatic 创建预压缩文件处理器。
|
||
//
|
||
// 根据配置创建预压缩文件处理器实例,设置默认扩展名列表。
|
||
//
|
||
// 参数:
|
||
// - enabled: 是否启用预压缩支持
|
||
// - root: 静态文件根目录路径
|
||
// - extensions: 支持预压缩的源文件扩展名列表(如 .html, .css),为空则使用默认值
|
||
// - precompressedExtensions: 预压缩文件扩展名列表(如 .br, .gz),为空则使用默认值
|
||
//
|
||
// 返回值:
|
||
// - *GzipStatic: 创建的预压缩文件处理器
|
||
func NewGzipStatic(enabled bool, root string, extensions, precompressedExtensions []string) *GzipStatic {
|
||
if len(extensions) == 0 {
|
||
extensions = []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
|
||
}
|
||
if len(precompressedExtensions) == 0 {
|
||
precompressedExtensions = []string{".br", ".gz"}
|
||
}
|
||
return &GzipStatic{
|
||
enabled: enabled,
|
||
root: root,
|
||
extensions: extensions,
|
||
precompressedExtensions: precompressedExtensions,
|
||
}
|
||
}
|
||
|
||
// ServeFile 发送预压缩文件(如果存在)。
|
||
//
|
||
// 检查是否存在对应的 .br 或 .gz 文件,按优先级(br > gzip)选择:
|
||
// 1. 如果客户端支持 br 且 .br 文件存在,返回 .br 文件
|
||
// 2. 否则如果客户端支持 gzip 且 .gz 文件存在,返回 .gz 文件
|
||
//
|
||
// 参数:
|
||
// - ctx: FastHTTP 请求上下文
|
||
// - filePath: 请求的文件路径
|
||
//
|
||
// 返回值:
|
||
// - bool: true 表示已发送预压缩文件,false 表示未发送
|
||
func (g *GzipStatic) ServeFile(ctx *fasthttp.RequestCtx, filePath string) bool {
|
||
if !g.enabled {
|
||
return false
|
||
}
|
||
|
||
// 检查文件扩展名
|
||
if !g.matchExtension(filePath) {
|
||
return false
|
||
}
|
||
|
||
// 安全检查:防止目录遍历
|
||
if strings.Contains(filePath, "..") {
|
||
return false
|
||
}
|
||
|
||
// 获取 Accept-Encoding 头
|
||
acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding")
|
||
|
||
// 按优先级检查预压缩文件
|
||
for _, ext := range g.precompressedExtensions {
|
||
// 检查客户端是否支持该编码
|
||
if !supportsEncoding(acceptEncoding, ext) {
|
||
continue
|
||
}
|
||
|
||
// 构建预压缩文件路径
|
||
compressedPath := filePath + ext
|
||
fullPath := filepath.Join(g.root, compressedPath)
|
||
|
||
// 检查文件是否存在(优先查缓存)
|
||
var exists bool
|
||
if cached, ok := g.existCache.Load(fullPath); ok {
|
||
if er, ok := cached.(existResult); ok {
|
||
exists = er.exists
|
||
} else {
|
||
// 意外类型,回退到 os.Stat
|
||
_, err := os.Stat(fullPath)
|
||
exists = err == nil
|
||
g.existCache.Store(fullPath, existResult{exists: exists})
|
||
}
|
||
} else {
|
||
_, err := os.Stat(fullPath)
|
||
exists = err == nil
|
||
g.existCache.Store(fullPath, existResult{exists: exists})
|
||
}
|
||
if !exists {
|
||
continue
|
||
}
|
||
|
||
// 设置 Content-Encoding 头
|
||
switch ext {
|
||
case ".br":
|
||
ctx.Response.Header.Set("Content-Encoding", "br")
|
||
case ".gz":
|
||
ctx.Response.Header.Set("Content-Encoding", "gzip")
|
||
}
|
||
ctx.Response.Header.Set("Vary", "Accept-Encoding")
|
||
// 设置原始文件的 Content-Type
|
||
// filePath 是原始文件路径 (如 "test.js"),直接使用即可
|
||
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
|
||
|
||
fasthttp.ServeFile(ctx, fullPath)
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// matchExtension 检查文件扩展名是否在支持的预压缩扩展名列表中。
|
||
//
|
||
// 参数:
|
||
// - filePath: 文件路径
|
||
//
|
||
// 返回值:
|
||
// - bool: true 表示文件扩展名匹配支持列表
|
||
func (g *GzipStatic) matchExtension(filePath string) bool {
|
||
ext := strings.ToLower(filepath.Ext(filePath))
|
||
for _, e := range g.extensions {
|
||
if strings.ToLower(e) == ext {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Enabled 返回是否启用预压缩。
|
||
//
|
||
// 返回值:
|
||
// - bool: true 表示已启用预压缩支持
|
||
func (g *GzipStatic) Enabled() bool {
|
||
return g.enabled
|
||
}
|
||
|
||
// Extensions 返回支持的源文件扩展名列表。
|
||
//
|
||
// 返回值:
|
||
// - []string: 支持预压缩的源文件扩展名列表
|
||
func (g *GzipStatic) Extensions() []string {
|
||
return g.extensions
|
||
}
|
||
|
||
// supportsEncoding 检查客户端是否支持指定编码。
|
||
//
|
||
// 通过检查 Accept-Encoding 请求头中是否包含指定编码名称。
|
||
// 忽略 q-value(质量因子),固定优先级由遍历顺序决定。
|
||
//
|
||
// 参数:
|
||
// - acceptEncoding: Accept-Encoding 请求头值
|
||
// - ext: 预压缩文件扩展名(如 ".br"、".gz")
|
||
//
|
||
// 返回值:
|
||
// - bool: true 表示客户端支持该编码
|
||
func supportsEncoding(acceptEncoding []byte, ext string) bool {
|
||
if len(acceptEncoding) == 0 {
|
||
return false
|
||
}
|
||
enc := strings.ToLower(string(acceptEncoding))
|
||
|
||
switch ext {
|
||
case ".br":
|
||
return strings.Contains(enc, "br")
|
||
case ".gz":
|
||
return strings.Contains(enc, "gzip")
|
||
default:
|
||
return false
|
||
}
|
||
}
|