Skip to content

Commit

Permalink
Add spring.web.resources.cache.use-last-modified
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bclozel committed Nov 10, 2020
1 parent 673a5ac commit f0a6128
Show file tree
Hide file tree
Showing 6 changed files with 59 additions and 23 deletions.
Expand Up @@ -361,6 +361,12 @@ public static class Cache {
*/
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;
}
Expand All @@ -374,6 +380,14 @@ public Cachecontrol getCachecontrol() {
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();
}
Expand Down
Expand Up @@ -205,6 +205,7 @@ private void configureResourceCaching(ResourceHandlerRegistration registration)
cacheControl.setMaxAge(cachePeriod);
}
registration.setCacheControl(cacheControl.toHttpCacheControl());
registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified());
}

@Override
Expand Down
Expand Up @@ -329,13 +329,15 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
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()));
}
}

Expand Down
Expand Up @@ -449,6 +449,19 @@ void cacheControl(String prefix) {
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)
Expand Down
Expand Up @@ -28,7 +28,6 @@
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;
Expand Down Expand Up @@ -754,26 +753,25 @@ void httpMessageConverterThatUsesConversionServiceDoesNotCreateACycle() {
@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
Expand Down Expand Up @@ -939,14 +937,20 @@ void urlPathHelperDoesNotUseFullPathWithAdditionalUntypedDispatcherServlet() {
});
}

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);
}
}
}
Expand Down
Expand Up @@ -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
Expand Down

0 comments on commit f0a6128

Please sign in to comment.