Thursday 25 March 2021

Spring boot: browser cache and etags in Spring boot

In this tutorial, I am going to explain how to work with cache headers in spring boot.

 

When you open a webpage, in browser, you can see multiple calls from client to server to download the resources. For example, open the url ‘https://www.google.com/’ in browser and in the network panel, you can see that multiple calls triggered by browser to download resources like images, css, js etc.,

 


If a client request for multiple resources like this, it leads to lot of network traffic and takes longer to serve these resources.

 

How can we reduce the traffic to server?

HTTP protocol provides a way that allow browsers to cache these resources. Browsers can store these resources in their cache until they expire. As a result, browsers can serve these pages from the local storage instead of requesting it over the network:

 

How can a web-server communicate to the browser to cache a resource?

By setting the response header 'Cache-Control', webserver inform browser to cache this resource.

 

Example

cache-control: max-age=86400, must-revalidate, no-transform

 

Here expiry set to 86400 seconds (i.e, 1 day). By setting this header, server informs client to cache this resource for a day from now.

 

Example code snippet

CacheControl cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS).noTransform().mustRevalidate();

final ByteArrayResource inputStream = ....;
return ResponseEntity.ok().cacheControl(cacheControl).contentLength(inputStream.contentLength()).body(inputStream);


How to set cache header for static resources?

Most of our web applications has static resources like images, files, css, html etc., Using cache headers, you can tell browsers to cache these static resources.

@Configuration
public class StaticResourceConfiguration implements WebMvcConfigurer {

	private static final String[] CLASSPATH_RESOURCE_LOCATIONS = { "classpath:/META-INF/resources/",
			"classpath:/resources/", "classpath:/static/", "classpath:/public/" };

	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS)
				.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).noTransform().mustRevalidate());
	}

}


How to set cache header using interceptor?

@Component
public class CacheInterceptor implements WebMvcConfigurer {
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		WebContentInterceptor interceptor = new WebContentInterceptor();
		interceptor.addCacheMapping(CacheControl.maxAge(1, TimeUnit.HOURS).noTransform().mustRevalidate(),
				"/v1/images/intercept/by-name/*");
		registry.addInterceptor(interceptor);
	}
}


ETag explained

An ETag is basically just a checksum for a file that semantically changes when the content of the file changes. When a client request for a file for the first time, server calculates the etag using the file content and send it back to the client as response header. Whenever client want to request for the same file again, client will send ETag in the HTTP request. Server will recalculate the etag of the file and if the Etag value of the document matches to the server calculated one, server will send a 304 code instead of 200, and no content. The browser will load the contents from its cache.

@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
	FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean = new FilterRegistrationBean<>(
			new ShallowEtagHeaderFilter());
	filterRegistrationBean.addUrlPatterns("/*");
	filterRegistrationBean.setName("etagFilter");
	return filterRegistrationBean;
}


We should use both Cache-Control and etag headers

Browser check Cache-Control to determine whether or not to make a request to the server. If cache expires, client request for a file by sending etag header. Server will recalculate the etag of the file and if the Etag value of the document matches to the server calculated one, server will send a 304 code instead of 200, and no content. The browser will load the contents from its cache

 

Find the below working application.

 

Step 1: Create new maven project ‘cache-headers-demo’.

 

Step 2: Update pom.xml with maven dependencies.

 

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.sample.app</groupId>
	<artifactId>cache-headers-demo</artifactId>
	<version>1</version>

	<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-parent -->
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.4.0</version>
	</parent>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>io.springfox</groupId>
			<artifactId>springfox-swagger2</artifactId>
			<version>2.9.2</version>
		</dependency>
		<dependency>
			<groupId>io.springfox</groupId>
			<artifactId>springfox-swagger-ui</artifactId>
			<version>2.9.2</version>
		</dependency>


		<dependency>
			<groupId>commons-fileupload</groupId>
			<artifactId>commons-fileupload</artifactId>
			<version>1.4</version>
		</dependency>


	</dependencies>
</project>


Step 3: Create static/html and static/images folder under src/main/resources folder.

 

Create hello.html file under static/html folder.

 

hello.html

<html>

	<head>
		<title>Hello World</title>
	</head>
	
	<body>
		<h1>Hello World</h1>
	</body>

</html>


Create banner.png file under static/images folder.

 

banner.png




Step 4: Create a package ‘com.sample.app.interceptor’ and define CacheInterceptor class.

 

CacheInterceptor.java

package com.sample.app.interceptor;

import java.util.concurrent.TimeUnit;

import org.springframework.http.CacheControl;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.WebContentInterceptor;

@Component
public class CacheInterceptor implements WebMvcConfigurer {
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		WebContentInterceptor interceptor = new WebContentInterceptor();
		interceptor.addCacheMapping(CacheControl.maxAge(1, TimeUnit.HOURS).noTransform().mustRevalidate(),
				"/v1/images/intercept/by-name/*");
		registry.addInterceptor(interceptor);
	}
}


Step 5: Create a package ‘com.sample.app.filter’ and define LoggingFilter class.

 

LoggingFilter.java

package com.sample.app.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.springframework.stereotype.Component;

@Component
public class LoggingFilter implements Filter {

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		HttpServletRequest httpServletRequest = (HttpServletRequest) request;
		String requestURI = httpServletRequest.getRequestURI();
		System.out.println("Request received for " + requestURI);

		chain.doFilter(request, response);

	}

}


Step 6: Create a package ‘com.sample.app.dto’ and define ImageUploadDto class.

 

ImageUploadDto.java

package com.sample.app.dto;

