From 8cc3fdef6f9bccd1b02b1dba5e0faf12165b2439 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 30 Apr 2026 14:17:56 +0800 Subject: [PATCH] perf(handler): optimize static file serving performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/cache/cache_bench_test.go | 49 ----- internal/cache/file_cache.go | 21 ++ internal/cache/sharded_cache.go | 265 ------------------------ internal/handler/fileinfo_cache.go | 138 ++++++++++++ internal/handler/fileinfo_cache_test.go | 127 ++++++++++++ internal/handler/static.go | 125 +++++++---- internal/handler/static_test.go | 12 +- internal/mimeutil/detect.go | 80 ++++++- 8 files changed, 447 insertions(+), 370 deletions(-) delete mode 100644 internal/cache/sharded_cache.go create mode 100644 internal/handler/fileinfo_cache.go create mode 100644 internal/handler/fileinfo_cache_test.go diff --git a/internal/cache/cache_bench_test.go b/internal/cache/cache_bench_test.go index 56b67f9..f0956de 100644 --- a/internal/cache/cache_bench_test.go +++ b/internal/cache/cache_bench_test.go @@ -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++ - } - }) - }) - } -} diff --git a/internal/cache/file_cache.go b/internal/cache/file_cache.go index 7e6348b..ca21cbd 100644 --- a/internal/cache/file_cache.go +++ b/internal/cache/file_cache.go @@ -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) } diff --git a/internal/cache/sharded_cache.go b/internal/cache/sharded_cache.go deleted file mode 100644 index bf4de79..0000000 --- a/internal/cache/sharded_cache.go +++ /dev/null @@ -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) -} diff --git a/internal/handler/fileinfo_cache.go b/internal/handler/fileinfo_cache.go new file mode 100644 index 0000000..52f92f1 --- /dev/null +++ b/internal/handler/fileinfo_cache.go @@ -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 +} diff --git a/internal/handler/fileinfo_cache_test.go b/internal/handler/fileinfo_cache_test.go new file mode 100644 index 0000000..af74a60 --- /dev/null +++ b/internal/handler/fileinfo_cache_test.go @@ -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) + } +} diff --git a/internal/handler/static.go b/internal/handler/static.go index 8c41a12..309f26c 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -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(默认 5s,0 表示每次验证 ModTime) - useSendfile bool - tryFilesPass bool - symlinkCheck bool - internal bool + pathPrefixLen int // 预计算的路径前缀长度,用于零分配路径剥离 + cacheTTL time.Duration // 缓存新鲜度 TTL(默认 5s,0 表示每次验证 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 } diff --git a/internal/handler/static_test.go b/internal/handler/static_test.go index db24905..298aa81 100644 --- a/internal/handler/static_test.go +++ b/internal/handler/static_test.go @@ -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 应被清空") diff --git a/internal/mimeutil/detect.go b/internal/mimeutil/detect.go index a25953c..f454da9 100644 --- a/internal/mimeutil/detect.go +++ b/internal/mimeutil/detect.go @@ -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 }