bigywater.tech
Spring · 51

静态资源与缓存头:ResourceHandler 与版本化

静态文件默认带不带缓存头?怎么让浏览器缓存一年又保证发布后能更新?内容哈希版本化到底改了什么?为什么设了长缓存还会拿到旧文件?add-mappings=false 关掉了什么?这篇在 Spring Boot 4.0.8 上逐个请求实测响应头。

0
默认配置下静态文件响应里 Cache-Control 与 ETag 头的个数
31536000 s
开了内容哈希加 365 天缓存之后,没有哈希的原路径 /app.js 也得到的 max-age
206 / 304 / 416
Range 与 If-Modified-Since 的内置支持

默认

实测 · 本机执行输出
  GET /hello.txt → 200 '0123456789abcdefghij'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  GET /app.js → 200 'console.log("app v1");'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}
  GET /css/site.css → 200 'body{color:red}'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/css', 'Content-Length': '16'}
  GET / → 200 '<h1>welcome</h1>'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/html;charset=UTF-8', 'Content-Length': '17'}
  GET /pub.txt → 200 'in public/'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '11'}
  GET /nope.txt → 404 '{"timestamp":"2026-10-19T03:51:48.077Z",'
      响应头:{'Content-Type': 'application/json'}
  HEAD /hello.txt → 200 ''
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  GET /over.txt → 200 '来自控制器的 over.txt'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '27'}

只有 Last-Modified 和 Accept-Ranges;控制器优先于同名静态文件。

条件请求与 Range

实测 · 本机执行输出
  GET /hello.txt → 200 '0123456789abcdefghij'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  条件请求:GET /hello.txt  [If-Modified-Since: Mon, 19 Oct 2026 03:51:45 GMT] → 304 ''
      响应头:{'Last-Modified': '<日期>'}
  条件请求(旧日期):GET /hello.txt  [If-Modified-Since: Mon, 01 Jan 2001 00:00:00 GMT] → 200 '0123456789abcdefghij'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  Range:GET /hello.txt  [Range: bytes=0-4] → 206 '01234'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Range': 'bytes 0-4/21', 'Content-Type': 'text/plain', 'Content-Length': '5'}
  Range:GET /hello.txt  [Range: bytes=15-] → 206 'fghij'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Range': 'bytes 15-20/21', 'Content-Type': 'text/plain', 'Content-Length': '6'}
  Range:GET /hello.txt  [Range: bytes=999-1000] → 416 '{"timestamp":"2026-10-19T03:51:49.766Z",'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Range': 'bytes */21', 'Content-Type': 'application/json'}

304、206、416 都是内置的。

缓存头

实测 · 本机执行输出
  -- spring.web.resources.cache.period=3600
  GET /hello.txt → 200 '0123456789abcdefghij'
      响应头:{'Cache-Control': 'max-age=3600', 'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  GET /app.js → 200 'console.log("app v1");'
      响应头:{'Cache-Control': 'max-age=3600', 'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}
  -- cachecontrol.max-age=1d、cache-public、must-revalidate
  GET /hello.txt → 200 '0123456789abcdefghij'
      响应头:{'Cache-Control': 'max-age=86400, must-revalidate, public', 'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}
  -- cachecontrol.no-cache=true
  GET /hello.txt → 200 '0123456789abcdefghij'
      响应头:{'Cache-Control': 'no-cache', 'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain', 'Content-Length': '21'}

所有静态资源同一个值,长缓存不能配全局。

内容哈希

实测 · 本机执行输出
  GET /url?path=/app.js → 200 '/app-0a5e4fedba3fc41d4a9932a0f872f7aa.js'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '40'}
  GET /url?path=/css/site.css → 200 '/css/site-b3fc414da2e8cd21fb476f6870d7fa'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '46'}
  GET /url?path=/hello.txt → 200 '/hello-3431dfdfe9c4653c16088037b05ad206.'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '43'}
  未版本化的原路径:GET /app.js → 200 'console.log("app v1");'
      响应头:{'Cache-Control': 'max-age=31536000, public', 'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}
  带内容哈希的路径:GET /app-0a5e4fedba3fc41d4a9932a0f872f7aa.js → 200 'console.log("app v1");'
      响应头:{'Cache-Control': 'max-age=31536000, public', 'Last-Modified': '<日期>', 'ETag': 'W/"0a5e4fedba3fc41d4a9932a0f872f7aa"', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}
  错误的哈希:GET /app-00000000000000000000000000000000.js → 404 '{"timestamp":"2026-10-19T03:51:53.836Z",'
      响应头:{'Content-Type': 'application/json'}
  条件请求:GET /app-0a5e4fedba3fc41d4a9932a0f872f7aa.js  [If-Modified-Since: Mon, 19 Oct 2026 03:51:45 GMT] → 304 ''
      响应头:{'Cache-Control': 'max-age=31536000, public', 'Last-Modified': '<日期>'}

URL 随内容变化,长期缓存才安全;原路径也被套上长缓存是陷阱。

固定版本

实测 · 本机执行输出
  GET /url?path=/app.js → 200 '/v7/app.js'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '10'}
  版本化路径:GET /v7/app.js → 200 'console.log("app v1");'
      响应头:{'Last-Modified': '<日期>', 'ETag': 'W/"v7"', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}
  原路径:GET /app.js → 200 'console.log("app v1");'
      响应头:{'Last-Modified': '<日期>', 'Accept-Ranges': 'bytes', 'Content-Type': 'text/javascript', 'Content-Length': '23'}

