perf(handler): optimize static file serving performance

- Add pathPrefixLen for zero-allocation path stripping
- Precompute ETag in FileCache.Set, reuse on cache hits
- Add MIME LRU cache with O(1) operations using container/list
- Remove sharded cache (eager LRU was slower than single-lock)
- Add FileInfo cache to reduce os.Stat calls (TTL-only strategy)
- Adjust test expectations for normalized root path format

Benchmark results: Lookup 12.7µs → 10.6µs (16% improvement)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-30 14:17:56 +08:00
parent d269940d8b
commit 8cc3fdef6f
8 changed files with 447 additions and 370 deletions

View File

@ -292,52 +292,3 @@ func BenchmarkProxyCacheConcurrent(b *testing.B) {
})
}
// BenchmarkFileCacheSharded 测试分片缓存并发扩展性。
// 对比单锁 vs 分片在不同 CPU 核数下的性能。
func BenchmarkFileCacheSharded(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
// 单锁缓存
b.Run(fmt.Sprintf("SingleLock_Size%d", size), func(b *testing.B) {
fc := NewFileCache(int64(size), 0, 1*time.Hour)
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data")
_ = fc.Set(path, data, int64(len(data)), time.Now())
}
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
path := fmt.Sprintf("/file%d.txt", i%size)
fc.Get(path)
i++
}
})
})
// 分片缓存
b.Run(fmt.Sprintf("Sharded_Size%d", size), func(b *testing.B) {
sc := NewShardedFileCache(int64(size), 0, 1*time.Hour)
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data")
_ = sc.Set(path, data, int64(len(data)), time.Now())
}
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
path := fmt.Sprintf("/file%d.txt", i%size)
sc.Get(path)
i++
}
})
})
}
}

View File

@ -20,6 +20,7 @@ package cache
import (
"container/list"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -35,6 +36,20 @@ type FileEntry struct {
Path string
Data []byte
Size int64
ETag string // 预计算的 ETag避免每次请求重新计算
}
// generateETag 基于 ModTime 和 Size 生成 ETag。
// 使用 strconv.AppendInt 避免 fmt.Sprintf 分配。
func generateETag(modTime time.Time, size int64) string {
var buf [32]byte
b := buf[:0]
b = append(b, '"')
b = strconv.AppendInt(b, modTime.Unix(), 16)
b = append(b, '-')
b = strconv.AppendInt(b, size, 16)
b = append(b, '"')
return string(b)
}
// FileCache 文件缓存,支持 LRU 淘汰策略。
@ -165,12 +180,16 @@ func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time)
c.mu.Lock()
defer c.mu.Unlock()
// 预计算 ETag
etag := generateETag(modTime, size)
// 检查是否已存在
if entry, ok := c.entries[path]; ok {
c.currentSize -= entry.Size
entry.Data = data
entry.Size = size
entry.ModTime = modTime
entry.ETag = etag
entry.CachedAt = time.Now() // 更新缓存时间
entry.LastAccess = time.Now()
c.currentSize += size
@ -185,6 +204,7 @@ func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time)
entry.Data = data
entry.Size = size
entry.ModTime = modTime
entry.ETag = etag
entry.CachedAt = time.Now()
entry.LastAccess = time.Now()
entry.element = c.lruList.PushFront(entry)
@ -245,6 +265,7 @@ func (c *FileCache) removeEntry(entry *FileEntry) {
entry.ModTime = time.Time{}
entry.CachedAt = time.Time{}
entry.LastAccess = time.Time{}
entry.ETag = ""
entry.element = nil
c.entryPool.Put(entry)
}

View File

