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:
		
							parent
							
								
									673a5ac2fd
								
							
						
					
					
						commit
						f0a6128db3
					
				|  | @ -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(); | ||||
| 			} | ||||
|  |  | |||
|  | @ -205,6 +205,7 @@ public class WebFluxAutoConfiguration { | |||
| 				cacheControl.setMaxAge(cachePeriod); | ||||
| 			} | ||||
| 			registration.setCacheControl(cacheControl.toHttpCacheControl()); | ||||
| 			registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()); | ||||
| 		} | ||||
| 
 | ||||
| 		@Override | ||||
|  |  | |||
|  | @ -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())); | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue