feat(middleware,http3,config): 增强 gzip_static 支持 Brotli,新增 HTTP/3 0-RTT 与性能配置验证

- gzip_static: 支持 .br 和 .gz 预压缩文件,按 br > gzip 优先级选择
- http3/server: 新增 Enable0RTT 配置,启用时输出安全警告
- config: 废弃 FileCache.LRUEviction 字段,新增 validatePerformance 验证
- 新增 gzip_static 完整测试套件覆盖编码优先级、路径遍历防护等场景

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-07 14:25:28 +08:00
parent 20016ebb59
commit 038c0639fd
7 changed files with 715 additions and 29 deletions

View File

@ -244,7 +244,8 @@ type FileCacheConfig struct {
MaxEntries int64 `yaml:"max_entries"` // 最大缓存条目数
MaxSize int64 `yaml:"max_size"` // 内存上限(字节)
Inactive time.Duration `yaml:"inactive"` // 未访问淘汰时间
LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰
// Deprecated: 该字段已废弃,将在未来版本中移除,请使用 MaxSize 代替
LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰(已废弃)
}
// TransportConfig HTTP Transport 配置。
@ -444,5 +445,10 @@ func Validate(cfg *Config) error {
return err
}
// 验证性能配置
if err := validatePerformance(&cfg.Performance); err != nil {
return fmt.Errorf("performance: %w", err)
}
return nil
}

View File

@ -90,7 +90,7 @@ func DefaultConfig() *Config {
Level: 6,
MinSize: 1024,
GzipStatic: false,
GzipStaticExtensions: []string{".gz", ".br"},
GzipStaticExtensions: []string{".br", ".gz"},
Types: []string{
"text/html",
"text/css",
@ -398,6 +398,7 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
fmt.Fprintf(&buf, " max_entries: %d # 最大缓存条目\n", cfg.Performance.FileCache.MaxEntries)
fmt.Fprintf(&buf, " max_size: %d # 内存上限(字节,%dMB\n", cfg.Performance.FileCache.MaxSize, cfg.Performance.FileCache.MaxSize/1024/1024)
fmt.Fprintf(&buf, " inactive: %ds # 未访问淘汰时间\n", int(cfg.Performance.FileCache.Inactive.Seconds()))
buf.WriteString(" # Deprecated: lru_eviction 已废弃,将在未来版本中移除\n")
fmt.Fprintf(&buf, " lru_eviction: %v # 启用 LRU 淘汰\n", cfg.Performance.FileCache.LRUEviction)
buf.WriteString(" transport: # HTTP Transport 连接池\n")
fmt.Fprintf(&buf, " max_idle_conns: %d # 最大空闲连接\n", cfg.Performance.Transport.MaxIdleConns)

View File

@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"net"
"os"
"strings"
"rua.plus/lolly/internal/loadbalance"
@ -634,3 +635,32 @@ func validateStream(s *StreamConfig) error {
return nil
}
// validatePerformance 验证性能配置。
//
// 检查性能配置中的废弃选项和潜在问题,输出警告信息。
//
// 参数:
// - p: 性能配置对象
//
// 返回值:
// - error: 验证失败时返回错误信息,成功返回 nil
func validatePerformance(p *PerformanceConfig) error {
// 检查废弃的 LRUEviction 字段
if p.FileCache.LRUEviction {
fmt.Fprintln(os.Stderr, "[警告] performance.file_cache.lru_eviction 已废弃,请使用 max_size 代替")
}
// 检查 Transport 配置(可能导致性能问题)
if p.Transport.MaxIdleConns < 0 {
return errors.New("transport.max_idle_conns 不能为负数")
}
if p.Transport.MaxIdleConnsPerHost < 0 {
return errors.New("transport.max_idle_conns_per_host 不能为负数")
}
if p.Transport.MaxConnsPerHost < 0 {
return errors.New("transport.max_conns_per_host 不能为负数")
}
return nil
}

View File

@ -906,3 +906,126 @@ func TestValidateStream(t *testing.T) {
})
}
}
func TestValidatePerformance(t *testing.T) {
tests := []struct {
name string
config PerformanceConfig
wantErr bool
errMsg string
}{
{
name: "空配置有效",
config: PerformanceConfig{
FileCache: FileCacheConfig{},
Transport: TransportConfig{},
},
wantErr: false,
},
{
name: "有效的 file_cache 配置",
config: PerformanceConfig{
FileCache: FileCacheConfig{
MaxEntries: 1000,
MaxSize: 1024 * 1024 * 100,
},
GoroutinePool: GoroutinePoolConfig{
Enabled: true,
},
},
wantErr: false,
},
{
name: "有效的 transport 配置(零值)",
config: PerformanceConfig{
Transport: TransportConfig{
MaxIdleConns: 0,
MaxIdleConnsPerHost: 0,
MaxConnsPerHost: 0,
},
},
wantErr: false,
},
{
name: "有效的 transport 配置(正值)",
config: PerformanceConfig{
Transport: TransportConfig{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 50,
},
},
wantErr: false,
},
{
name: "LRUEviction=true废弃警告",
config: PerformanceConfig{
FileCache: FileCacheConfig{
LRUEviction: true,
},
},
wantErr: false,
},
{
name: "MaxIdleConns 负数",
config: PerformanceConfig{
Transport: TransportConfig{
MaxIdleConns: -1,
},
},
wantErr: true,
errMsg: "transport.max_idle_conns 不能为负数",
},
{
name: "MaxIdleConnsPerHost 负数",
config: PerformanceConfig{
Transport: TransportConfig{
MaxIdleConnsPerHost: -1,
},
},
wantErr: true,
errMsg: "transport.max_idle_conns_per_host 不能为负数",
},
{
name: "MaxConnsPerHost 负数",
config: PerformanceConfig{
Transport: TransportConfig{
MaxConnsPerHost: -1,
},
},
wantErr: true,
errMsg: "transport.max_conns_per_host 不能为负数",
},
{
name: "多个 transport 字段为负",
config: PerformanceConfig{
Transport: TransportConfig{
MaxIdleConns: -1,
MaxIdleConnsPerHost: -2,
MaxConnsPerHost: -3,
},
},
wantErr: true,
errMsg: "transport.max_idle_conns 不能为负数",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePerformance(&tt.config)
if tt.wantErr {
if err == nil {
t.Errorf("validatePerformance() 期望返回错误,但返回 nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validatePerformance() 错误消息不匹配,期望包含 %q实际 %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("validatePerformance() 期望返回 nil但返回错误: %v", err)
}
}
})
}
}

