1. OVERVIEW

Often times I have seen API implementations not taking advantage of client side caching. Consider this example, a REST service needs to get data from a handful of other services and for every request, even though the upstream response might have not changed for the same input, it’s being calculated repeatedly and sent back to the client.

Depending on how expensive this calculation might be, wouldn’t be a better approach if the HTTP request includes data about what it previously has stored from a prior server response in an attempt for the server to find out if this calculation would be needed at all? This will improve the application performance while saving on server resources.
And what about if this expensive calculation is not needed, wouldn’t be a good practice for the server to let the client know that nothing has changed on the server side for that request? This will also save on bandwidth, assuming the client service is able to reconstruct the response payload.

This post focuses on the client side of this improvement, configuring Spring’s RestTemplate to use HttpClient and Ehcache to cache upstream HTTP responses using ETags.

2. REQUIREMENTS

  • Java 7+.
  • Maven 3.2+.
  • Familiarity with Spring Framework.

3. THE DEMO SERVICE 2

This service includes a simple API returning a String. As part of the HTTP response, the ETag header value will be set to the md5 hash of the entity representation (the response body in this demo) via the Spring’s ShallowEtagHeaderFilter.
Basically this means the ETag header value will change for different String responses.

Let’s discuss the relevant parts of the Demo Service 2:

  • pom.xml:
...
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
...

spring-boot-starter-web dependency will be used to implement a RESTful API using Spring.

  • Demo2CachingRestTemplateApplication.java:
package com.asimio.api.demo.main;
...
@SpringBootApplication(scanBasePackages = { "com.asimio.api.demo" })
public class Demo2CachingRestTemplateApplication {

  public static void main(String[] args) {
    SpringApplication.run(Demo2CachingRestTemplateApplication.class, args);
  }

  @Bean
  public Filter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
  }

  @Bean
  public FilterRegistrationBean shallowEtagHeaderFilterRegistration() {
    FilterRegistrationBean result = new FilterRegistrationBean();
    result.setFilter(this.shallowEtagHeaderFilter());
    result.addUrlPatterns("/api/*");
    result.setName("shallowEtagHeaderFilter");
    result.setOrder(1);
    return result;
  }
}

This is the Spring Boot app’s start class as defined in *pom.xml*’s start-class property. It’s also taking care if registering the ShallowEtagHeaderFilter filter mentioned earlier. It’s worth mentioning that:

  • HelloResource.java:
package com.asimio.api.demo.rest;
...
@RestController
@RequestMapping(value = "/api/hello")
public class HelloResource {

  // Shallow implementation, saves bandwidth but doesn't save server resources.
  @RequestMapping(value = "/{name}", method = RequestMethod.GET)
  public String getHello(@PathVariable("name") String name) {
    return String.format("%s %s", "Hello", name);
  }
}

A simple implementation of an endpoint to be used by Demo Service 1.

4. THE DEMO SERVICE 1

This service implements a simple API that uses RestTemplate to delegate requests to Demo Service 2 demonstrating how to configure it using HttpClient and Ehcache to cache responses using ETags. This approach saves us from explicitly caching, updating and evicting objects, managing TTLs, etc. with the associated overhead related to thread safety.

The relevant parts of the Demo Service 1 are:

  • pom.xml:
...
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>net.sf.ehcache</groupId>
  <artifactId>ehcache</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.httpcomponents</groupId>
  <artifactId>httpclient</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.httpcomponents</groupId>
  <artifactId>httpclient-cache</artifactId>
  <version>${httpclient.version}</version>
</dependency>
...

Similarly to Demo Service 2, spring-boot-starter-web dependency is included to implement an API using Spring MVC RESTful.
spring-boot-starter-cache is a Spring Boot starter responsible for creating Caching-related beans depending on classes found in the classpath, for instance ehcache, the cache provider in this tutorial.
httpclient library is used as the underlying library used by RestTemplate to send outbound requests and httpclient-cache is used to provide support for httpclient to cache responses.

  • Demo1CachingRestTemplateApplication.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.asimio.api.demo.main;
