Add spring.web.resources.cache.use-last-modified

Prior to this commit, packaging a Spring Boot application as a container
image with Cloud Native Buildpacks could result in unwanted browser
caching behavior, with "Last-Modified" HTTP response headers pointing to
dates in the far past.

This is due to CNB resetting the last-modified date metadata for static
files (for build reproducibility and container layer caching) and Spring
static resource handling relying on that information when serving static
resources.

This commit introduces a new configuration property
`spring.web.resources.cache.use-last-modified` that can be used to
disable this behavior in Spring if the application is meant to run as a
container image built by CNB.

The default value for this property remains `true` since this remains
the default value in Spring Framework and using that information in
other deployment models is a perfectly valid use case.

Fixes gh-24099
This commit is contained in:
Brian Clozel 2020-11-10 13:40:25 +01:00
parent 673a5ac2fd
commit f0a6128db3
6 changed files with 59 additions and 23 deletions

View File

@ -361,6 +361,12 @@ public class WebProperties {
*/
private final Cachecontrol cachecontrol = new Cachecontrol();
/**
* Whether we should use the "lastModified" metadata of the files in HTTP
* caching headers. Enabled by default.
*/
private boolean useLastModified = true;
public Duration getPeriod() {
return this.period;
}
@ -374,6 +380,14 @@ public class WebProperties {
return this.cachecontrol;
}
public boolean isUseLastModified() {
return this.useLastModified;
}
public void setUseLastModified(boolean useLastModified) {
this.useLastModified = useLastModified;
}
private boolean hasBeenCustomized() {
return this.customized || getCachecontrol().hasBeenCustomized();
}

View File

@ -205,6 +205,7 @@ public class WebFluxAutoConfiguration {
cacheControl.setMaxAge(cachePeriod);
}
registration.setCacheControl(cacheControl.toHttpCacheControl());
registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified());
}
@Override

View File

@ -329,13 +329,15 @@ public class WebMvcAutoConfiguration {
if (!registry.hasMappingForPattern("/webjars/**")) {
customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/")
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)
.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()));
}
String staticPathPattern = this.mvcProperties.getStaticPathPattern();
if (!registry.hasMappingForPattern(staticPathPattern)) {
customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern)
.addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations()))
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)
.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()));
}
}

View File

@ -449,6 +449,19 @@ class WebFluxAutoConfigurationTests {
Assertions.setExtractBareNamePropertyMethods(true);
}
@Test
void useLastModified() {
this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false").run((context) -> {
Map<PathPattern, Object> handlerMap = getHandlerMap(context);
assertThat(handlerMap).hasSize(2);
for (Object handler : handlerMap.values()) {
if (handler instanceof ResourceWebHandler) {
assertThat(((ResourceWebHandler) handler).isUseLastModified()).isFalse();
}
}
});
}
@Test
void customPrinterAndParserShouldBeRegisteredAsConverters() {
this.contextRunner.withUserConfiguration(ParserConfiguration.class, PrinterConfiguration.class)

View File

@ -28,7 +28,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
@ -754,26 +753,25 @@ class WebMvcAutoConfigurationTests {
@ParameterizedTest
@ValueSource(strings = { "spring.resources.", "spring.web.resources." })
void cachePeriod(String prefix) {
this.contextRunner.withPropertyValues(prefix + "cache.period:5").run(this::assertCachePeriod);
}
private void assertCachePeriod(AssertableWebApplicationContext context) {
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
assertThat(handlerMap).hasSize(2);
for (Entry<String, Object> entry : handlerMap.entrySet()) {
Object handler = entry.getValue();
if (handler instanceof ResourceHttpRequestHandler) {
assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(5);
assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).isNull();
}
}
this.contextRunner.withPropertyValues(prefix + "cache.period:5").run((context) -> {
assertResourceHttpRequestHandler((context), (handler) -> {
assertThat(handler.getCacheSeconds()).isEqualTo(5);
assertThat(handler.getCacheControl()).isNull();
});
});
}
@ParameterizedTest
@ValueSource(strings = { "spring.resources.", "spring.web.resources." })
void cacheControl(String prefix) {
this.contextRunner.withPropertyValues(prefix + "cache.cachecontrol.max-age:5",
prefix + "cache.cachecontrol.proxy-revalidate:true").run(this::assertCacheControl);
this.contextRunner
.withPropertyValues(prefix + "cache.cachecontrol.max-age:5",
prefix + "cache.cachecontrol.proxy-revalidate:true")
.run((context) -> assertResourceHttpRequestHandler(context, (handler) -> {
assertThat(handler.getCacheSeconds()).isEqualTo(-1);
assertThat(handler.getCacheControl()).usingRecursiveComparison()
.isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate());
}));
}
@Test
@ -939,14 +937,20 @@ class WebMvcAutoConfigurationTests {
});
}
private void assertCacheControl(AssertableWebApplicationContext context) {
@Test
void lastModifiedNotUsedIfDisabled() {
this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false")
.run((context) -> assertResourceHttpRequestHandler(context,
(handler) -> assertThat(handler.isUseLastModified()).isFalse()));
}
private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context,
Consumer<ResourceHttpRequestHandler> handlerConsumer) {
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
assertThat(handlerMap).hasSize(2);
for (Object handler : handlerMap.keySet()) {
if (handler instanceof ResourceHttpRequestHandler) {
assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(-1);
assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).usingRecursiveComparison()
.isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate());
handlerConsumer.accept((ResourceHttpRequestHandler) handler);
}
}
}

View File

@ -8925,7 +8925,9 @@ This means you can just type a single command and quickly get a sensible image i
Refer to the individual plugin documentation on how to use buildpacks with {spring-boot-maven-plugin-docs}#build-image[Maven] and {spring-boot-gradle-plugin-docs}#build-image[Gradle].
NOTE: In order to achieve reproducible builds and container image caching, Buildpacks can manipulate the application resources metadata (such as the file "last modified" information).
You should ensure that your application does not rely on that metadata at runtime.
Spring Boot can use that information when serving static resources, but this can be disabled with configprop:spring.web.resources.cache.use-last-modified[]
[[boot-features-whats-next]]
== What to Read Next