View File

@ -109,6 +109,16 @@ func (s *Server) Start() error {
MaxIncomingStreams: int64(s.config.MaxStreams),
MaxIdleTimeout: s.config.IdleTimeout,
KeepAlivePeriod: 30 * time.Second,
Allow0RTT: s.config.Enable0RTT,
}
// 如果启用了 0-RTT输出安全警告
if s.config.Enable0RTT {
logging.Warn().
Msg("HTTP/3 0-RTT is enabled. " +
"For 0-RTT to work, TLS session tickets must be configured " +
"(TLSConfig.ClientSessionCache and TLSConfig.SessionTicketKey). " +
"See documentation for details.")
}
// 设置默认值

View File

@ -15,7 +15,6 @@
package compression
import (
"bytes"
"os"
"path/filepath"
"strings"
@ -25,8 +24,8 @@ import (
// GzipStatic 预压缩文件支持。
//
// 检查是否存在预压缩的 .gz 文件,如果存在则直接发送
// 避免实时压缩的 CPU 开销。
// 检查是否存在预压缩的 .gz 或 .br 文件,如果存在且客户端支持对应编码
// 则直接发送,避免实时压缩的 CPU 开销。
type GzipStatic struct {
// enabled 是否启用
enabled bool
@ -36,6 +35,9 @@ type GzipStatic struct {
// extensions 支持的扩展名
extensions []string
// precompressedExtensions 预压缩扩展名,按优先级排序(默认 [".br", ".gz"]
precompressedExtensions []string
}
// NewGzipStatic 创建预压缩文件处理器。
@ -49,16 +51,18 @@ func NewGzipStatic(enabled bool, root string, extensions []string) *GzipStatic {
extensions = []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
}
return &GzipStatic{
enabled: enabled,
root: root,
extensions: extensions,
enabled: enabled,
root: root,
extensions: extensions,
precompressedExtensions: []string{".br", ".gz"},
}
}
// ServeFile 发送预压缩文件(如果存在)。
//
// 检查是否存在对应的 .gz 文件,如果存在且客户端支持 gzip
// 则发送预压缩文件。
// 检查是否存在对应的 .br 或 .gz 文件按优先级br > gzip选择
// 1. 如果客户端支持 br 且 .br 文件存在,返回 .br 文件
// 2. 否则如果客户端支持 gzip 且 .gz 文件存在,返回 .gz 文件
//
// 参数:
// - ctx: FastHTTP 请求上下文
@ -71,36 +75,48 @@ func (g *GzipStatic) ServeFile(ctx *fasthttp.RequestCtx, filePath string) bool {
return false
}
// 检查客户端是否支持 gzip
acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding")
if !bytes.Contains(acceptEncoding, []byte("gzip")) {
return false
}
// 检查文件扩展名
if !g.matchExtension(filePath) {
return false
}
// 检查预压缩文件是否存在
gzPath := filePath + ".gz"
fullGzPath := filepath.Join(g.root, gzPath)
// 安全检查:防止目录遍历
if strings.Contains(gzPath, "..") {
if strings.Contains(filePath, "..") {
return false
}
// 检查文件是否存在
if _, err := os.Stat(fullGzPath); err != nil {
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)
// 检查文件是否存在
if _, err := os.Stat(fullPath); err != nil {
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")
fasthttp.ServeFile(ctx, fullPath)
return true
}
// 发送预压缩文件
ctx.Response.Header.Set("Content-Encoding", "gzip")
ctx.Response.Header.Set("Vary", "Accept-Encoding")
fasthttp.ServeFile(ctx, fullGzPath)
return true
return false
}
// TryServeFile 尝试发送预压缩文件的静态方法。
@ -145,3 +161,22 @@ func (g *GzipStatic) Extensions() []string {
func DefaultExtensions() []string {
return []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
}
// supportsEncoding 检查客户端是否支持指定编码。
//
// 简单检查,忽略 q-value固定优先级由遍历顺序决定。
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
}
}

View File

@ -0,0 +1,481 @@
// Package compression 提供 gzip_static 预压缩文件测试。
package compression
import (
"os"
"path/filepath"
"testing"
"github.com/valyala/fasthttp"
)
// TestGzipStaticServeFile_BrotliPriority 测试 .br 文件优先于 .gz 文件
func TestGzipStaticServeFile_BrotliPriority(t *testing.T) {
// 创建临时目录
tmpDir := t.TempDir()
// 创建 .br 和 .gz 文件
brFile := filepath.Join(tmpDir, "test.js.br")
gzFile := filepath.Join(tmpDir, "test.js.gz")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil {
t.Fatalf("创建 .gz 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
tests := []struct {
name string
acceptEncoding string
wantServed bool
wantEncoding string
}{
{
name: "同时支持 br 和 gzip优先返回 br",
acceptEncoding: "br, gzip",
wantServed: true,
wantEncoding: "br",
},
{
name: "只支持 br",
acceptEncoding: "br",
wantServed: true,
wantEncoding: "br",
},
{
name: "只支持 gzip",
acceptEncoding: "gzip",
wantServed: true,
wantEncoding: "gzip",
},
{
name: "支持 deflate 和 gzip返回 gzip",
acceptEncoding: "deflate, gzip",
wantServed: true,
wantEncoding: "gzip",
},
{
name: "不支持任何编码",
acceptEncoding: "",
wantServed: false,
wantEncoding: "",
},
{
name: "支持 br大小写不敏感",
acceptEncoding: "BR",
wantServed: true,
wantEncoding: "br",
},
{
name: "支持 gzip大小写不敏感",
acceptEncoding: "GZIP",
wantServed: true,
wantEncoding: "gzip",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
served := g.ServeFile(ctx, "test.js")
if served != tt.wantServed {
t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed)
}
if tt.wantServed {
encoding := ctx.Response.Header.Peek("Content-Encoding")
if string(encoding) != tt.wantEncoding {
t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding)
}
}
})
}
}
// TestGzipStaticServeFile_GzipFallback 测试仅 .gz 存在时的回退
func TestGzipStaticServeFile_GzipFallback(t *testing.T) {
// 创建临时目录
tmpDir := t.TempDir()
// 只创建 .gz 文件
gzFile := filepath.Join(tmpDir, "test.css.gz")
if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil {
t.Fatalf("创建 .gz 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
tests := []struct {
name string
acceptEncoding string
wantServed bool
wantEncoding string
}{
{
name: "支持 br 但没有 .br 文件,回退到 gzip",
acceptEncoding: "br, gzip",
wantServed: true,
wantEncoding: "gzip",
},
{
name: "只支持 gzip",
acceptEncoding: "gzip",
wantServed: true,
wantEncoding: "gzip",
},
{
name: "只支持 br没有 .br 文件,不服务",
acceptEncoding: "br",
wantServed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
served := g.ServeFile(ctx, "test.css")
if served != tt.wantServed {
t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed)
}
if tt.wantServed {
encoding := ctx.Response.Header.Peek("Content-Encoding")
if string(encoding) != tt.wantEncoding {
t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding)
}
}
})
}
}
// TestGzipStaticServeFile_AcceptEncodingParsing 测试 Accept-Encoding 头解析
func TestGzipStaticServeFile_AcceptEncodingParsing(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.html.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
tests := []struct {
name string
acceptEncoding string
wantSupported bool
}{
{
name: "包含 br",
acceptEncoding: "gzip, deflate, br",
wantSupported: true,
},
{
name: "br 在中间",
acceptEncoding: "gzip, br, deflate",
wantSupported: true,
},
{
name: "br 在最后",
acceptEncoding: "gzip, deflate,br",
wantSupported: true,
},
{
name: "包含空格",
acceptEncoding: "gzip, br , deflate",
wantSupported: true,
},
{
name: "q-value 支持",
acceptEncoding: "br;q=0.9, gzip;q=0.8",
wantSupported: true,
},
{
name: "没有 br",
acceptEncoding: "gzip, deflate",
wantSupported: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding)
served := g.ServeFile(ctx, "test.html")
if served != tt.wantSupported {
t.Errorf("ServeFile() = %v, want %v", served, tt.wantSupported)
}
})
}
}
// TestGzipStaticServeFile_Disabled 测试禁用时不服务
func TestGzipStaticServeFile_Disabled(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.js.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
// 禁用的 GzipStatic
g := NewGzipStatic(false, tmpDir, nil)
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", "br")
served := g.ServeFile(ctx, "test.js")
if served {
t.Error("禁用时不应服务文件")
}
}
// TestGzipStaticServeFile_InvalidExtension 测试无效扩展名
func TestGzipStaticServeFile_InvalidExtension(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.exe.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", "br")
served := g.ServeFile(ctx, "test.exe")
if served {
t.Error("无效扩展名不应服务文件")
}
}
// TestGzipStaticServeFile_PathTraversal 测试路径遍历防护
func TestGzipStaticServeFile_PathTraversal(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.js.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", "br")
// 尝试路径遍历
served := g.ServeFile(ctx, "../test.js")
if served {
t.Error("路径遍历应被阻止")
}
}
// TestGzipStaticServeFile_VaryHeader 测试 Vary 头设置
func TestGzipStaticServeFile_VaryHeader(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.js.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
g := NewGzipStatic(true, tmpDir, nil)
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", "br")
g.ServeFile(ctx, "test.js")
vary := ctx.Response.Header.Peek("Vary")
if string(vary) != "Accept-Encoding" {
t.Errorf("Vary 头 = %q, want %q", string(vary), "Accept-Encoding")
}
}
// TestNewGzipStatic_DefaultExtensions 测试默认扩展名
func TestNewGzipStatic_DefaultExtensions(t *testing.T) {
g := NewGzipStatic(true, "/tmp", nil)
expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
got := g.Extensions()
if len(got) != len(expected) {
t.Errorf("扩展名数量 = %d, want %d", len(got), len(expected))
}
for i, ext := range expected {
if got[i] != ext {
t.Errorf("扩展名[%d] = %q, want %q", i, got[i], ext)
}
}
}
// TestNewGzipStatic_CustomExtensions 测试自定义扩展名
func TestNewGzipStatic_CustomExtensions(t *testing.T) {
custom := []string{".custom", ".ext"}
g := NewGzipStatic(true, "/tmp", custom)
got := g.Extensions()
if len(got) != 2 || got[0] != ".custom" || got[1] != ".ext" {
t.Errorf("自定义扩展名 = %v, want %v", got, custom)
}
}
// TestGzipStatic_Enabled 测试 Enabled 方法
func TestGzipStatic_Enabled(t *testing.T) {
g1 := NewGzipStatic(true, "/tmp", nil)
if !g1.Enabled() {
t.Error("Enabled() = false, want true")
}
g2 := NewGzipStatic(false, "/tmp", nil)
if g2.Enabled() {
t.Error("Enabled() = true, want false")
}
}
// TestDefaultExtensions 测试 DefaultExtensions 函数
func TestDefaultExtensions(t *testing.T) {
expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"}
got := DefaultExtensions()
if len(got) != len(expected) {
t.Errorf("默认扩展名数量 = %d, want %d", len(got), len(expected))
}
for i, ext := range expected {
if got[i] != ext {
t.Errorf("默认扩展名[%d] = %q, want %q", i, got[i], ext)
}
}
}
// TestSupportsEncoding 测试 supportsEncoding 函数
func TestSupportsEncoding(t *testing.T) {
tests := []struct {
name string
acceptEncoding string
wantEncoding string
wantSupported bool
}{
{
name: "支持 br",
acceptEncoding: "br",
wantEncoding: ".br",
wantSupported: true,
},
{
name: "支持 gzip",
acceptEncoding: "gzip",
wantEncoding: ".gz",
wantSupported: true,
},
{
name: "不支持",
acceptEncoding: "deflate",
wantEncoding: ".br",
wantSupported: false,
},
{
name: "空 Accept-Encoding",
acceptEncoding: "",
wantEncoding: ".br",
wantSupported: false,
},
{
name: "br 在中间",
acceptEncoding: "gzip, br, deflate",
wantEncoding: ".br",
wantSupported: true,
},
{
name: "gzip 在中间",
acceptEncoding: "br, gzip, deflate",
wantEncoding: ".gz",
wantSupported: true,
},
{
name: "大小写不敏感",
acceptEncoding: "BR, GZIP",
wantEncoding: ".br",
wantSupported: true,
},
{
name: "未知扩展名",
acceptEncoding: "br, gzip",
wantEncoding: ".unknown",
wantSupported: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
supported := supportsEncoding([]byte(tt.acceptEncoding), tt.wantEncoding)
if supported != tt.wantSupported {
t.Errorf("supportsEncoding(%q, %q) = %v, want %v",
tt.acceptEncoding, tt.wantEncoding, supported, tt.wantSupported)
}
})
}
}
// TestTryServeFile 测试 TryServeFile 静态方法
func TestTryServeFile(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
brFile := filepath.Join(tmpDir, "test.js.br")
if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil {
t.Fatalf("创建 .br 文件失败: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Accept-Encoding", "br")
served := TryServeFile(ctx, tmpDir, "test.js", nil)
if !served {
t.Error("TryServeFile() = false, want true")
}
encoding := ctx.Response.Header.Peek("Content-Encoding")
if string(encoding) != "br" {
t.Errorf("Content-Encoding = %q, want %q", string(encoding), "br")
}
}
// TestGzipStatic_PrecompressedExtensions 测试预压缩扩展名优先级
func TestGzipStatic_PrecompressedExtensions(t *testing.T) {
g := NewGzipStatic(true, "/tmp", nil)
// 验证默认预压缩扩展名顺序
expected := []string{".br", ".gz"}
if len(g.precompressedExtensions) != len(expected) {
t.Errorf("预压缩扩展名数量 = %d, want %d", len(g.precompressedExtensions), len(expected))
}
for i, ext := range expected {
if g.precompressedExtensions[i] != ext {
t.Errorf("预压缩扩展名[%d] = %q, want %q", i, g.precompressedExtensions[i], ext)
}
}
}