perf: 增加内存缓存提升性能

This commit is contained in:
2026-04-26 05:12:35 +08:00
parent 2112518f80
commit 1140ffc61f
5 changed files with 213 additions and 1004 deletions

329
afi.go
View File

@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"crypto/md5"
"crypto/sha1"
@@ -28,6 +29,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -64,7 +66,7 @@ var skipHidden = flag.Bool("k", false, "跳过隐藏文件")
// var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式
var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名")
var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了
var dav = flag.Bool("dav", true, "启用 WebDAV 接口")
var dav = flag.Bool("dav", false, "启用 WebDAV 接口")
// rpcCall 定义RPC调用的JSON数据结构
// 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和)
@@ -74,9 +76,32 @@ type rpcCall struct {
}
var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径)
var handler http.Handler // HTTP处理器, 用于处理静态文件服务
var authTable = make(map[string]string)
// gzipWriterPool 复用 gzip.Writer减少每次请求的分配开销
var gzipWriterPool = sync.Pool{
New: func() any {
w, err := gzip.NewWriterLevel(nil, gzip.BestSpeed)
if err != nil {
panic(err)
}
return w
},
}
// dirCacheEntry 缓存目录列表数据,避免重复 ReadDir + Stat + 排序
type dirCacheEntry struct {
etag string // ETag 值
folders []rowTemplate // 文件夹列表(不含 "../"
files []rowTemplate // 文件列表
mtime time.Time // 目录修改时间(用于验证)
}
var (
dirCacheMu sync.RWMutex
dirCache = make(map[string]*dirCacheEntry, 128)
)
// check 检查错误, 如果错误不为nil则panic
// 用于简化错误处理
func check(e error) {
@@ -89,7 +114,7 @@ func check(e error) {
// 用于捕获panic并返回500错误, 同时记录日志
// w: HTTP响应写入器
// s: 日志信息参数
func exitPath(w http.ResponseWriter, s ...interface{}) {
func exitPath(w http.ResponseWriter, s ...any) {
if r := recover(); r != nil {
// 发生panic时记录错误并返回500状态码
log.Println("error", s, r)
@@ -104,17 +129,19 @@ func exitPath(w http.ResponseWriter, s ...interface{}) {
// 例如: 1024 -> "1.0k", 1048576 -> "1.0M"
// bytes: 文件大小(字节)
// 返回: 格式化后的字符串(如"1.5M"
var humanUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
func humanize(bytes int64) string {
b := float64(bytes)
u := 0
for {
if b < 1024 {
// 根据当前数量级选择合适的单位
return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u]
}
b = b / 1024
if bytes < 1024 {
return strconv.FormatInt(bytes, 10) + "B"
}
f := float64(bytes) / 1024
u := 1
for f >= 1024 && u < len(humanUnits)-1 {
f /= 1024
u++
}
return strconv.FormatFloat(f, 'f', 1, 64) + humanUnits[u]
}
// parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map大幅提升目录列表性能
@@ -176,131 +203,174 @@ func parseDescriptIon(dirPath string) map[string]string {
// r: HTTP请求
// fullPath: 目录的绝对路径
// path: 请求的URL路径
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) {
// 读取目录内容
files, err := os.ReadDir(fullPath)
check(err)
// dirFi: 目录的 FileInfo由调用者提供避免重复 stat
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string, dirFi fs.FileInfo) {
dirMtime := dirFi.ModTime()
// ETag 基于目录 mtime目录内容变化时自动失效
etag := fmt.Sprintf(`W/"%x"`, dirMtime.UnixNano())
// 只解析一次 DESCRIPT.ION大幅提升性能
descMap := parseDescriptIon(fullPath)
// 缓存查找
dirCacheMu.RLock()
entry, ok := dirCache[fullPath]
dirCacheMu.RUnlock()
// 按文件名(不区分大小写)排序
sort.Slice(files, func(i, j int) bool {
nameI := files[i].Name()
nameJ := files[j].Name()
var folders, files []rowTemplate
// DESCRIPT.ION 始终放在最后
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
return false
if ok && entry.mtime.Equal(dirMtime) {
// 缓存命中 → ETag 检查
if r.Header.Get("If-None-Match") == entry.etag {
w.WriteHeader(http.StatusNotModified)
return
}
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
return true
w.Header().Set("ETag", entry.etag)
folders = entry.folders
files = entry.files
} else {
// 缓存未命中 → 读取目录并构建
entries, err := os.ReadDir(fullPath)
check(err)
descMap := parseDescriptIon(fullPath)
sort.Slice(entries, func(i, j int) bool {
nameI := entries[i].Name()
nameJ := entries[j].Name()
if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") {
return false
}
if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") {
return true
}
return nameI < nameJ
})
mimeCache := make(map[string]string, 16)
folders = make([]rowTemplate, 0, len(entries))
files = make([]rowTemplate, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if *skipHidden && strings.HasPrefix(name, ".") {
continue
}
if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
continue
}
var info fs.FileInfo
if !entry.IsDir() {
var statErr error
info, statErr = entry.Info()
if statErr != nil {
log.Println("error - cant stat a file", statErr)
continue
}
}
href := url.PathEscape(name)
if entry.IsDir() && strings.HasPrefix(href, "/") {
href = strings.TrimPrefix(href, "/")
}
desc := descMap[name]
if entry.IsDir() {
folders = append(folders, rowTemplate{
Name: html.EscapeString(name + "/"),
Href: template.URL(href),
Size: "4.0kB",
Ext: "folder",
Desc: html.EscapeString(desc),
Mime: "inode/directory",
})
} else {
ext := strings.TrimPrefix(filepath.Ext(name), ".")
filemime, ok := mimeCache[ext]
if !ok {
filemime = mime.TypeByExtension("." + ext)
if filemime != "" {
if idx := strings.IndexByte(filemime, ';'); idx >= 0 {
filemime = strings.TrimSpace(filemime[:idx])
}
} else {
if name == "DESCRIPT.ION" {
filemime = "text/plain"
} else {
filemime = "unknown/unknown"
}
}
mimeCache[ext] = filemime
}
files = append(files, rowTemplate{
Name: html.EscapeString(name),
Href: template.URL(href),
Size: humanize(info.Size()),
Ext: html.EscapeString(ext),
Desc: html.EscapeString(desc),
Mime: filemime,
})
}
}
return nameI < nameJ
})
// 确保路径以斜杠结尾
// 入缓存
dirCacheMu.Lock()
dirCache[fullPath] = &dirCacheEntry{etag: etag, folders: folders, files: files, mtime: dirMtime}
dirCacheMu.Unlock()
w.Header().Set("ETag", etag)
}
// --- 构建页面(请求相关部分)---
if !strings.HasSuffix(path, "/") {
path += "/"
}
// 构建当前路径
pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀
pathtext := "/" + strings.TrimPrefix(path, *extraPath)
p := pageTemplate{}
now := time.Now().UTC()
p.TimeStamp = now.Format(time.RFC3339)
// 如果不是根目录, 添加返回上级目录的链接
rowCap := len(folders) + len(files) + 1
p.RowsFolders = make([]rowTemplate, 0, rowCap)
p.RowsFiles = make([]rowTemplate, 0, rowCap)
if path != *extraPath {
p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"})
}
p.RowsFolders = append(p.RowsFolders, folders...)
p.RowsFiles = append(p.RowsFiles, files...)
p.ExtraPath = template.HTML(html.EscapeString(*extraPath))
p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径
p.Path = template.HTML(html.EscapeString(pathtext))
if pathtext == "/" {
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1)
p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", "/")
} else {
p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1)
}
// 遍历目录中的每个条目
// 遍历目录中的每个条目
for _, entry := range files {
name := entry.Name()
// 跳过隐藏文件
if *skipHidden && strings.HasPrefix(name, ".") {
continue
}
// 跳过符号链接(如果不允许跟随)
if !*symlinks && entry.Type()&os.ModeSymlink != 0 {
continue
}
// 获取文件信息(只对文件调用 Info文件夹不需要
var info fs.FileInfo
if !entry.IsDir() {
var statErr error
info, statErr = entry.Info()
if statErr != nil {
log.Println("error - cant stat a file", statErr)
continue
}
}
href := url.PathEscape(name)
// 处理文件夹链接的斜杠问题
if entry.IsDir() && strings.HasPrefix(href, "/") {
href = strings.Replace(href, "/", "", 1)
}
desc := descMap[name]
// 根据类型添加到不同的列表
if entry.IsDir() {
row := rowTemplate{
Name: html.EscapeString(name + "/"),
Href: template.URL(href),
Size: "4.0kB",
Ext: "folder",
Desc: html.EscapeString(desc),
Mime: "inode/directory",
}
p.RowsFolders = append(p.RowsFolders, row)
} else {
// 文件:使用 info.Size()
ext := strings.TrimPrefix(filepath.Ext(name), ".")
filemime := mime.TypeByExtension("." + ext)
filemime, _, _ = mime.ParseMediaType(filemime)
if filemime == "" {
if name == "DESCRIPT.ION" {
filemime = "text/plain"
} else {
filemime = "unknown/unknown"
}
}
row := rowTemplate{
Name: html.EscapeString(name),
Href: template.URL(href),
Size: humanize(info.Size()),
Ext: html.EscapeString(ext),
Desc: html.EscapeString(desc),
Mime: filemime,
}
p.RowsFiles = append(p.RowsFiles, row)
}
p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", filepath.Base(fullPath))
}
// 检查客户端是否支持gzip压缩
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Type", "text/html")
// 渲染到 buffer按大小决定是否 gzip<1KB 直接发,压缩了更慢)
var buf bytes.Buffer
tmpl.Execute(&buf, p)
body := buf.Bytes()
w.Header().Set("Content-Type", "text/html")
if len(body) > 1024 && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Add("Content-Encoding", "gzip")
// 使用gzip压缩响应内容, 提高传输效率
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
check(err)
defer gz.Close()
tmpl.Execute(gz, p)
gz := gzipWriterPool.Get().(*gzip.Writer)
gz.Reset(w)
gz.Write(body)
gz.Close()
gzipWriterPool.Put(gz)
} else {
tmpl.Execute(w, p)
w.Write(body)
}
}
@@ -309,8 +379,6 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
// w: HTTP响应写入器
// r: HTTP请求
func doContent(w http.ResponseWriter, r *http.Request) {
// query := r.URL.Query()
// 此处可以接收参数
// 如果URL不以配置的前缀开头, 重定向到正确的前缀
if !strings.HasPrefix(r.URL.Path, *extraPath) {
http.Redirect(w, r, *extraPath, http.StatusFound)
@@ -322,14 +390,20 @@ func doContent(w http.ResponseWriter, r *http.Request) {
defer exitPath(w, "get content", path)
// 获取安全的绝对路径
fullPath := enforcePath(path)
stat, errStat := os.Stat(fullPath)
check(errStat)
// 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理
if stat.IsDir() {
replyList(w, r, fullPath, path)
// 只 Open + Stat 一次,避免 FileServer 内部二次 stat
f, err := os.Open(fullPath)
check(err)
fi, err := f.Stat()
check(err)
if fi.IsDir() {
f.Close()
replyList(w, r, fullPath, path, fi)
} else {
handler.ServeHTTP(w, r)
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(fi.Name())))
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
f.Close()
}
}
@@ -462,8 +536,8 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
// 打开文件并复制到ZIP中
file, err := os.Open(path)
check(err)
defer file.Close()
_, err = io.Copy(headerWriter, file)
file.Close()
check(err)
return nil
})
@@ -573,7 +647,14 @@ func main() {
check(err)
// 创建HTTP服务器
server := &http.Server{Addr: *host + ":" + *port, Handler: handler}
server := &http.Server{
Addr: *host + ":" + *port,
Handler: nil,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 注册路由处理器
http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和)
@@ -585,8 +666,6 @@ func main() {
http.HandleFunc(*extraPath+"login", authLogin) // 登录接口
http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口
http.HandleFunc("/", doContent) // 主内容处理接口
// 创建静态文件服务器, 用于直接提供文件下载
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
// 输出启动信息
fmt.Printf("Agile File Interface 版本 %s\n", ver)