feat(static): 添加 expires 缓存控制支持

- StaticConfig 添加 Expires 字段,支持 nginx 兼容格式(30d, 1h, max, epoch)
- StaticHandler 添加 SetExpires 方法和缓存响应头设置
- serveFile 自动设置 Cache-Control 和 Expires 响应头
- nginx 转换器正确转换 expires 指令
- --generate-config 输出包含 expires 文档和示例

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-27 14:05:25 +08:00
parent 02265331d4
commit d191e1865d
5 changed files with 107 additions and 4 deletions

View File

@ -319,6 +319,12 @@ type StaticConfig struct {
// Internal 仅允许内部访问 // Internal 仅允许内部访问
// 设置为 true 时,该位置仅允许内部重定向访问 // 设置为 true 时,该位置仅允许内部重定向访问
Internal bool `yaml:"internal"` Internal bool `yaml:"internal"`
// Expires 缓存过期时间
// 支持 nginx 兼容格式30d, 1h, 1m, max, epoch, off
// 设置 Cache-Control: max-age 和 Expires 响应头
// 示例expires: 30d → Cache-Control: max-age=2592000
Expires string `yaml:"expires"`
} }
// ProxyConfig 反向代理配置,支持负载均衡和健康检查。 // ProxyConfig 反向代理配置,支持负载均衡和健康检查。

View File

@ -355,13 +355,17 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) {
buf.WriteString(" try_files: [] # SPA 部署示例: [\"$uri\", \"$uri/\", \"/index.html\"]\n") buf.WriteString(" try_files: [] # SPA 部署示例: [\"$uri\", \"$uri/\", \"/index.html\"]\n")
buf.WriteString(" try_files_pass: false # 内部重定向是否触发中间件\n") buf.WriteString(" try_files_pass: false # 内部重定向是否触发中间件\n")
buf.WriteString(" symlink_check: false # 是否检查符号链接安全(防止路径遍历攻击)\n") buf.WriteString(" symlink_check: false # 是否检查符号链接安全(防止路径遍历攻击)\n")
buf.WriteString(" # expires: \"\" # 缓存过期时间nginx 兼容格式30d, 1h, max, epoch\n")
buf.WriteString(" # 30d → Cache-Control: max-age=2592000\n")
buf.WriteString(" # max → Cache-Control: max-age=315360000, immutable\n")
buf.WriteString(" # epoch → Cache-Control: no-cache\n")
buf.WriteString(" # location_type: \"\" # 位置匹配类型(有效值: exact, prefix, regex, regex_caseless, prefix_priority, named\n") buf.WriteString(" # location_type: \"\" # 位置匹配类型(有效值: exact, prefix, regex, regex_caseless, prefix_priority, named\n")
buf.WriteString(" # internal: false # 仅允许内部重定向访问\n") buf.WriteString(" # internal: false # 仅允许内部重定向访问\n")
} }
buf.WriteString(" # 示例:使用 alias 替换路径\n") buf.WriteString(" # 示例:静态资源缓存配置\n")
buf.WriteString(" # - path: \"/images/\"\n") buf.WriteString(" # - path: \"/assets/\"\n")
buf.WriteString(" # alias: \"/var/www/files/\" # /images/logo.png → /var/www/files/logo.png\n") buf.WriteString(" # root: \"/var/www/assets\"\n")
buf.WriteString(" # index: [\"index.html\"]\n") buf.WriteString(" # expires: \"30d\" # 缓存 30 天\n")
buf.WriteString("\n") buf.WriteString("\n")
// proxy 配置示例 // proxy 配置示例

View File