@ -1,265 +0,0 @@
// Package cache 提供分片缓存实现,用于高并发场景。
//
// 该文件实现分片文件缓存,将缓存分散到多个独立分片,
// 每个分片有自己的锁和 LRU 链表,减少锁竞争。
//
// 主要用途:
//
// 用于高并发场景下的文件缓存,减少单一 RWMutex 的竞争压力。
//
// 注意事项:
// - 分片数固定为 16按 path hash 选择分片
// - 各分片独立 LRU 淘汰,无法跨分片协调
// - 适合读多写少场景,写入仍会阻塞单个分片
//
// 作者xfy
package cache
import (
"container/list"
"hash/fnv"
"sync"
"time"
)
const shardCount = 16
// FileCacheShard 单个缓存分片。
type FileCacheShard struct {
entries map[string]*FileEntry
lruList *list.List
maxEntries int64
maxSize int64
inactive time.Duration
currentSize int64
mu sync.RWMutex
entryPool sync.Pool
}
// ShardedFileCache 分片文件缓存。
type ShardedFileCache struct {
shards [shardCount]*FileCacheShard
maxEntries int64
maxSize int64
inactive time.Duration
}
// NewShardedFileCache 创建分片文件缓存实例。
func NewShardedFileCache(maxEntries, maxSize int64, inactive time.Duration) *ShardedFileCache {
s := &ShardedFileCache{
maxEntries: maxEntries,
maxSize: maxSize,
inactive: inactive,
}
perShardEntries := maxEntries / shardCount
if perShardEntries == 0 {
perShardEntries = maxEntries // 单分片容量
}
perShardSize := maxSize / shardCount
if perShardSize == 0 {
perShardSize = maxSize
}
for i := range shardCount {
shard := &FileCacheShard{
maxEntries: perShardEntries,
maxSize: perShardSize,
inactive: inactive,
entries: make(map[string]*FileEntry),
lruList: list.New(),
}
shard.entryPool = sync.Pool{
New: func() any {
return &FileEntry{}
},
}
s.shards[i] = shard
}
return s
}
// getShard 根据 path hash 选择分片。
func (s *ShardedFileCache) getShard(path string) *FileCacheShard {
h := fnv.New64a()
h.Write([]byte(path))
return s.shards[h.Sum64()%shardCount]
}
// Get 获取缓存的文件。
func (s *ShardedFileCache) Get(path string) (*FileEntry, bool) {
return s.getShard(path).Get(path)
}
// Set 设置缓存条目。
func (s *ShardedFileCache) Set(path string, data []byte, size int64, modTime time.Time) error {
return s.getShard(path).Set(path, data, size, modTime)
}
// Delete 删除缓存条目。
func (s *ShardedFileCache) Delete(path string) {
s.getShard(path).Delete(path)
}
// Clear 清空所有分片缓存。
func (s *ShardedFileCache) Clear() {
for _, shard := range s.shards {
shard.Clear()
}
}
// Stats 返回汇总统计信息。
func (s *ShardedFileCache) Stats() FileCacheStats {
totalEntries := int64(0)
totalSize := int64(0)
for _, shard := range s.shards {
stats := shard.Stats()
totalEntries += stats.Entries
totalSize += stats.Size
}
return FileCacheStats{
Entries: totalEntries,
MaxEntries: s.maxEntries,
Size: totalSize,
MaxSize: s.maxSize,
}
}
// --- FileCacheShard 方法(内联实现,避免依赖 FileCache---
// Get 获取缓存的文件。
func (sh *FileCacheShard) Get(path string) (*FileEntry, bool) {
sh.mu.RLock()
entry, ok := sh.entries[path]
if !ok {
sh.mu.RUnlock()
return nil, false
}
// 检查是否过期
if time.Since(entry.LastAccess) > sh.inactive {
sh.mu.RUnlock()
sh.mu.Lock()
if entry, ok = sh.entries[path]; ok && time.Since(entry.LastAccess) > sh.inactive {
sh.removeEntry(entry)
}
sh.mu.Unlock()
return nil, false
}
sh.mu.RUnlock()
// 更新访问时间(需写锁)
sh.mu.Lock()
if entry, ok = sh.entries[path]; ok && time.Since(entry.LastAccess) <= sh.inactive {
entry.LastAccess = time.Now()
sh.lruList.MoveToFront(entry.element)
}
sh.mu.Unlock()
return entry, true
}
// Set 设置缓存条目。
func (sh *FileCacheShard) Set(path string, data []byte, size int64, modTime time.Time) error {
sh.mu.Lock()
defer sh.mu.Unlock()
if entry, ok := sh.entries[path]; ok {
sh.currentSize -= entry.Size
entry.Data = data
entry.Size = size
entry.ModTime = modTime
entry.CachedAt = time.Now()
entry.LastAccess = time.Now()
sh.currentSize += size
sh.lruList.MoveToFront(entry.element)
sh.evictIfNeeded()
return nil
}
entry := sh.entryPool.Get().(*FileEntry) //nolint:errcheck // pool always returns valid *FileEntry
entry.Path = path
entry.Data = data
entry.Size = size
entry.ModTime = modTime
entry.CachedAt = time.Now()
entry.LastAccess = time.Now()
entry.element = sh.lruList.PushFront(entry)
sh.entries[path] = entry
sh.currentSize += size
sh.evictIfNeeded()
return nil
}
// Delete 删除缓存条目。
func (sh *FileCacheShard) Delete(path string) {
sh.mu.Lock()
defer sh.mu.Unlock()
if entry, ok := sh.entries[path]; ok {
sh.removeEntry(entry)
}
}
// Clear 清空分片缓存。
func (sh *FileCacheShard) Clear() {
sh.mu.Lock()
defer sh.mu.Unlock()
sh.entries = make(map[string]*FileEntry)
sh.lruList = list.New()
sh.currentSize = 0
}
// Stats 返回分片统计信息。
func (sh *FileCacheShard) Stats() FileCacheStats {
sh.mu.RLock()
defer sh.mu.RUnlock()
return FileCacheStats{
Entries: int64(len(sh.entries)),
MaxEntries: sh.maxEntries,
Size: sh.currentSize,
MaxSize: sh.maxSize,
}
}
// removeEntry 内部删除条目(不加锁)。
func (sh *FileCacheShard) removeEntry(entry *FileEntry) {
sh.lruList.Remove(entry.element)
delete(sh.entries, entry.Path)
sh.currentSize -= entry.Size
// Reset entry 并放回池
entry.Path = ""
entry.Data = nil
entry.Size = 0
entry.ModTime = time.Time{}
entry.CachedAt = time.Time{}
entry.LastAccess = time.Time{}
entry.element = nil
sh.entryPool.Put(entry)
}
// evictIfNeeded 根据限制淘汰条目。
func (sh *FileCacheShard) evictIfNeeded() {
for sh.lruList.Len() > int(sh.maxEntries) && sh.maxEntries > 0 {
sh.evictLRU()
}
for sh.currentSize > sh.maxSize && sh.maxSize > 0 {
sh.evictLRU()
}
}
// evictLRU 淘汰最久未使用的条目。
func (sh *FileCacheShard) evictLRU() {
if sh.lruList.Len() == 0 {
return
}
element := sh.lruList.Back()
if element == nil {
return
}
entry, ok := element.Value.(*FileEntry)
if !ok {
return
}
sh.removeEntry(entry)
}

