feat(cache): store ContentType in FileEntry for cache hits

- Add ContentType field to FileEntry struct
- Update Set method signature to accept contentType parameter
- Use cached ContentType in static.go cache hit branches
- Update all test files to use new Set signature

This avoids redundant MIME type detection on cache hits,
reducing lock contention in mimeutil.DetectContentType.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-30 16:13:42 +08:00
parent 23fdcf89ae
commit 3c96f12f74
5 changed files with 60 additions and 54 deletions

View File

@ -35,7 +35,7 @@ func BenchmarkFileCacheGet(b *testing.B) {
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()
@ -65,14 +65,14 @@ func BenchmarkFileCacheSet(b *testing.B) {
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/newfile%d.txt", i)
data := []byte("new cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
})
}
@ -91,7 +91,7 @@ func BenchmarkFileCacheSet_Pooled(b *testing.B) {
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ReportAllocs()
@ -99,7 +99,7 @@ func BenchmarkFileCacheSet_Pooled(b *testing.B) {
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/newfile%d.txt", i)
data := []byte("new cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
})
}
@ -114,7 +114,7 @@ func BenchmarkFileCacheSetNoEviction(b *testing.B) {
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
}
@ -131,7 +131,7 @@ func BenchmarkFileCacheConcurrent(b *testing.B) {
for i := range size {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()
@ -142,7 +142,7 @@ func BenchmarkFileCacheConcurrent(b *testing.B) {
if i%10 == 0 {
path := fmt.Sprintf("/newfile%d.txt", i)
data := []byte("updated data content")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
} else {
path := fmt.Sprintf("/file%d.txt", i%size)
fc.Get(path)
@ -163,7 +163,7 @@ func BenchmarkFileCacheGetOnly(b *testing.B) {
for i := range 1000 {
path := fmt.Sprintf("/static/file%d.css", i)
data := make([]byte, 1024) // 1KB 数据
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()
@ -188,14 +188,14 @@ func BenchmarkFileCacheSizeEviction(b *testing.B) {
data := make([]byte, 1024) // 1KB 每条
for i := range 1000 {
path := fmt.Sprintf("/file%d.txt", i)
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/newfile%d.txt", i)
newData := make([]byte, 1024)
_ = fc.Set(path, newData, int64(len(newData)), time.Now())
_ = fc.Set(path, newData, int64(len(newData)), time.Now(), "text/plain")
}
}
@ -208,7 +208,7 @@ func BenchmarkFileCacheLRUTouch(b *testing.B) {
for i := range 100 {
path := fmt.Sprintf("/file%d.txt", i)
data := []byte("cached data")
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
}
b.ResetTimer()

View File

@ -36,7 +36,7 @@ func TestFileCacheSetGet(t *testing.T) {
path := "/test/file.txt"
data := []byte("Hello, World!")
err := fc.Set(path, data, int64(len(data)), time.Now())
err := fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
if err != nil {
t.Errorf("Set() error: %v", err)
}
@ -53,7 +53,7 @@ func TestFileCacheSetGet(t *testing.T) {
func TestFileCacheDelete(t *testing.T) {
fc := NewFileCache(10, 1024, 1*time.Hour)
_ = fc.Set("/test.txt", []byte("data"), 4, time.Now())
_ = fc.Set("/test.txt", []byte("data"), 4, time.Now(), "text/plain")
fc.Delete("/test.txt")
@ -67,12 +67,12 @@ func TestFileCacheLRUEviction(t *testing.T) {
// 最大 3 个条目
fc := NewFileCache(3, 0, 1*time.Hour)
_ = fc.Set("/a", []byte("a"), 1, time.Now())
_ = fc.Set("/b", []byte("b"), 1, time.Now())
_ = fc.Set("/c", []byte("c"), 1, time.Now())
_ = fc.Set("/a", []byte("a"), 1, time.Now(), "text/plain")
_ = fc.Set("/b", []byte("b"), 1, time.Now(), "text/plain")
_ = fc.Set("/c", []byte("c"), 1, time.Now(), "text/plain")
// 再添加一个,应该淘汰 /a
_ = fc.Set("/d", []byte("d"), 1, time.Now())
_ = fc.Set("/d", []byte("d"), 1, time.Now(), "text/plain")
_, ok := fc.Get("/a")
if ok {
@ -216,11 +216,11 @@ func TestFileCacheSizeEviction(t *testing.T) {
// 最大 10 字节
fc := NewFileCache(0, 10, 1*time.Hour)
_ = fc.Set("/a", []byte("12345"), 5, time.Now())
_ = fc.Set("/b", []byte("12345"), 5, time.Now())
_ = fc.Set("/a", []byte("12345"), 5, time.Now(), "text/plain")
_ = fc.Set("/b", []byte("12345"), 5, time.Now(), "text/plain")
// 再添加 6 字节,应该淘汰一个
_ = fc.Set("/c", []byte("123456"), 6, time.Now())
_ = fc.Set("/c", []byte("123456"), 6, time.Now(), "text/plain")
stats := fc.Stats()
if stats.Size > 10 {
@ -231,7 +231,7 @@ func TestFileCacheSizeEviction(t *testing.T) {
func TestFileCacheInactiveEviction(t *testing.T) {
fc := NewFileCache(10, 1024, 100*time.Millisecond)
_ = fc.Set("/test", []byte("data"), 4, time.Now())
_ = fc.Set("/test", []byte("data"), 4, time.Now(), "text/plain")
// 立即获取应该成功
_, ok := fc.Get("/test")
@ -252,8 +252,8 @@ func TestFileCacheInactiveEviction(t *testing.T) {
func TestFileCacheClear(t *testing.T) {
fc := NewFileCache(10, 1024, 1*time.Hour)
_ = fc.Set("/a", []byte("a"), 1, time.Now())
_ = fc.Set("/b", []byte("b"), 1, time.Now())
_ = fc.Set("/a", []byte("a"), 1, time.Now(), "text/plain")
_ = fc.Set("/b", []byte("b"), 1, time.Now(), "text/plain")
fc.Clear()
@ -266,8 +266,8 @@ func TestFileCacheClear(t *testing.T) {
func TestFileCacheStats(t *testing.T) {
fc := NewFileCache(100, 1024, 1*time.Hour)
_ = fc.Set("/a", []byte("12345"), 5, time.Now())
_ = fc.Set("/b", []byte("12345"), 5, time.Now())
_ = fc.Set("/a", []byte("12345"), 5, time.Now(), "text/plain")
_ = fc.Set("/b", []byte("12345"), 5, time.Now(), "text/plain")
stats := fc.Stats()
if stats.Entries != 2 {
@ -569,7 +569,7 @@ func TestFileCacheRefreshCachedAt(t *testing.T) {
data := []byte("test data")
// 设置缓存
_ = fc.Set(path, data, int64(len(data)), time.Now())
_ = fc.Set(path, data, int64(len(data)), time.Now(), "text/plain")
// 获取原始 CachedAt 时间
entry, ok := fc.Get(path)

View File

@ -29,14 +29,15 @@ import (
// FileEntry 文件缓存条目,存储单个文件的缓存信息。
type FileEntry struct {
ModTime time.Time
CachedAt time.Time // 缓存时间,用于 TTL 验证(新鲜度)
LastAccess time.Time
element *list.Element
Path string
Data []byte
Size int64
ETag string // 预计算的 ETag避免每次请求重新计算
ModTime time.Time
CachedAt time.Time // 缓存时间,用于 TTL 验证(新鲜度)
LastAccess time.Time
element *list.Element
Path string
Data []byte
Size int64
ETag string // 预计算的 ETag避免每次请求重新计算
ContentType string // 预计算的 MIME 类型,避免每次请求重新检测
}
// generateETag 基于 ModTime 和 Size 生成 ETag。
@ -173,10 +174,11 @@ func (c *FileCache) Get(path string) (*FileEntry, bool) {
// - data: 文件内容字节
// - size: 文件大小(字节)
// - modTime: 文件最后修改时间
// - contentType: MIME 类型
//
// 返回值:
// - error: 当前实现始终返回 nil
func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time) error {
func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time, contentType string) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -190,6 +192,7 @@ func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time)
entry.Size = size
entry.ModTime = modTime
entry.ETag = etag
entry.ContentType = contentType
entry.CachedAt = time.Now() // 更新缓存时间
entry.LastAccess = time.Now()
c.currentSize += size
@ -205,6 +208,7 @@ func (c *FileCache) Set(path string, data []byte, size int64, modTime time.Time)
entry.Size = size
entry.ModTime = modTime
entry.ETag = etag
entry.ContentType = contentType
entry.CachedAt = time.Now()
entry.LastAccess = time.Now()
entry.element = c.lruList.PushFront(entry)
@ -266,6 +270,7 @@ func (c *FileCache) removeEntry(entry *FileEntry) {
entry.CachedAt = time.Time{}
entry.LastAccess = time.Time{}
entry.ETag = ""
entry.ContentType = ""
entry.element = nil
c.entryPool.Put(entry)
}

View File

@ -37,7 +37,7 @@ func BenchmarkFileCacheSetAllocation_New(b *testing.B) {
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/new/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
}
@ -52,7 +52,7 @@ func BenchmarkFileCacheSetAllocation_Update(b *testing.B) {
size := int64(len(data))
for i := range 1000 {
path := fmt.Sprintf("/update/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
b.ReportAllocs()
@ -61,7 +61,7 @@ func BenchmarkFileCacheSetAllocation_Update(b *testing.B) {
for i := 0; b.Loop(); i++ {
// 循环更新已有条目
path := fmt.Sprintf("/update/file%d.txt", i%1000)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
}
@ -80,7 +80,7 @@ func BenchmarkFileCacheSetAllocation_Eviction(b *testing.B) {
size := int64(len(data))
for i := range 100 {
path := fmt.Sprintf("/evict/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
b.ReportAllocs()
@ -89,7 +89,7 @@ func BenchmarkFileCacheSetAllocation_Eviction(b *testing.B) {
for i := 0; b.Loop(); i++ {
// 每个 Set 都触发淘汰
path := fmt.Sprintf("/evict/new%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
}
@ -105,7 +105,7 @@ func BenchmarkFileCacheSetAllocation_EvictionWithPool(b *testing.B) {
// 预填充
for i := range 100 {
path := fmt.Sprintf("/pool/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
b.ReportAllocs()
@ -113,7 +113,7 @@ func BenchmarkFileCacheSetAllocation_EvictionWithPool(b *testing.B) {
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/pool/new%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
}
@ -130,7 +130,7 @@ func BenchmarkFileCacheSetAllocation_MemoryLimit(b *testing.B) {
// 预填充到接近上限
for i := range 900 {
path := fmt.Sprintf("/mem/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
b.ReportAllocs()
@ -138,7 +138,7 @@ func BenchmarkFileCacheSetAllocation_MemoryLimit(b *testing.B) {
for i := 0; b.Loop(); i++ {
path := fmt.Sprintf("/mem/new%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
}
@ -158,7 +158,7 @@ func BenchmarkFileCacheSetAllocation_Concurrent(b *testing.B) {
i := 0
for pb.Next() {
path := fmt.Sprintf("/conc/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
i++
}
})
@ -174,7 +174,7 @@ func BenchmarkFileCacheSetAllocation_ConcurrentEviction(b *testing.B) {
// 预填充
for i := range 100 {
path := fmt.Sprintf("/concevict/file%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
}
b.ReportAllocs()
@ -184,7 +184,7 @@ func BenchmarkFileCacheSetAllocation_ConcurrentEviction(b *testing.B) {
i := 0
for pb.Next() {
path := fmt.Sprintf("/concevict/new%d.txt", i)
fc.Set(path, data, size, time.Now())
fc.Set(path, data, size, time.Now(), "text/plain")
i++
}
})

View File

@ -660,7 +660,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
return
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
@ -681,7 +681,7 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string)
h.fileCache.RefreshCachedAt(filePath)
}
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
return
@ -734,9 +734,9 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
// 检查文件是否被修改
if entry.ModTime.Equal(info.ModTime()) {
// 缓存命中且文件未修改
// 使用缓存的 ETag,避免重新生成
// 使用缓存的 ETag 和 ContentType,避免重新生成
ctx.Response.SetBody(entry.Data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.SetContentType(entry.ContentType)
ctx.Response.Header.Set("ETag", entry.ETag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
@ -776,12 +776,13 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
}
// 存入缓存(仅对小文件缓存)
contentType := mimeutil.DetectContentType(filePath)
if h.fileCache != nil && info.Size() < 1024*1024 { // < 1MB
_ = h.fileCache.Set(filePath, data, info.Size(), info.ModTime())
_ = h.fileCache.Set(filePath, data, info.Size(), info.ModTime(), contentType)
}
ctx.Response.SetBody(data)
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.SetContentType(contentType)
ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)