import org.springframework.web.multipart.MultipartFile;

public class ImageUploadDto {
	private String description;
	private MultipartFile[] documents;

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public MultipartFile[] getDocuments() {
		return documents;
	}

	public void setDocuments(MultipartFile[] documents) {
		this.documents = documents;
	}

}


Step 7: Create a package 'com.sample.app.config' and define AppConfig, StaticResourceConfiguration and SwaggerConfig classes.

 

AppConfig.java

package com.sample.app.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ShallowEtagHeaderFilter;

import com.sample.app.filter.LoggingFilter;

@Configuration
public class AppConfig {
	@Bean
	public FilterRegistrationBean<LoggingFilter> perfFilter() {
		FilterRegistrationBean<LoggingFilter> registration = new FilterRegistrationBean<>();
		registration.setFilter(new LoggingFilter());
		registration.addUrlPatterns("/*");
		return registration;
	}

	@Bean
	public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
		FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean = new FilterRegistrationBean<>(
				new ShallowEtagHeaderFilter());
		filterRegistrationBean.addUrlPatterns("/*");
		filterRegistrationBean.setName("etagFilter");
		return filterRegistrationBean;
	}
}


StaticResourceConfiguration.java

package com.sample.app.config;

import java.util.concurrent.TimeUnit;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class StaticResourceConfiguration implements WebMvcConfigurer {

	private static final String[] CLASSPATH_RESOURCE_LOCATIONS = { "classpath:/META-INF/resources/",
			"classpath:/resources/", "classpath:/static/", "classpath:/public/" };

	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS)
				.setCacheControl(CacheControl.maxAge(2, TimeUnit.HOURS).noTransform().mustRevalidate());
	}

}


SwaggerConfig.java

package com.sample.app.config;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Component
@EnableAutoConfiguration
@EnableSwagger2
public class SwaggerConfig {

	@Bean
	public Docket userApi() {

		return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().paths(PathSelectors.any())
				.apis(RequestHandlerSelectors.basePackage("com.sample.app.controller")).build();
	}

	private ApiInfo apiInfo() {

		return new ApiInfoBuilder().title("Query builder").description("Query builder using spring specification")
				.version("2.0").build();
	}
}


Step 8: Create a package ‘com.sample.app.controller’ and define ImageController class.

 

ImageController.java

package com.sample.app.controller;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.concurrent.TimeUnit;

import org.apache.commons.io.IOUtils;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.sample.app.dto.ImageUploadDto;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;

@RestController
@RequestMapping(value = "/v1/images/")
@Api(tags = "Image controller", description = "This section contains image related APIs")
public class ImageController {

	private static final String IMAGES_PATH = "/Users/Shared/images/";

	@ApiOperation(value = "Get given image", notes = "This API will get the image")
	@GetMapping(value = "by-name/{name}", produces = { MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_GIF_VALUE,
			MediaType.IMAGE_PNG_VALUE })
	public ResponseEntity<Resource> image(
			@ApiParam(name = "name", value = "Image name.", required = true) @PathVariable("name") String name)
			throws IOException {
		CacheControl cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS).noTransform().mustRevalidate();

		final ByteArrayResource inputStream = new ByteArrayResource(Files.readAllBytes(Paths.get(IMAGES_PATH + name)));
		return ResponseEntity.ok().cacheControl(cacheControl).contentLength(inputStream.contentLength())
				.body(inputStream);
	}

	@ApiOperation(value = "Upload the image", notes = "This API will upload the image")
	@PostMapping("upload")
	public ResponseEntity<String> uploadFiles(@ModelAttribute ImageUploadDto requestDto) throws IOException {

		System.out.println(requestDto.getDescription());
		MultipartFile[] multiPartFiles = requestDto.getDocuments();

		for (MultipartFile multiPartFile : multiPartFiles) {
			// String name = multiPartFile.getName();
			String originalFileName = multiPartFile.getOriginalFilename();

			try (InputStream is = multiPartFile.getInputStream();
					OutputStream os = new FileOutputStream(IMAGES_PATH + originalFileName)) {
				IOUtils.copy(is, os);
			}

		}

		return ResponseEntity.status(HttpStatus.CREATED).body("Success");

	}

	@ApiOperation(value = "Get given image", notes = "This API will get the image")
	@GetMapping(value = "intercept/by-name/{name}", produces = { MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_GIF_VALUE,
			MediaType.IMAGE_PNG_VALUE })
	public ResponseEntity<Resource> getImage(
			@ApiParam(name = "name", value = "Image name.", required = true) @PathVariable("name") String name)
			throws IOException {
		
		final ByteArrayResource inputStream = new ByteArrayResource(Files.readAllBytes(Paths.get(IMAGES_PATH + name)));
		return ResponseEntity.ok().contentLength(inputStream.contentLength())
				.body(inputStream);
	}
}


Step 9: Define App class.

 

App.java

package com.sample.app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
	public static void main(String[] args) {

		SpringApplication.run(App.class, args);

	}
}


Total project structure looks like below.





Run App.java.

 

Open the url ‘http://localhost:8080/images/banner.png’ in browser, you will see a response code 200 along with the headers Cache-Control and ETag.






Reload the url ‘http://localhost:8080/images/banner.png’ again, you will see the status code 304.

 


Same is the case with following urls also.

 

http://localhost:8080/v1/images/by-name/test.png

http://localhost:8080/v1/images/intercept/by-name/test.png

 

You can download complete working application from below link.

https://github.com/harikrishna553/springboot/tree/master/rest/cache-headers-demo



Previous                                                    Next                                                    Home

No comments:

Post a Comment