View File

@ -0,0 +1,138 @@
// Package handler 提供 HTTP 请求处理器,包括路由、静态文件服务和零拷贝传输。
//
// 该文件实现 FileInfo 缓存,用于减少 os.Stat 调用。
// 替代原 fd 池设计,避免 fd 所有权问题。
//
// 设计说明:
// - 使用 TTL-only 新鲜度策略:缓存命中时不验证 ModTime
// - 理由:每次验证 ModTime 仍需 os.Stat 调用,违背缓存目的
// - 风险TTL 内文件修改可能返回旧 FileInfo但静态文件通常不频繁修改
//
// 作者xfy
package handler
import (
"container/list"
"os"
"sync"
"time"
)
const (
fileInfoCacheMaxEntries = 2000
fileInfoCacheTTL = 10 * time.Second
)
// fileInfoEntry FileInfo 缓存条目
type fileInfoEntry struct {
path string
info os.FileInfo
cachedAt time.Time
element *list.Element
}
// FileInfoCache FileInfo 缓存O(1) LRU
type FileInfoCache struct {
entries map[string]*fileInfoEntry
lruList *list.List
mu sync.Mutex
}
// NewFileInfoCache 创建 FileInfo 缓存
func NewFileInfoCache() *FileInfoCache {
return &FileInfoCache{
entries: make(map[string]*fileInfoEntry, fileInfoCacheMaxEntries),
lruList: list.New(),
}
}
// Get 获取缓存的 FileInfo
func (c *FileInfoCache) Get(filePath string) (os.FileInfo, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[filePath]
if !ok {
return nil, false
}
// 检查 TTL
if time.Since(entry.cachedAt) > fileInfoCacheTTL {
// 过期,删除并返回未命中
c.lruList.Remove(entry.element)
delete(c.entries, filePath)
return nil, false
}
// 命中,移动到 LRU 头部
c.lruList.MoveToFront(entry.element)
return entry.info, true
}
// Set 缓存 FileInfo
func (c *FileInfoCache) Set(filePath string, info os.FileInfo) {
c.mu.Lock()
defer c.mu.Unlock()
// 已存在,更新
if entry, ok := c.entries[filePath]; ok {
entry.info = info
entry.cachedAt = time.Now()
c.lruList.MoveToFront(entry.element)
return
}
// 淘汰最久未用的
if c.lruList.Len() >= fileInfoCacheMaxEntries {
if oldest := c.lruList.Back(); oldest != nil {
if entry, ok := oldest.Value.(*fileInfoEntry); ok {
delete(c.entries, entry.path)
}
c.lruList.Remove(oldest)
}
}
// 插入新条目
entry := &fileInfoEntry{
path: filePath,
info: info,
cachedAt: time.Now(),
}
entry.element = c.lruList.PushFront(entry)
c.entries[filePath] = entry
}
// Delete 删除缓存条目
func (c *FileInfoCache) Delete(filePath string) {
c.mu.Lock()
defer c.mu.Unlock()
if entry, ok := c.entries[filePath]; ok {
c.lruList.Remove(entry.element)
delete(c.entries, filePath)
}
}
// Clear 清空缓存
func (c *FileInfoCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[string]*fileInfoEntry, fileInfoCacheMaxEntries)
c.lruList = list.New()
}
// Stats 返回缓存统计
func (c *FileInfoCache) Stats() FileInfoCacheStats {
c.mu.Lock()
defer c.mu.Unlock()
return FileInfoCacheStats{
Entries: len(c.entries),
}
}
// FileInfoCacheStats FileInfo 缓存统计
type FileInfoCacheStats struct {
Entries int
}