...
@SpringBootApplication(scanBasePackages = { "com.asimio.api.demo.main", "com.asimio.api.demo.rest" })
@EnableCaching // for cacheManager and related beans to get auto-configured
public class Demo1CachingRestTemplateApplication {

  @Value("#{cacheManager.getCache('httpClient')}")
  private Cache httpClientCache;

  public static void main(String[] args) {
    SpringApplication.run(Demo1CachingRestTemplateApplication.class, args);
  }

  @Bean
  public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
    PoolingHttpClientConnectionManager result = new PoolingHttpClientConnectionManager();
    result.setMaxTotal(20);
    return result;
  }

  @Bean
  public CacheConfig cacheConfig() {
    CacheConfig result = CacheConfig
      .custom()
      .setMaxCacheEntries(DEFAULT_MAX_CACHE_ENTRIES)
      .build();
    return result;
  }

  @Bean
  public HttpCacheStorage httpCacheStorage() {
    Ehcache ehcache = (Ehcache) this.httpClientCache.getNativeCache();
    HttpCacheStorage result = new EhcacheHttpCacheStorage(ehcache);
    return result;
  }

  @Bean
  public HttpClient httpClient(PoolingHttpClientConnectionManager poolingHttpClientConnectionManager,
    CacheConfig cacheConfig, HttpCacheStorage httpCacheStorage) {

    HttpClient result = CachingHttpClientBuilder
      .create()
      .setCacheConfig(cacheConfig)
      .setHttpCacheStorage(httpCacheStorage)
      .disableRedirectHandling()
      .setConnectionManager(poolingHttpClientConnectionManager)
      .build();
    return result;
  }

  @Bean
  public RestTemplate restTemplate(HttpClient httpClient) {
    HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
    requestFactory.setHttpClient(httpClient);
    return new RestTemplate(requestFactory);
  }
...

First the @EnableCaching allows the cacheManager to be auto-configured.

PoolingHttpClientConnectionManager, HttpClient and RestTemplate beans look similar to the ones included in Troubleshooting Spring’s RestTemplate Requests Timeout except that in this post the HttpClient object is instantiated using CachingHttpClientBuilder while in the other post the HttpClient bean was instantiated using HttpClientBuilder. \

Basically this three beans are used to configure the RestTemplate bean to use Apache HttpClient instead of the default implementation which is based on the JDK plus some basic configuration such as the number of connections in the pool.

It’s also worth mentioning httpClient reference in line 10 refers to the cache name as found in ehcache.xml.

The other interesting beans are CacheConfig and HttpCacheStorage.
HttpCacheStorage is in fact not required to provide client side caching. If it were removed (along with the @EnableCaching annotation), a BasicHttpCacheStorage or ManagedHttpCacheStorage implementation would be used instead as explained in the next table with configuration set in the CacheConfig bean.

HttpCacheStorage implementations Description
BasicHttpCacheStorage Default implementation if cacheDir is set when instantiating an HttpClient instance via CachingHttpClientBuilder.
EhcacheHttpCacheStorage Discussed in this post, uses Ehcache as the backend.
ManagedHttpCacheStorage Default implementation if cacheDir is not set when instantiating an HttpClient instance via CachingHttpClientBuilder.
MemcacheHttpCacheStorage Uses Memcache as the backend.


But using EhcacheHttpCacheStorage allows for more configuration settings, the application might already be using Ehcache and its statistics, data and operations could be accessed via JMX MBean.

JMX - MBeans - Ehcache Stats JMX - MBeans - Ehcache Stats

  • ehcache.xml:
...
<cache
  name="httpClient"
  maxElementsInMemory="10"
  timeToLiveSeconds="86400"
  eternal="false"
  overflowToDisk="false" />
...

The cache configuration used to store the HTTP responses.

  • HelloResource.java:
package com.asimio.api.demo.rest;
...
@RestController
@RequestMapping(value = "/api/hello")
public class HelloResource {

  @Autowired
  private RestTemplate restTemplate;

