Spring Boot— CRUD example with Caching
https://github.com/buddhiprab/spring-boot-crud-cache.git
How to configure spring cache in Service methods in conjunction with @Cacheable, @CacheEvict annotations, so that when a record added/updated (write) to database it will be reflected in the caches of findAll, findById (read) methods
i’m using a delivery record example to illustrate the cashing behaviour.
A. findAll() method caching behaviour
- first call to the findAll method: “deliveries” cache will miss, database hit
- second call onwards: “deliveries” cache will hit, no database hit
B. saveOrUpdate() method in conjunction with findAll, findById caching behaviour
- call to the saveOrUpdate method: will do a save or update a delivery record to database and
@Caching(evict = {
@CacheEvict(value=”delivery”, allEntries=true),
@CacheEvict(value=”deliveries”, allEntries=true) })
will cause “deliveries” and “delivery” caches to be cleared
2. now call to findAll or findById method: cache will miss and database will hit because the saveOrUpdate() method cleared the caches, so the new records added to the db table will be fetched and cache will be updated with new records fetched from db
Sample code to demonstrate caching behaviour
Service class
- note the @Cacheable, @CacheEvict annotations, responsible for caching and cache clearing
package com.buddhi.service;
import com.buddhi.dto.DeliveryDto;
import com.buddhi.model.Delivery;
import com.buddhi.repository.DeliveryRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.beans.BeanUtils.copyProperties;
@Slf4j
@Service
public class DeliveryService {
@Autowired
DeliveryRepository deliveryRepository;
@Cacheable("deliveries")
public List<DeliveryDto> findAll(){
log.info("DeliveryService: findAll");
List<Delivery> deliveries = deliveryRepository.findAll();
List<DeliveryDto> deliveryDtos = new ArrayList<>();
for (Delivery delivery:deliveries){
DeliveryDto deliveryDto = new DeliveryDto();
copyProperties(delivery, deliveryDto);
deliveryDtos.add(deliveryDto);
}
return deliveryDtos;
}
@Cacheable("delivery")
public DeliveryDto findById(Long id){
log.info("DeliveryService: findById");
Delivery delivery = deliveryRepository.findById(id).orElse(null);
DeliveryDto deliveryDto = new DeliveryDto();
copyProperties(delivery,deliveryDto);
return deliveryDto;
}
@Caching(evict = {
@CacheEvict(value="delivery", allEntries=true),
@CacheEvict(value="deliveries", allEntries=true)})
public Delivery saveOrUpdate(DeliveryDto deliveryDto){
log.info("DeliveryService: saveOrUpdate, {}", deliveryDto.getPickupName());
Delivery delivery = Delivery.builder()
.pickupName(deliveryDto.getPickupName())
...
.build();
if(deliveryDto.getId()!=null){
delivery.setId(deliveryDto.getId());
}
return deliveryRepository.save(delivery);
}
}
Repository class
package com.buddhi.repository;
import com.buddhi.model.Delivery;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DeliveryRepository extends JpaRepository<Delivery, Long> {
}
Controller class
package com.buddhi.controller;
import com.buddhi.dto.DeliveryDto;
import com.buddhi.model.Delivery;
import com.buddhi.service.DeliveryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping(path = "/deliveries")
public class DeliveryController {
@Autowired
DeliveryService deliveryService;
@PostMapping(path = "")
public ResponseEntity<Long> createOrUpdateDelivery(@RequestBody DeliveryDto deliveryDto) {
log.info("DeliveryController: createOrUpdateDelivery");
Delivery delivery = deliveryService.saveOrUpdate(deliveryDto);
return new ResponseEntity<>(delivery.getId(), HttpStatus.OK);
}
@GetMapping(path = "")
public ResponseEntity<List<DeliveryDto>> getDeliveries() {
log.info("DeliveryController: getDeliveries");
List<DeliveryDto> deliveries = deliveryService.findAll();
return new ResponseEntity<>(deliveries, HttpStatus.OK);
}
@GetMapping(path = "/{id}")
public ResponseEntity<DeliveryDto> getDelivery(@PathVariable Long id) {
log.info("DeliveryController: getDelivery");
DeliveryDto delivery = deliveryService.findById(id);
return new ResponseEntity<>(delivery, HttpStatus.OK);
}
}
Dto class
package com.buddhi.dto;
import com.buddhi.util.CustomDateAndTimeDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import lombok.Data;
import java.util.Date;
@Data
public class DeliveryDto {
private Long id;
//pickup
private String pickupName;
private String pickupAddress;
@JsonDeserialize(using= CustomDateAndTimeDeserialize.class)
private Date pickupDateTime;
private String[] pickupContactNumbers;
private String pickupComment;
//drop
private String dropName;
private String dropAddress;
private String[] dropContactNumbers;
private String dropComment;
}
Model class
package com.buddhi.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.Date;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Builder
@Table(name = "delivery")
public class Delivery {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "pickup_name")
private String pickupName;
@Column(name = "pickup_address")
private String pickupAddress;
@Column(name = "pickup_datetime")
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")
private Date pickupDateTime;
@Column(name = "pickup_contact_numbers")
private String pickupContactNumbers;
@Column(name = "pickup_comment")
private String pickupComment;
@Column(name = "drop_name")
private String dropName;
@Column(name = "drop_address")
private String dropAddress;
@Column(name = "drop_contact_numbers")
private String dropContactNumbers;
@Column(name = "drop_comment")
private String dropComment;
}
SpringBoot Application class
package com.buddhi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class DeliveryApplication {
public static void main(String[] args) {
SpringApplication.run(DeliveryApplication.class, args);
}
}
application.yml
server:
port: 8080
spring:
h2:
console:
enabled: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password: password
platform: org.hibernate.dialect.Oracle10gDialect
continue-on-error: true
jpa:
hibernate:
ddl-auto: none
logging:
level:
root: info
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p --- [%15.15t] %-40.40logger{39} :: %X{correlationId} : %m%n%ex{full}"
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from delivery -->
</parent>
<groupId>com</groupId>
<artifactId>buddhi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>buddhi</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
This Interceptor is to add a tracing id to log, so we can trace the individual requests in the log(this has nothing to do with caching)
package com.buddhi.config;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@Component
public class CorrelationInterceptor extends HandlerInterceptorAdapter {
private static final String CORRELATION_ID_HEADER_NAME = "X-Correlation-Id";
private static final String CORRELATION_ID_LOG_VAR_NAME = "correlationId";
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
final Object handler) throws Exception {
final String correlationId = getCorrelationIdFromHeader(request);
MDC.put(CORRELATION_ID_LOG_VAR_NAME, correlationId);
return true;
}
@Override
public void afterCompletion(final HttpServletRequest request, final HttpServletResponse response,
final Object handler, final Exception ex) throws Exception {
MDC.remove(CORRELATION_ID_LOG_VAR_NAME);
}
private String getCorrelationIdFromHeader(final HttpServletRequest request) {
String correlationId = request.getHeader(CORRELATION_ID_HEADER_NAME);
if (StringUtils.isBlank(correlationId)) {
correlationId = generateUniqueCorrelationId();
}
return correlationId;
}
private String generateUniqueCorrelationId() {
return UUID.randomUUID().toString();
}
}
unique id inserted for request tracing in the log (correlationId variable added in application.yml logging pattern)
add the interceptor in WebMvcConfigurer
package com.buddhi.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private CorrelationInterceptor correlationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(correlationInterceptor);
}
}
folder structure
CustomDateAndTimeDeserialize class
package com.buddhi.util;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.Serializable;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
@Slf4j
public class CustomDateAndTimeDeserialize extends JsonDeserializer<Date> {
private SimpleDateFormat dateFormat = new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss");
@Override
public Date deserialize(JsonParser paramJsonParser,
DeserializationContext paramDeserializationContext)
throws IOException, JsonProcessingException {
String str = paramJsonParser.getText().trim();
try {
return dateFormat.parse(str);
} catch (ParseException e) {
log.error(e.getMessage());
e.printStackTrace();
}
return paramDeserializationContext.parseDate(str);
}
}
schema.sql
create table if not exists delivery
(
id int auto_increment,
pickup_name varchar(100) null,
pickup_address varchar(200) null,
pickup_datetime datetime null,
pickup_contact_numbers varchar(100) null,
pickup_comment text null,
drop_name varchar(100) null,
drop_address varchar(200) null,
drop_contact_numbers varchar(45) null,
drop_comment text null,
primary key (id)
);
create index idx_pdt on delivery (pickup_datetime);
postman requests
1: save 1 record, save to db and will clear caches
2: get all, will return the saved records, get all from db
3: get all again, will get records from cache, no service method hit(note the service log line is not there, only controller line) so no db hit
3: save 2nd record, save to db and will clear caches
4: get all will return 2 records, get all from db
5: save 3rd record, save to db and will clear cache
6: get all will return 3 records, will get from db
7: update 2nd record, will save to db and clear caches
8: get all will return the updated 2nd record, will get all from db
9: call get all again, will get all from cache