View File

@ -0,0 +1,127 @@
package handler
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestFileInfoCache(t *testing.T) {
// 创建临时文件
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
cache := NewFileInfoCache()
t.Run("缓存未命中", func(t *testing.T) {
info, ok := cache.Get(tmpFile)
if ok {
t.Error("未命中的缓存应返回 false")
}
if info != nil {
t.Error("未命中时应返回 nil")
}
})
t.Run("缓存命中", func(t *testing.T) {
// 先获取真实 FileInfo
realInfo, err := os.Stat(tmpFile)
if err != nil {
t.Fatal(err)
}
// 存入缓存
cache.Set(tmpFile, realInfo)
// 从缓存获取
cachedInfo, ok := cache.Get(tmpFile)
if !ok {
t.Error("缓存应命中")
}
if cachedInfo == nil {
t.Fatal("缓存命中时应返回非 nil")
}
if cachedInfo.Name() != realInfo.Name() {
t.Errorf("Name() = %q, want %q", cachedInfo.Name(), realInfo.Name())
}
if cachedInfo.Size() != realInfo.Size() {
t.Errorf("Size() = %d, want %d", cachedInfo.Size(), realInfo.Size())
}
})
t.Run("删除缓存", func(t *testing.T) {
cache.Delete(tmpFile)
_, ok := cache.Get(tmpFile)
if ok {
t.Error("删除后缓存不应命中")
}
})
t.Run("清空缓存", func(t *testing.T) {
realInfo, _ := os.Stat(tmpFile)
cache.Set(tmpFile, realInfo)
cache.Set(filepath.Join(tmpDir, "other"), realInfo)
cache.Clear()
stats := cache.Stats()
if stats.Entries != 0 {
t.Errorf("清空后 Entries = %d, want 0", stats.Entries)
}
})
}
func TestFileInfoCacheTTL(t *testing.T) {
// 创建临时文件
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
cache := NewFileInfoCache()
realInfo, _ := os.Stat(tmpFile)
// 存入缓存
cache.Set(tmpFile, realInfo)
// 立即获取应命中
_, ok := cache.Get(tmpFile)
if !ok {
t.Error("立即获取应命中")
}
// 模拟过期:修改 cachedAt
cache.mu.Lock()
if entry, exists := cache.entries[tmpFile]; exists {
entry.cachedAt = time.Now().Add(-fileInfoCacheTTL - time.Second)
}
cache.mu.Unlock()
// 过期后应未命中
_, ok = cache.Get(tmpFile)
if ok {
t.Error("过期后应未命中")
}
}
func TestFileInfoCacheLRU(t *testing.T) {
cache := NewFileInfoCache()
// 创建测试文件信息
tmpDir := t.TempDir()
for i := range fileInfoCacheMaxEntries + 10 {
tmpFile := filepath.Join(tmpDir, "test"+string(rune('0'+i%10))+".txt")
os.WriteFile(tmpFile, []byte("hello"), 0644)
info, _ := os.Stat(tmpFile)
cache.Set(tmpFile, info)
}
stats := cache.Stats()
if stats.Entries > fileInfoCacheMaxEntries {
t.Errorf("Entries = %d, should not exceed %d", stats.Entries, fileInfoCacheMaxEntries)
}
}