@ -844,6 +844,10 @@ func convertStaticDirectives(directives []Directive, static *config.StaticConfig
static.Index = append(static.Index, d.Args...) static.Index = append(static.Index, d.Args...)
case "try_files": case "try_files":
static.TryFiles = append(static.TryFiles, d.Args...) static.TryFiles = append(static.TryFiles, d.Args...)
case "expires":
if len(d.Args) > 0 {
static.Expires = d.Args[0]
}
default: default:
if msg, ok := unsupportedDirectives[d.Name]; ok { if msg, ok := unsupportedDirectives[d.Name]; ok {
result.Warnings = append(result.Warnings, Warning{ result.Warnings = append(result.Warnings, Warning{

View File

@ -52,6 +52,7 @@ type StaticHandler struct {
root string root string
alias string alias string
pathPrefix string pathPrefix string
expires string // 缓存过期时间nginx 兼容格式)
// 切片字段 // 切片字段
index []string index []string
tryFiles []string tryFiles []string
@ -224,6 +225,17 @@ func (h *StaticHandler) SetInternal(enabled bool) {
h.internal = enabled h.internal = enabled
} }
// SetExpires 设置缓存过期时间。
//
// 支持 nginx 兼容格式30d, 1h, 1m, max, epoch, off
// 设置后会在响应中添加 Cache-Control 和 Expires 头。
//
// 参数:
// - expires: 过期时间字符串
func (h *StaticHandler) SetExpires(expires string) {
h.expires = expires
}
// SetCacheTTL 设置缓存新鲜度 TTL。 // SetCacheTTL 设置缓存新鲜度 TTL。
// //
// TTL 控制缓存条目的新鲜度验证间隔。 // TTL 控制缓存条目的新鲜度验证间隔。
@ -561,6 +573,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
ctx.Response.SetStatusCode(fasthttp.StatusNotModified) ctx.Response.SetStatusCode(fasthttp.StatusNotModified)
ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
ctx.Response.SkipBody = true ctx.Response.SkipBody = true
return return
} }
@ -572,6 +585,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
// 预压缩文件已发送,补充验证头 // 预压缩文件已发送,补充验证头
ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
return return
} }
} }
@ -586,6 +600,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
return return
} }
// 文件已修改,删除旧缓存 // 文件已修改,删除旧缓存
@ -603,6 +618,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err == nil { if err == nil {
@ -629,6 +645,69 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf
ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath))
ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("ETag", etag)
ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat))
h.setCacheHeaders(ctx)
}
// setCacheHeaders 设置缓存控制响应头。
func (h *StaticHandler) setCacheHeaders(ctx *fasthttp.RequestCtx) {
if h.expires == "" || h.expires == "off" {
return
}
if h.expires == "epoch" {
ctx.Response.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Response.Header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
return
}
if h.expires == "max" {
ctx.Response.Header.Set("Cache-Control", "public, max-age=315360000, immutable")
ctx.Response.Header.Set("Expires", time.Now().Add(315360000*time.Second).UTC().Format(httpTimeFormat))
return
}
maxAge := parseExpires(h.expires)
if maxAge > 0 {
ctx.Response.Header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
ctx.Response.Header.Set("Expires", time.Now().Add(time.Duration(maxAge)*time.Second).UTC().Format(httpTimeFormat))
}
}
// parseExpires 解析 nginx 兼容的过期时间格式。
// 支持格式30d, 1h, 1m, 1s, 30d1h 等
// 返回秒数。
func parseExpires(expires string) int64 {
if expires == "" || expires == "off" {
return 0
}
if expires == "max" {
return 315360000
}
if expires == "epoch" {
return -1
}
var total int64
var num int64
for _, ch := range expires {
switch {
case ch >= '0' && ch <= '9':
num = num*10 + int64(ch-'0')
case ch == 'd':
total += num * 86400
num = 0
case ch == 'h':
total += num * 3600
num = 0
case ch == 'm':
total += num * 60
num = 0
case ch == 's':
total += num
num = 0
}
}
return total
} }
// validateSymlink 验证符号链接是否安全。 // validateSymlink 验证符号链接是否安全。

View File

@ -1098,6 +1098,11 @@ func (s *Server) registerStaticHandlersWithLocationEngine(cfg *config.ServerConf
// 设置 internal 限制 // 设置 internal 限制
staticHandler.SetInternal(static.Internal) staticHandler.SetInternal(static.Internal)
// 设置缓存过期时间
if static.Expires != "" {
staticHandler.SetExpires(static.Expires)
}
// 根据 LocationType 注册路由 // 根据 LocationType 注册路由
locType := static.LocationType locType := static.LocationType
if locType == "" { if locType == "" {
@ -1464,6 +1469,11 @@ func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.Serv
// 设置符号链接安全检查 // 设置符号链接安全检查
staticHandler.SetSymlinkCheck(static.SymlinkCheck) staticHandler.SetSymlinkCheck(static.SymlinkCheck)
// 设置缓存过期时间
if static.Expires != "" {
staticHandler.SetExpires(static.Expires)
}
// 设置 try_files 配置 // 设置 try_files 配置
if len(static.TryFiles) > 0 { if len(static.TryFiles) > 0 {
// 注意tryFilesPass 需要路由器支持,当前实现传入 nil // 注意tryFilesPass 需要路由器支持,当前实现传入 nil