  @RequestMapping(value = "/{name}", method = RequestMethod.GET)
  public String getHello(@PathVariable(value = "name") String name) {
    ResponseEntity<String> response = this.restTemplate.getForEntity("http://localhost:8080/api/hello/{name}", String.class, name);
    return response.getBody();
  }
}

This is a sample API that sends requests to another web service where caching details are completely transparent to the application. This code doesn’t need to update the cache or evict items, etc.. In fact, it doesn’t know values might have been retrieved from a cache.

5. RUNNING THE SERVICES

Let’s send an HTTP request to Demo Service 1, which in turn sends a request to Demo Service 2:

curl -v "http://localhost:8090/api/hello/orlando"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8090 (#0)
> GET /api/hello/orlando HTTP/1.1
> Host: localhost:8090
> User-Agent: curl/7.51.0
> Accept: */*
>
< HTTP/1.1 200
< X-Application-Context: application:8090
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 13
< Date: Tue, 11 Jul 2017 11:24:56 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
Hello orlando

A successful 200 OK response, but let’s look at the logs generated when Demo Service 1 sends the request to Demo Service 2:

2017-07-11 07:24:56 DEBUG RestTemplate:87 - Created GET request for "http://localhost:8080/api/hello/orlando"
2017-07-11 07:24:56 DEBUG RestTemplate:779 - Setting request Accept header to [text/plain, application/json, application/*+json, */*]
2017-07-11 07:24:56 DEBUG RequestAddCookies:123 - CookieSpec selected: default
2017-07-11 07:24:56 DEBUG RequestAuthCache:77 - Auth cache not set in the context
2017-07-11 07:24:56 DEBUG CachingExec:275 - Cache miss
2017-07-11 07:24:56 DEBUG PoolingHttpClientConnectionManager:255 - Connection request: [route: {}->http://localhost:8080][total kept alive: 0; route allocated: 0 of 2; total allocated: 0 of 20]
2017-07-11 07:24:56 DEBUG PoolingHttpClientConnectionManager:288 - Connection leased: [id: 0][route: {}->http://localhost:8080][total kept alive: 0; route allocated: 1 of 2; total allocated: 1 of 20]
2017-07-11 07:24:56 DEBUG MainClientExec:235 - Opening connection {}->http://localhost:8080
2017-07-11 07:24:56 DEBUG DefaultHttpClientConnectionOperator:139 - Connecting to localhost/127.0.0.1:8080
2017-07-11 07:24:56 DEBUG DefaultHttpClientConnectionOperator:146 - Connection established 127.0.0.1:50965<->127.0.0.1:8080
2017-07-11 07:24:56 DEBUG MainClientExec:256 - Executing request GET /api/hello/orlando HTTP/1.1
2017-07-11 07:24:56 DEBUG MainClientExec:261 - Target auth state: UNCHALLENGED
2017-07-11 07:24:56 DEBUG MainClientExec:267 - Proxy auth state: UNCHALLENGED
2017-07-11 07:24:56 DEBUG headers:133 - http-outgoing-0 >> GET /api/hello/orlando HTTP/1.1
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> Accept: text/plain, application/json, application/*+json, */*
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> Host: localhost:8080
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> Connection: Keep-Alive
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> User-Agent: Apache-HttpClient/4.5.3 (Java/1.8.0_74)
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> Accept-Encoding: gzip,deflate
2017-07-11 07:24:56 DEBUG headers:136 - http-outgoing-0 >> Via: 1.1 localhost (Apache-HttpClient/4.5.3 (cache))
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "GET /api/hello/orlando HTTP/1.1[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "Accept: text/plain, application/json, application/*+json, */*[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "Host: localhost:8080[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "Connection: Keep-Alive[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "User-Agent: Apache-HttpClient/4.5.3 (Java/1.8.0_74)[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "Accept-Encoding: gzip,deflate[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "Via: 1.1 localhost (Apache-HttpClient/4.5.3 (cache))[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 >> "[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "HTTP/1.1 200 [\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "X-Application-Context: application:8080[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "ETag: "023e8caa26fd7411445527af3d9aed055"[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "Content-Type: text/plain;charset=UTF-8[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "Content-Length: 13[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "Date: Tue, 11 Jul 2017 11:24:56 GMT[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:73 - http-outgoing-0 << "[\r][\n]"
2017-07-11 07:24:56 DEBUG wire:87 - http-outgoing-0 << "Hello orlando"
2017-07-11 07:24:56 DEBUG headers:122 - http-outgoing-0 << HTTP/1.1 200
2017-07-11 07:24:56 DEBUG headers:125 - http-outgoing-0 << X-Application-Context: application:8080
2017-07-11 07:24:56 DEBUG headers:125 - http-outgoing-0 << ETag: "023e8caa26fd7411445527af3d9aed055"
2017-07-11 07:24:56 DEBUG headers:125 - http-outgoing-0 << Content-Type: text/plain;charset=UTF-8
2017-07-11 07:24:56 DEBUG headers:125 - http-outgoing-0 << Content-Length: 13
2017-07-11 07:24:56 DEBUG headers:125 - http-outgoing-0 << Date: Tue, 11 Jul 2017 11:24:56 GMT
2017-07-11 07:24:56 DEBUG MainClientExec:285 - Connection can be kept alive indefinitely
2017-07-11 07:24:56 DEBUG PoolingHttpClientConnectionManager:320 - Connection [id: 0][route: {}->http://localhost:8080] can be kept alive indefinitely
2017-07-11 07:24:56 DEBUG PoolingHttpClientConnectionManager:326 - Connection released: [id: 0][route: {}->http://localhost:8080][total kept alive: 1; route allocated: 1 of 2; total allocated: 1 of 20]
2017-07-11 07:24:56 DEBUG RestTemplate:691 - GET request for "http://localhost:8080/api/hello/orlando" resulted in 200 ()
2017-07-11 07:24:56 DEBUG RestTemplate:102 - Reading [java.lang.String] as "text/plain;charset=UTF-8" using [org.springframework.http.converter.StringHttpMessageConverter@51d92a91]

The interesting logs here are the 200 OK response from Demo Service 2 which also includes the header ETag: “023e8caa26fd7411445527af3d9aed055” and Hello orlando in the body.
023e8caa26fd7411445527af3d9aed055 being the md5 digest for Hello orlando.

Let’s now repeat the same request:

curl http://localhost:8090/api/hello/orlando
Hello orlando

Looking again at the logs generated when Demo Service 1 sends the request to Demo Service 2:

2017-07-11 07:26:50 DEBUG RestTemplate:87 - Created GET request for "http://localhost:8080/api/hello/orlando"
2017-07-11 07:26:50 DEBUG RestTemplate:779 - Setting request Accept header to [text/plain, application/json, application/*+json, */*]
2017-07-11 07:26:50 DEBUG RequestAddCookies:123 - CookieSpec selected: default
2017-07-11 07:26:50 DEBUG RequestAuthCache:77 - Auth cache not set in the context
2017-07-11 07:26:50 DEBUG CachingExec:300 - Revalidating cache entry
2017-07-11 07:26:50 DEBUG PoolingHttpClientConnectionManager:255 - Connection request: [route: {}->http://localhost:8080][total kept alive: 1; route allocated: 1 of 2; total allocated: 1 of 20]
2017-07-11 07:26:50 DEBUG wire:87 - http-outgoing-0 << "end of stream"
2017-07-11 07:26:50 DEBUG DefaultManagedHttpClientConnection:79 - http-outgoing-0: Close connection
2017-07-11 07:26:50 DEBUG PoolingHttpClientConnectionManager:288 - Connection leased: [id: 1][route: {}->http://localhost:8080][total kept alive: 0; route allocated: 1 of 2; total allocated: 1 of 20]
2017-07-11 07:26:50 DEBUG MainClientExec:235 - Opening connection {}->http://localhost:8080
2017-07-11 07:26:50 DEBUG DefaultHttpClientConnectionOperator:139 - Connecting to localhost/127.0.0.1:8080
2017-07-11 07:26:50 DEBUG DefaultHttpClientConnectionOperator:146 - Connection established 127.0.0.1:51030<->127.0.0.1:8080
2017-07-11 07:26:50 DEBUG MainClientExec:256 - Executing request GET /api/hello/orlando HTTP/1.1
2017-07-11 07:26:50 DEBUG MainClientExec:261 - Target auth state: UNCHALLENGED
2017-07-11 07:26:50 DEBUG MainClientExec:267 - Proxy auth state: UNCHALLENGED
2017-07-11 07:26:50 DEBUG headers:133 - http-outgoing-1 >> GET /api/hello/orlando HTTP/1.1
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> Accept: text/plain, application/json, application/*+json, */*
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> Host: localhost:8080
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> Connection: Keep-Alive
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> User-Agent: Apache-HttpClient/4.5.3 (Java/1.8.0_74)
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> Accept-Encoding: gzip,deflate
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> Via: 1.1 localhost (Apache-HttpClient/4.5.3 (cache))
2017-07-11 07:26:50 DEBUG headers:136 - http-outgoing-1 >> If-None-Match: "023e8caa26fd7411445527af3d9aed055"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "GET /api/hello/orlando HTTP/1.1[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "Accept: text/plain, application/json, application/*+json, */*[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "Host: localhost:8080[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "Connection: Keep-Alive[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "User-Agent: Apache-HttpClient/4.5.3 (Java/1.8.0_74)[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "Accept-Encoding: gzip,deflate[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "Via: 1.1 localhost (Apache-HttpClient/4.5.3 (cache))[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "If-None-Match: "023e8caa26fd7411445527af3d9aed055"[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 >> "[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 << "HTTP/1.1 304 [\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 << "X-Application-Context: application:8080[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 << "ETag: "023e8caa26fd7411445527af3d9aed055"[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 << "Date: Tue, 11 Jul 2017 11:26:50 GMT[\r][\n]"
2017-07-11 07:26:50 DEBUG wire:73 - http-outgoing-1 << "[\r][\n]"
2017-07-11 07:26:50 DEBUG headers:122 - http-outgoing-1 << HTTP/1.1 304
2017-07-11 07:26:50 DEBUG headers:125 - http-outgoing-1 << X-Application-Context: application:8080
2017-07-11 07:26:50 DEBUG headers:125 - http-outgoing-1 << ETag: "023e8caa26fd7411445527af3d9aed055"
2017-07-11 07:26:50 DEBUG headers:125 - http-outgoing-1 << Date: Tue, 11 Jul 2017 11:26:50 GMT
2017-07-11 07:26:50 DEBUG MainClientExec:285 - Connection can be kept alive indefinitely
2017-07-11 07:26:50 DEBUG PoolingHttpClientConnectionManager:320 - Connection [id: 1][route: {}->http://localhost:8080] can be kept alive indefinitely
2017-07-11 07:26:50 DEBUG PoolingHttpClientConnectionManager:326 - Connection released: [id: 1][route: {}->http://localhost:8080][total kept alive: 1; route allocated: 1 of 2; total allocated: 1 of 20]
2017-07-11 07:26:50 DEBUG RestTemplate:691 - GET request for "http://localhost:8080/api/hello/orlando" resulted in 200 ()
2017-07-11 07:26:50 DEBUG RestTemplate:102 - Reading [java.lang.String] as "text/plain;charset=UTF-8" using [org.springframework.http.converter.StringHttpMessageConverter@51d92a91]

First notice the request sent from Demo Service 1 now includes the header If-None-Match: “023e8caa26fd7411445527af3d9aed055”. Then look at Demo Service 2’s response status, 304 NOT MODIFIED with the same ETag value and no body. But the curl output was Hello orlando, that’s because it was retrieved from the cache.

And that’s it for this post.

Thanks for reading and as always, feedback is very much appreciated. If you found this post helpful and would like to receive updates when content like this gets published, sign up to the newsletter.

6. SOURCE CODE

Accompanying source code for this blog post can be found at:

7. REFERENCES