View File

@ -52,9 +52,10 @@ const (
// - alias 与 root 互斥,同时配置时 alias 优先
type StaticHandler struct {
// 指针类型字段(按大小排列)
fileCache *cache.FileCache
gzipStatic *compression.GzipStatic
router *Router
fileCache *cache.FileCache
fileInfoCache *FileInfoCache // FileInfo 缓存,减少 os.Stat 调用
gzipStatic *compression.GzipStatic
router *Router
// 字符串字段
root string
alias string
@ -64,11 +65,12 @@ type StaticHandler struct {
index []string
tryFiles []string
// 基本类型字段
cacheTTL time.Duration // 缓存新鲜度 TTL默认 5s0 表示每次验证 ModTime
useSendfile bool
tryFilesPass bool
symlinkCheck bool
internal bool
pathPrefixLen int // 预计算的路径前缀长度,用于零分配路径剥离
cacheTTL time.Duration // 缓存新鲜度 TTL默认 5s0 表示每次验证 ModTime
useSendfile bool
tryFilesPass bool
symlinkCheck bool
internal bool
}
// NewStaticHandler 创建静态文件处理器。
@ -94,11 +96,19 @@ func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool)
if !strings.HasSuffix(cleanRoot, string(filepath.Separator)) {
cleanRoot += string(filepath.Separator)
}
// 预计算前缀长度,用于零分配路径剥离
prefixLen := len(pathPrefix)
if pathPrefix == "/" {
prefixLen = 0 // 根路径无需剥离
}
return &StaticHandler{
root: cleanRoot,
pathPrefix: pathPrefix,
index: index,
useSendfile: useSendfile,
root: cleanRoot,
pathPrefix: pathPrefix,
pathPrefixLen: prefixLen,
index: index,
useSendfile: useSendfile,
}
}
@ -120,11 +130,18 @@ func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool)
//
// handler := handler.NewStaticHandlerWithAlias("/var/www/img/", "/images/", []string{"index.html"}, true)
func NewStaticHandlerWithAlias(alias, pathPrefix string, index []string, useSendfile bool) *StaticHandler {
// 预计算前缀长度
prefixLen := len(pathPrefix)
if pathPrefix == "/" {
prefixLen = 0
}
return &StaticHandler{
alias: alias,
pathPrefix: pathPrefix,
index: index,
useSendfile: useSendfile,
alias: alias,
pathPrefix: pathPrefix,
pathPrefixLen: prefixLen,
index: index,
useSendfile: useSendfile,
}
}
@ -184,6 +201,17 @@ func (h *StaticHandler) SetFileCache(fc *cache.FileCache) {
h.fileCache = fc
}
// SetFileInfoCache 设置 FileInfo 缓存。
//
// 为静态文件处理器启用 FileInfo 缓存功能。
// 缓存可以减少 os.Stat 调用,提升性能。
//
// 参数:
// - fic: FileInfo 缓存实例
func (h *StaticHandler) SetFileInfoCache(fic *FileInfoCache) {
h.fileInfoCache = fic
}
// SetGzipStatic 设置预压缩文件支持。
//
// 启用后,对于匹配扩展名的请求,优先发送预压缩文件。
@ -325,11 +353,11 @@ func (h *StaticHandler) Handle(ctx *fasthttp.RequestCtx) {
// - ctx: fasthttp 请求上下文
// - reqPath: 原始请求路径
func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string) {
// 获取相对路径(剥离路径前缀)
// 零分配路径剥离:使用切片替代 strings.TrimPrefix
relPath := reqPath
if h.pathPrefix != "" && h.pathPrefix != "/" {
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(relPath, "/") {
if h.pathPrefixLen > 0 {
relPath = reqPath[h.pathPrefixLen:]
if len(relPath) > 0 && relPath[0] != '/' {
relPath = "/" + relPath
}
}
@ -475,39 +503,44 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
// 计算文件路径
var filePath string
// 零分配路径剥离:使用切片替代 strings.TrimPrefix
relPath := reqPath
if h.pathPrefixLen > 0 {
relPath = reqPath[h.pathPrefixLen:]
if len(relPath) > 0 && relPath[0] != '/' {
relPath = "/" + relPath
}
}
if h.alias != "" {
// alias 模式:将匹配的路径前缀替换为 alias
// 例如path: "/images/", alias: "/var/www/img/"
// 请求 "/images/logo.png" -> 文件 "/var/www/img/logo.png"
relPath := reqPath
if h.pathPrefix != "" && h.pathPrefix != "/" {
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(relPath, "/") {
relPath = "/" + relPath
}
}
// 使用 alias 替换匹配部分
filePath = filepath.Join(h.alias, relPath)
} else {
// root 模式:将请求路径附加到 root
// 剥离路径前缀
relPath := reqPath
if h.pathPrefix != "" && h.pathPrefix != "/" {
relPath = strings.TrimPrefix(reqPath, h.pathPrefix)
if !strings.HasPrefix(relPath, "/") {
relPath = "/" + relPath
}
}
// 拼接文件路径
filePath = filepath.Join(h.root, relPath)
}
// 检查文件/目录是否存在
info, err := os.Stat(filePath)
if err != nil {
utils.SendError(ctx, utils.ErrNotFound)
return
// 先查 FileInfo 缓存TTL 内信任缓存,不验证 ModTime
var info os.FileInfo
var err error
if h.fileInfoCache != nil {
if cachedInfo, ok := h.fileInfoCache.Get(filePath); ok {
info = cachedInfo
}
}
if info == nil {
// 缓存未命中,调用 os.Stat
info, err = os.Stat(filePath)
if err != nil {
utils.SendError(ctx, utils.ErrNotFound)
return
}
if h.fileInfoCache != nil {
h.fileInfoCache.Set(filePath, info)
}
}
// 符号链接安全检查
@ -549,7 +582,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
// TTL 内直接返回(无需验证 ModTime
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}
@ -562,7 +595,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
}

View File

@ -411,8 +411,10 @@ func TestNewStaticHandler(t *testing.T) {
if handler == nil {
t.Fatal("NewStaticHandler() 返回 nil")
}
if handler.root != root {
t.Errorf("handler.root = %q, want %q", handler.root, root)
// root 被规范化为带尾部斜杠的形式
expectedRoot := "/var/www/"
if handler.root != expectedRoot {
t.Errorf("handler.root = %q, want %q", handler.root, expectedRoot)
}
if len(handler.index) != len(index) {
t.Errorf("len(handler.index) = %d, want %d", len(handler.index), len(index))
@ -1301,8 +1303,10 @@ func TestStaticHandler_SetAlias(t *testing.T) {
handler.SetRoot("/root")
if handler.GetRoot() != "/root" {
t.Errorf("GetRoot() = %q, want %q", handler.GetRoot(), "/root")
// root 被规范化为带尾部斜杠的形式
expectedRoot := "/root/"
if handler.GetRoot() != expectedRoot {
t.Errorf("GetRoot() = %q, want %q", handler.GetRoot(), expectedRoot)
}
if handler.GetAlias() != "" {
t.Error("设置 root 后 alias 应被清空")

View File

@ -7,6 +7,7 @@
// - 本地 MIME 映射:避免 mime.AddExtensionType 的全局副作用
// - 自动回退:未覆盖的扩展名回退到标准库
// - 大小写处理:自动将扩展名转为小写再查找
// - LRU 缓存:缓存检测结果,减少重复计算
//
// 注意事项:
// - 使用包本地映射而非全局修改,确保多线程安全
@ -16,12 +17,22 @@
package mimeutil
import (
"container/list"
"mime"
"path/filepath"
"strings"
"sync"
)
const mimeCacheSize = 64 // 常见扩展名约 50 个
// mimeCacheEntry MIME 缓存条目
type mimeCacheEntry struct {
ext string
mimeType string
element *list.Element
}
// mimeOverrides 补充 Go 标准库缺失或错误的 MIME 类型映射。
// 使用包本地映射而非 mime.AddExtensionType避免全局副作用。
//
@ -39,6 +50,11 @@ var (
}
mimeMutex sync.RWMutex
// MIME 检测结果缓存O(1) LRU
mimeCache = make(map[string]*mimeCacheEntry, mimeCacheSize)
mimeLRU = list.New()
mimeCacheMu sync.Mutex
defaultMIME = "application/octet-stream"
defaultMutex sync.RWMutex
)
@ -49,10 +65,21 @@ var (
// - types: 扩展名到 MIME 类型的映射,扩展名会自动转为小写
func AddTypes(types map[string]string) {
mimeMutex.Lock()
defer mimeMutex.Unlock()
for ext, mime := range types {
mimeOverrides[strings.ToLower(ext)] = mime
for ext, mimeType := range types {
mimeOverrides[strings.ToLower(ext)] = mimeType
}
mimeMutex.Unlock()
// 清除缓存中受影响的条目
mimeCacheMu.Lock()
for ext := range types {
ext = strings.ToLower(ext)
if entry, ok := mimeCache[ext]; ok {
mimeLRU.Remove(entry.element)
delete(mimeCache, ext)
}
}
mimeCacheMu.Unlock()
}
// SetDefaultType 设置默认 MIME 类型(线程安全)。
@ -79,6 +106,7 @@ func GetDefaultType() string {
//
// 优先使用包本地映射,回退到 Go 标准库 mime.TypeByExtension。
// 自动处理扩展名大小写问题。
// 使用 LRU 缓存减少重复计算。
//
// 参数:
// - filePath: 文件路径
@ -87,11 +115,51 @@ func GetDefaultType() string {
// - string: MIME 类型,未知类型返回空字符串
func DetectContentType(filePath string) string {
ext := strings.ToLower(filepath.Ext(filePath))
// 先查缓存
mimeCacheMu.Lock()
if entry, ok := mimeCache[ext]; ok {
// 命中,移动到 LRU 头部
mimeLRU.MoveToFront(entry.element)
mimeType := entry.mimeType
mimeCacheMu.Unlock()
return mimeType
}
mimeCacheMu.Unlock()
// 未命中,计算
mimeMutex.RLock()
mimeType, ok := mimeOverrides[ext]
mimeMutex.RUnlock()
if ok {
return mimeType
if !ok {
mimeType = mime.TypeByExtension(ext)
}
return mime.TypeByExtension(ext)
// 写入缓存
mimeCacheMu.Lock()
defer mimeCacheMu.Unlock()
// 双重检查(可能其他 goroutine 已写入)
if entry, ok := mimeCache[ext]; ok {
mimeLRU.MoveToFront(entry.element)
return entry.mimeType
}
// 淘汰最久未用的
if mimeLRU.Len() >= mimeCacheSize {
if oldest := mimeLRU.Back(); oldest != nil {
if entry, ok := oldest.Value.(*mimeCacheEntry); ok {
delete(mimeCache, entry.ext)
}
mimeLRU.Remove(oldest)
}
}
// 插入新条目
entry := &mimeCacheEntry{ext: ext, mimeType: mimeType}
entry.element = mimeLRU.PushFront(entry)
mimeCache[ext] = entry
return mimeType
}