手动维护的路径前缀,ETag 都一样。

关掉静态资源

实测 · 本机执行输出
  GET /hello.txt → 404 '{"timestamp":"2026-10-19T03:51:56.085Z",'
      响应头:{'Content-Type': 'application/json'}
  GET /pub.txt → 404 '{"timestamp":"2026-10-19T03:51:56.101Z",'
      响应头:{'Content-Type': 'application/json'}
  GET /over.txt → 200 '来自控制器的 over.txt'
      响应头:{'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '27'}
  GET /index.html → 404 '{"timestamp":"2026-10-19T03:51:56.105Z",'
      响应头:{'Content-Type': 'application/json'}

add-mappings=false 连欢迎页一起关。

路径穿越

实测 · 本机执行输出
  GET /%2e%2e/hello.txt → 400 '<!doctype html><html lang="en"><head><ti'
      响应头:{'Content-Type': 'text/html;charset=utf-8', 'Content-Length': '435'}
  GET /css/../hello.txt → 404 '{"timestamp":"2026-10-19T03:51:57.054Z",'
      响应头:{'Content-Type': 'application/json'}
  GET /css/%2e%2e/hello.txt → 404 '{"timestamp":"2026-10-19T03:51:57.070Z",'
      响应头:{'Content-Type': 'application/json'}
  GET /css/..%2fhello.txt → 400 '<!doctype html><html lang="en"><head><ti'
      响应头:{'Content-Type': 'text/html;charset=utf-8', 'Content-Length': '435'}
  GET /..%2f..%2fetc/passwd → 400 '<!doctype html><html lang="en"><head><ti'
      响应头:{'Content-Type': 'text/html;charset=utf-8', 'Content-Length': '435'}
  GET /%2e%2e%2f%2e%2e%2fetc/passwd → 400 '<!doctype html><html lang="en"><head><ti'
      响应头:{'Content-Type': 'text/html;charset=utf-8', 'Content-Length': '435'}
  GET /css/%5c..%5chello.txt → 400 '<!doctype html><html lang="en"><head><ti'
      响应头:{'Content-Type': 'text/html;charset=utf-8', 'Content-Length': '435'}

试了的 7 种写法都没读到目录外的文件。

分步演示

静态资源与缓存头
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动。用 Spring Boot 4.0.8 的 SpringApplication(spring-boot-starter-webmvc,内嵌 Tomcat 11.0.24);静态文件放在编译输出目录的 static/ 与 public/(不是 jar 包内);请求由 Python http.client 发出,Last-Modified 的具体日期在输出里被替换成占位符;各配置分别启动应用
    • 没有验证:从 jar 包内读取静态文件时的 Last-Modified;gzip 预压缩资源(chain.compressed);WebJars 的版本化;ResourceTransformer(如 CSS 里的相对 URL 改写);If-None-Match 与 ETag 的 304;模板里怎么引用带哈希的 URL(Thymeleaf 的 @{} 与 ResourceUrlEncodingFilter);路径穿越只试了 7 种写法,不是完整的安全测试
    京ICP备2026067502号-1京公网安备11010502063487号