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(); | 			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() { | 			public Duration getPeriod() { | ||||||
| 				return this.period; | 				return this.period; | ||||||
| 			} | 			} | ||||||
|  | @ -374,6 +380,14 @@ public class WebProperties { | ||||||
| 				return this.cachecontrol; | 				return this.cachecontrol; | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | 			public boolean isUseLastModified() { | ||||||
|  | 				return this.useLastModified; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			public void setUseLastModified(boolean useLastModified) { | ||||||
|  | 				this.useLastModified = useLastModified; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
| 			private boolean hasBeenCustomized() { | 			private boolean hasBeenCustomized() { | ||||||
| 				return this.customized || getCachecontrol().hasBeenCustomized(); | 				return this.customized || getCachecontrol().hasBeenCustomized(); | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
|  | @ -205,6 +205,7 @@ public class WebFluxAutoConfiguration { | ||||||
| 				cacheControl.setMaxAge(cachePeriod); | 				cacheControl.setMaxAge(cachePeriod); | ||||||
| 			} | 			} | ||||||
| 			registration.setCacheControl(cacheControl.toHttpCacheControl()); | 			registration.setCacheControl(cacheControl.toHttpCacheControl()); | ||||||
|  | 			registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		@Override | 		@Override | ||||||
|  |  | ||||||
|  | @ -329,13 +329,15 @@ public class WebMvcAutoConfiguration { | ||||||
| 			if (!registry.hasMappingForPattern("/webjars/**")) { | 			if (!registry.hasMappingForPattern("/webjars/**")) { | ||||||
| 				customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**") | 				customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**") | ||||||
| 						.addResourceLocations("classpath:/META-INF/resources/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(); | 			String staticPathPattern = this.mvcProperties.getStaticPathPattern(); | ||||||
| 			if (!registry.hasMappingForPattern(staticPathPattern)) { | 			if (!registry.hasMappingForPattern(staticPathPattern)) { | ||||||
| 				customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern) | 				customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern) | ||||||
| 						.addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations())) | 						.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); | 		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 | 	@Test | ||||||
| 	void customPrinterAndParserShouldBeRegisteredAsConverters() { | 	void customPrinterAndParserShouldBeRegisteredAsConverters() { | ||||||
| 		this.contextRunner.withUserConfiguration(ParserConfiguration.class, PrinterConfiguration.class) | 		this.contextRunner.withUserConfiguration(ParserConfiguration.class, PrinterConfiguration.class) | ||||||
|  |  | ||||||
|  | @ -28,7 +28,6 @@ import java.util.LinkedHashMap; | ||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.Locale; | import java.util.Locale; | ||||||
| import java.util.Map; | import java.util.Map; | ||||||
| import java.util.Map.Entry; |  | ||||||
| import java.util.concurrent.Executor; | import java.util.concurrent.Executor; | ||||||
| import java.util.concurrent.TimeUnit; | import java.util.concurrent.TimeUnit; | ||||||
| import java.util.function.Consumer; | import java.util.function.Consumer; | ||||||
|  | @ -754,26 +753,25 @@ class WebMvcAutoConfigurationTests { | ||||||
| 	@ParameterizedTest | 	@ParameterizedTest | ||||||
| 	@ValueSource(strings = { "spring.resources.", "spring.web.resources." }) | 	@ValueSource(strings = { "spring.resources.", "spring.web.resources." }) | ||||||
| 	void cachePeriod(String prefix) { | 	void cachePeriod(String prefix) { | ||||||
| 		this.contextRunner.withPropertyValues(prefix + "cache.period:5").run(this::assertCachePeriod); | 		this.contextRunner.withPropertyValues(prefix + "cache.period:5").run((context) -> { | ||||||
| 	} | 			assertResourceHttpRequestHandler((context), (handler) -> { | ||||||
| 
 | 				assertThat(handler.getCacheSeconds()).isEqualTo(5); | ||||||
| 	private void assertCachePeriod(AssertableWebApplicationContext context) { | 				assertThat(handler.getCacheControl()).isNull(); | ||||||
| 		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(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	@ParameterizedTest | 	@ParameterizedTest | ||||||
| 	@ValueSource(strings = { "spring.resources.", "spring.web.resources." }) | 	@ValueSource(strings = { "spring.resources.", "spring.web.resources." }) | ||||||
| 	void cacheControl(String prefix) { | 	void cacheControl(String prefix) { | ||||||
| 		this.contextRunner.withPropertyValues(prefix + "cache.cachecontrol.max-age:5", | 		this.contextRunner | ||||||
| 				prefix + "cache.cachecontrol.proxy-revalidate:true").run(this::assertCacheControl); | 				.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 | 	@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)); | 		Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class)); | ||||||
| 		assertThat(handlerMap).hasSize(2); | 		assertThat(handlerMap).hasSize(2); | ||||||
| 		for (Object handler : handlerMap.keySet()) { | 		for (Object handler : handlerMap.keySet()) { | ||||||
| 			if (handler instanceof ResourceHttpRequestHandler) { | 			if (handler instanceof ResourceHttpRequestHandler) { | ||||||
| 				assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(-1); | 				handlerConsumer.accept((ResourceHttpRequestHandler) handler); | ||||||
| 				assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).usingRecursiveComparison() |  | ||||||
| 						.isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate()); |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -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]. | 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]] | [[boot-features-whats-next]] | ||||||
| == What to Read Next | == What to Read Next | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue