Lab: Implementing Redis Caching in the Book Catalog Application
Objective
Enhance the performance of the Book Catalog application by implementing Redis caching to reduce database query load.
Prerequisites
- Completed Module 4 (Data Persistence with Spring Data JPA)
- Java 11 or higher
- Maven or Gradle
- Docker (for running Redis)
- IDE (IntelliJ IDEA, Eclipse, or VS Code)
Step 1: Set Up Redis Using Docker
- Start a Redis container:
docker run --name redis -p 6379:6379 -d redis
- Verify Redis is running:
docker ps
Step 2: Add Redis Dependencies to Your Project
For Maven (in pom.xml):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
For Gradle (in build.gradle):
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
Step 3: Configure Redis and Caching in application.properties
Add the following properties to your application.properties
file:
# Redis Configuration
spring.redis.host=localhost
spring.redis.port=6379
# Cache Configuration
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
spring.cache.redis.cache-null-values=true
Step 4: Create a Cache Configuration Class
Create a new class called CacheConfig
in a new package called config
:
package com.example.bookcatalog.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfig)
.withCacheConfiguration("books", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)))
.withCacheConfiguration("booksByTitle", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(2)))
.withCacheConfiguration("booksByAuthor", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(2)))
.build();
}
}
Step 5: Update the Book Entity for Serialization
Update your Book
entity to implement Serializable
:
package com.example.bookcatalog.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.io.Serializable;
import java.time.LocalDate;
@Entity
@Table(name = "books")
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
// Existing fields and methods...
// Make sure all your fields have proper getters and setters
}
Step 6: Implement Caching in the Service Layer
Update the BookService
class to use caching annotations:
package com.example.bookcatalog.service;
import com.example.bookcatalog.exception.BookNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class BookService {
private final BookRepository bookRepository;
@Autowired
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Cacheable(value = "books")
@Transactional(readOnly = true)
public List<Book> getAllBooks() {
return bookRepository.findAll();
}
@Cacheable(value = "books", key = "#id")
@Transactional(readOnly = true)
public Book getBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
}
@Cacheable(value = "booksByTitle", key = "#title")
@Transactional(readOnly = true)
public List<Book> getBooksByTitle(String title) {
return bookRepository.findByTitleContainingIgnoreCase(title);
}
@Cacheable(value = "booksByAuthor", key = "#author")
@Transactional(readOnly = true)
public List<Book> getBooksByAuthor(String author) {
return bookRepository.findByAuthorContainingIgnoreCase(author);
}
@Transactional(readOnly = true)
public Page<Book> getBooks(Pageable pageable) {
return bookRepository.findAll(pageable);
}
@CachePut(value = "books", key = "#result.id")
@CacheEvict(value = {"booksByTitle", "booksByAuthor"}, allEntries = true)
@Transactional
public Book createBook(Book book) {
return bookRepository.save(book);
}
@Caching(
put = { @CachePut(value = "books", key = "#id") },
evict = {
@CacheEvict(value = "booksByTitle", allEntries = true),
@CacheEvict(value = "booksByAuthor", allEntries = true)
}
)
@Transactional
public Book updateBook(Long id, Book bookDetails) {
Book book = getBookById(id);
book.setTitle(bookDetails.getTitle());
book.setAuthor(bookDetails.getAuthor());
book.setIsbn(bookDetails.getIsbn());
book.setPublicationDate(bookDetails.getPublicationDate());
book.setPrice(bookDetails.getPrice());
book.setDescription(bookDetails.getDescription());
return bookRepository.save(book);
}
@Caching(
evict = {
@CacheEvict(value = "books", key = "#id"),
@CacheEvict(value = "booksByTitle", allEntries = true),
@CacheEvict(value = "booksByAuthor", allEntries = true)
}
)
@Transactional
public void deleteBook(Long id) {
Book book = getBookById(id);
bookRepository.delete(book);
}
@CacheEvict(value = {"books", "booksByTitle", "booksByAuthor"}, allEntries = true)
public void clearAllCaches() {
// This method intentionally left blank. The caches are cleared by the annotation.
}
}
Step 7: Create a Cache Controller for Management
Create a new controller to manage caches:
package com.example.bookcatalog.controller;
import com.example.bookcatalog.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/cache")
public class CacheController {
private final BookService bookService;
@Autowired
public CacheController(BookService bookService) {
this.bookService = bookService;
}
@DeleteMapping
public ResponseEntity<String> clearAllCaches() {
bookService.clearAllCaches();
return ResponseEntity.ok("All caches have been cleared");
}
}
Step 8: Add Cache Monitoring to Actuator
Update your application.properties
to expose cache information via Actuator:
# Actuator Configuration
management.endpoints.web.exposure.include=health,info,metrics,caches
management.endpoint.health.show-details=always
Step 9: Create a Cache Monitor Service
Create a service to monitor cache performance:
package com.example.bookcatalog.service;
import org.springframework.boot.actuate.metrics.cache.CacheMetricsRegistrar;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
@Service
public class CacheMonitorService {
private final CacheManager cacheManager;
public CacheMonitorService(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public Map<String, Object> getCacheStatistics() {
Map<String, Object> stats = new HashMap<>();
Collection<String> cacheNames = cacheManager.getCacheNames();
stats.put("availableCaches", cacheNames);
stats.put("cacheCount", cacheNames.size());
return stats;
}
}
Step 10: Add a Cache Monitoring Endpoint
Extend the CacheController
to include cache monitoring:
package com.example.bookcatalog.controller;
import com.example.bookcatalog.service.BookService;
import com.example.bookcatalog.service.CacheMonitorService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/cache")
public class CacheController {
private final BookService bookService;
private final CacheMonitorService cacheMonitorService;
@Autowired
public CacheController(BookService bookService, CacheMonitorService cacheMonitorService) {
this.bookService = bookService;
this.cacheMonitorService = cacheMonitorService;
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getCacheStatistics() {
return ResponseEntity.ok(cacheMonitorService.getCacheStatistics());
}
@DeleteMapping
public ResponseEntity<String> clearAllCaches() {
bookService.clearAllCaches();
return ResponseEntity.ok("All caches have been cleared");
}
}
Step 11: Create a Simple Cache Test Controller
Create a controller to demonstrate the caching behavior:
package com.example.bookcatalog.controller;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/cache-demo")
public class CacheDemoController {
private static final Logger logger = LoggerFactory.getLogger(CacheDemoController.class);
private final BookService bookService;
private final Map<Long, LocalDateTime> requestTimes = new HashMap<>();
@Autowired
public CacheDemoController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/book/{id}")
public ResponseEntity<Map<String, Object>> demonstrateBookCaching(@PathVariable Long id) {
// Record start time
LocalDateTime startTime = LocalDateTime.now();
// Check if we have a previous request time for this ID
boolean isCached = requestTimes.containsKey(id);
LocalDateTime previousRequestTime = requestTimes.get(id);
// Execute the potentially cached operation
Book book = bookService.getBookById(id);
// Record end time and calculate duration
LocalDateTime endTime = LocalDateTime.now();
long durationMs = ChronoUnit.MILLIS.between(startTime, endTime);
// Update the request time for this ID
requestTimes.put(id, endTime);
// Prepare response with timing information
Map<String, Object> response = new HashMap<>();
response.put("book", book);
response.put("requestTime", endTime);
response.put("executionTimeMs", durationMs);
response.put("likelyCacheHit", isCached && durationMs < 50); // Assuming cache hits are fast
if (previousRequestTime != null) {
response.put("timeSinceLastRequestMs",
ChronoUnit.MILLIS.between(previousRequestTime, endTime));
}
// Log the request
logger.info("Book ID: {}, Duration: {}ms, Likely Cache Hit: {}",
id, durationMs, response.get("likelyCacheHit"));
return ResponseEntity.ok(response);
}
}
Step 12: Implement a Redis Health Indicator
Create a custom health indicator for Redis:
package com.example.bookcatalog.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.stereotype.Component;
@Component
public class RedisHealthIndicator implements HealthIndicator {
private final RedisConnectionFactory redisConnectionFactory;
public RedisHealthIndicator(RedisConnectionFactory redisConnectionFactory) {
this.redisConnectionFactory = redisConnectionFactory;
}
@Override
public Health health() {
try {
RedisConnection connection = redisConnectionFactory.getConnection();
String pong = new String(connection.ping());
connection.close();
if ("PONG".equalsIgnoreCase(pong)) {
return Health.up()
.withDetail("ping", "PONG")
.withDetail("status", "Redis is operational")
.build();
} else {
return Health.down()
.withDetail("ping", pong)
.withDetail("status", "Redis ping didn't return PONG")
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("status", "Redis connection failed")
.withDetail("error", e.getMessage())
.build();
}
}
}
Step 13: Add Profile-Specific Cache Configuration
Update the application-dev.properties and application-prod.properties files:
application-dev.properties:
# Development Cache Config
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
spring.redis.host=localhost
spring.redis.port=6379
application-prod.properties:
# Production Cache Config
spring.cache.type=redis
spring.cache.redis.time-to-live=600000
spring.redis.host=${REDIS_HOST:localhost}
spring.redis.port=${REDIS_PORT:6379}
spring.redis.password=${REDIS_PASSWORD:}
Step 14: Create a LoadDatabase Class to Populate Test Data
package com.example.bookcatalog.config;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.time.LocalDate;
import java.util.Arrays;
@Configuration
@Profile("dev")
public class LoadDatabase {
@Bean
CommandLineRunner initDatabase(BookRepository repository) {
return args -> {
// Only load sample data if the repository is empty
if (repository.count() == 0) {
repository.saveAll(Arrays.asList(
new Book(null, "Spring Boot in Action", "Craig Walls",
"978-1617292545", LocalDate.of(2015, 12, 28), 39.99,
"Learn Spring Boot through real-world applications"),
new Book(null, "Pro Spring Boot 2", "Felipe Gutierrez",
"978-1484236758", LocalDate.of(2018, 8, 10), 44.99,
"A comprehensive guide to Spring Boot 2"),
new Book(null, "Clean Code", "Robert C. Martin",
"978-0132350884", LocalDate.of(2008, 8, 11), 49.99,
"A handbook of agile software craftsmanship"),
new Book(null, "Effective Java", "Joshua Bloch",
"978-0134685991", LocalDate.of(2018, 1, 6), 54.99,
"Best practices for the Java platform"),
new Book(null, "Design Patterns", "Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides",
"978-0201633610", LocalDate.of(1994, 11, 10), 59.99,
"Elements of Reusable Object-Oriented Software")
));
}
};
}
}
Step 15: Create a Performance Testing Controller
package com.example.bookcatalog.controller;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/performance")
public class PerformanceTestController {
private static final Logger logger = LoggerFactory.getLogger(PerformanceTestController.class);
private final BookService bookService;
@Autowired
public PerformanceTestController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/test/{id}/{iterations}")
public ResponseEntity<Map<String, Object>> performanceTest(
@PathVariable Long id,
@PathVariable int iterations,
@RequestParam(defaultValue = "true") boolean enableCache) {
List<Long> executionTimes = new ArrayList<>();
Book result = null;
// Clear cache if requested
if (!enableCache) {
bookService.clearAllCaches();
}
// Perform the test
for (int i = 0; i < iterations; i++) {
long startTime = System.nanoTime();
result = bookService.getBookById(id);
long endTime = System.nanoTime();
long duration = TimeUnit.NANOSECONDS.toMillis(endTime - startTime);
executionTimes.add(duration);
logger.info("Iteration {}: {}ms", i + 1, duration);
// Small delay to avoid overwhelming the system
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Clear cache between iterations if cache is disabled
if (!enableCache) {
bookService.clearAllCaches();
}
}
// Calculate statistics
long totalTime = executionTimes.stream().mapToLong(Long::longValue).sum();
double averageTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0);
long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0);
long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0);
// Prepare response
Map<String, Object> response = new HashMap<>();
response.put("book", result);
response.put("iterations", iterations);
response.put("cacheEnabled", enableCache);
response.put("executionTimes", executionTimes);
response.put("totalTimeMs", totalTime);
response.put("averageTimeMs", averageTime);
response.put("minTimeMs", minTime);
response.put("maxTimeMs", maxTime);
return ResponseEntity.ok(response);
}
}
Step 16: Update Swagger Documentation
Update your Swagger configuration to include the new endpoints:
package com.example.bookcatalog.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI bookCatalogAPI() {
return new OpenAPI()
.info(new Info()
.title("Book Catalog API")
.description("A Spring Boot REST API for managing a book catalog with Redis caching")
.version("v1")
.contact(new Contact()
.name("Your Name")
.url("https://example.com")
.email("[email protected]"))
.license(new License()
.name("MIT License")
.url("https://opensource.org/licenses/MIT")));
}
}
Step 17: Test Caching Performance
-
Run your Spring Boot application
-
Use the performance testing endpoint to compare cached vs. non-cached performance:
/api/performance/test/1/10?enableCache=true
/api/performance/test/1/10?enableCache=false
- Check the cache statistics:
/api/cache/stats
- Monitor the application health with Redis:
/actuator/health
- View cache
Step 18: Observe Cache Metrics in Actuator
- Access the Actuator metrics endpoint for caches:
/actuator/metrics/cache.gets
/actuator/metrics/cache.puts
/actuator/metrics/cache.evictions
- Look for cache-specific metrics by cache name:
/actuator/metrics/cache.gets?tag=name:books
/actuator/metrics/cache.puts?tag=name:books
- Analyze the hit ratio to determine caching effectiveness
Step 19: Implement a Redis Dashboard Controller
Create a simple dashboard controller to visualize Redis cache performance:
package com.example.bookcatalog.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Controller
@RequestMapping("/dashboard")
public class CacheDashboardController {
private final CacheManager cacheManager;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public CacheDashboardController(CacheManager cacheManager, RedisTemplate<String, Object> redisTemplate) {
this.cacheManager = cacheManager;
this.redisTemplate = redisTemplate;
}
@GetMapping("/redis")
public String redisDashboard(Model model) {
// Get cache names
model.addAttribute("cacheNames", cacheManager.getCacheNames());
// Get Redis stats
Map<String, Object> redisStats = new HashMap<>();
// Get all keys for each cache
Map<String, Set<String>> cacheKeys = new HashMap<>();
for (String cacheName : cacheManager.getCacheNames()) {
Set<String> keys = redisTemplate.keys(cacheName + "*");
cacheKeys.put(cacheName, keys);
redisStats.put(cacheName + "_size", keys != null ? keys.size() : 0);
}
model.addAttribute("redisStats", redisStats);
model.addAttribute("cacheKeys", cacheKeys);
return "redis-dashboard";
}
}
Step 20: Create a Thymeleaf Template for the Redis Dashboard
Create a new file at src/main/resources/templates/redis-dashboard.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Redis Cache Dashboard</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
/>
<style>
.cache-stats {
background-color: #f8f9fa;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.card {
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1>Redis Cache Dashboard</h1>
<div class="cache-stats">
<h3>Cache Statistics</h3>
<div class="row">
<div th:each="cacheName : ${cacheNames}" class="col-md-4">
<div class="card">
<div class="card-header" th:text="${cacheName}">Cache Name</div>
<div class="card-body">
<p>
Entries:
<span th:text="${redisStats.get(cacheName + '_size')}"
>0</span
>
</p>
<h6>Keys:</h6>
<ul class="list-group">
<li
th:each="key : ${cacheKeys.get(cacheName)}"
class="list-group-item"
th:text="${key}"
style="font-size: 0.8rem;"
>
key
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<a href="/api/cache" class="btn btn-danger">Clear All Caches</a>
<a href="/actuator/health" class="btn btn-info ms-2">Health Check</a>
<a href="/api/cache/stats" class="btn btn-primary ms-2"
>Cache Stats API</a
>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Step 21: Create a RedisTemplate Configuration
For better control of serialization, create a RedisTemplate configuration:
package com.example.bookcatalog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
Step 22: Implement Custom TTL for Different Book Types
Extend the caching configuration to set different TTLs based on book categories. First, add a category field to the Book entity:
package com.example.bookcatalog.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.io.Serializable;
import java.time.LocalDate;
@Entity
@Table(name = "books")
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(max = 100, message = "Title must be less than 100 characters")
private String title;
@NotBlank(message = "Author is required")
@Size(max = 100, message = "Author must be less than 100 characters")
private String author;
@NotBlank(message = "ISBN is required")
@Size(min = 10, max = 17, message = "ISBN must be between 10 and 17 characters")
@Column(unique = true)
private String isbn;
@NotNull(message = "Publication date is required")
@PastOrPresent(message = "Publication date cannot be in the future")
private LocalDate publicationDate;
@NotNull(message = "Price is required")
private Double price;
@Size(max = 500, message = "Description must be less than 500 characters")
private String description;
@NotBlank(message = "Category is required")
private String category;
// Constructors
public Book() {
}
public Book(Long id, String title, String author, String isbn, LocalDate publicationDate,
Double price, String description) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.publicationDate = publicationDate;
this.price = price;
this.description = description;
this.category = "General";
}
public Book(Long id, String title, String author, String isbn, LocalDate publicationDate,
Double price, String description, String category) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.publicationDate = publicationDate;
this.price = price;
this.description = description;
this.category = category;
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public LocalDate getPublicationDate() {
return publicationDate;
}
public void setPublicationDate(LocalDate publicationDate) {
this.publicationDate = publicationDate;
}
public Double getPrice() {
return price;
}
public void setPrice(Double price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
Then update the BookService to use dynamic TTLs:
package com.example.bookcatalog.service;
import com.example.bookcatalog.exception.BookNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class BookService {
private final BookRepository bookRepository;
@Autowired
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Cacheable(value = "books")
@Transactional(readOnly = true)
public List<Book> getAllBooks() {
return bookRepository.findAll();
}
@Cacheable(value = "books", key = "#id", condition = "#id > 0")
@Transactional(readOnly = true)
public Book getBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
}
@Cacheable(value = "booksByCategory", key = "#category", condition = "!#category.equals('Fiction')")
@Transactional(readOnly = true)
public List<Book> getBooksByCategory(String category) {
return bookRepository.findByCategoryIgnoreCase(category);
}
@Cacheable(value = "booksByTitle", key = "#title")
@Transactional(readOnly = true)
public List<Book> getBooksByTitle(String title) {
return bookRepository.findByTitleContainingIgnoreCase(title);
}
@Cacheable(value = "booksByAuthor", key = "#author")
@Transactional(readOnly = true)
public List<Book> getBooksByAuthor(String author) {
return bookRepository.findByAuthorContainingIgnoreCase(author);
}
@Transactional(readOnly = true)
public Page<Book> getBooks(Pageable pageable) {
return bookRepository.findAll(pageable);
}
@CachePut(value = "books", key = "#result.id")
@CacheEvict(value = {"booksByTitle", "booksByAuthor", "booksByCategory"}, allEntries = true)
@Transactional
public Book createBook(Book book) {
return bookRepository.save(book);
}
@Caching(
put = { @CachePut(value = "books", key = "#id") },
evict = {
@CacheEvict(value = "booksByTitle", allEntries = true),
@CacheEvict(value = "booksByAuthor", allEntries = true),
@CacheEvict(value = "booksByCategory", allEntries = true)
}
)
@Transactional
public Book updateBook(Long id, Book bookDetails) {
Book book = getBookById(id);
book.setTitle(bookDetails.getTitle());
book.setAuthor(bookDetails.getAuthor());
book.setIsbn(bookDetails.getIsbn());
book.setPublicationDate(bookDetails.getPublicationDate());
book.setPrice(bookDetails.getPrice());
book.setDescription(bookDetails.getDescription());
book.setCategory(bookDetails.getCategory());
return bookRepository.save(book);
}
@Caching(
evict = {
@CacheEvict(value = "books", key = "#id"),
@CacheEvict(value = "booksByTitle", allEntries = true),
@CacheEvict(value = "booksByAuthor", allEntries = true),
@CacheEvict(value = "booksByCategory", allEntries = true)
}
)
@Transactional
public void deleteBook(Long id) {
Book book = getBookById(id);
bookRepository.delete(book);
}
@CacheEvict(value = {"books", "booksByTitle", "booksByAuthor", "booksByCategory"}, allEntries = true)
public void clearAllCaches() {
// This method intentionally left blank. The caches are cleared by the annotation.
}
}
Update the BookRepository to include the new finder method:
package com.example.bookcatalog.repository;
import com.example.bookcatalog.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByTitleContainingIgnoreCase(String title);
List<Book> findByAuthorContainingIgnoreCase(String author);
List<Book> findByCategoryIgnoreCase(String category);
}
Step 23: Update the Controller to Support the Category Field
package com.example.bookcatalog.controller;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book Controller", description = "API for managing books in the catalog")
public class BookController {
private final BookService bookService;
@Autowired
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
@Operation(summary = "Get all books", description = "Returns a list of all books in the catalog")
public ResponseEntity<List<Book>> getAllBooks() {
return ResponseEntity.ok(bookService.getAllBooks());
}
@GetMapping("/paged")
@Operation(summary = "Get paged books", description = "Returns a paginated list of books with optional sorting")
public ResponseEntity<Page<Book>> getPagedBooks(Pageable pageable) {
return ResponseEntity.ok(bookService.getBooks(pageable));
}
@GetMapping("/{id}")
@Operation(summary = "Get book by ID", description = "Returns a book by its ID")
public ResponseEntity<Book> getBookById(@PathVariable Long id) {
return ResponseEntity.ok(bookService.getBookById(id));
}
@GetMapping("/title/{title}")
@Operation(summary = "Get books by title", description = "Returns books containing the given title (case-insensitive)")
public ResponseEntity<List<Book>> getBooksByTitle(@PathVariable String title) {
return ResponseEntity.ok(bookService.getBooksByTitle(title));
}
@GetMapping("/author/{author}")
@Operation(summary = "Get books by author", description = "Returns books by the given author (case-insensitive)")
public ResponseEntity<List<Book>> getBooksByAuthor(@PathVariable String author) {
return ResponseEntity.ok(bookService.getBooksByAuthor(author));
}
@GetMapping("/category/{category}")
@Operation(summary = "Get books by category", description = "Returns books in the given category (case-insensitive)")
public ResponseEntity<List<Book>> getBooksByCategory(@PathVariable String category) {
return ResponseEntity.ok(bookService.getBooksByCategory(category));
}
@PostMapping
@Operation(summary = "Create a book", description = "Creates a new book in the catalog")
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
return new ResponseEntity<>(bookService.createBook(book), HttpStatus.CREATED);
}
@PutMapping("/{id}")
@Operation(summary = "Update a book", description = "Updates an existing book in the catalog")
public ResponseEntity<Book> updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
return ResponseEntity.ok(bookService.updateBook(id, book));
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a book", description = "Deletes a book from the catalog")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
return ResponseEntity.noContent().build();
}
}
Step 24: Update Database Migration for Category Field
Create or update your Flyway migration script to include the category field:
-- V2__add_category_field.sql
ALTER TABLE books ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General' NOT NULL;
Step 25: Test Everything Together
- Start your Spring Boot application
- Add a few books with different categories
- Make requests to get books by category and observe cache behavior
- Test cache eviction when updating or deleting books
- Monitor cache performance using Actuator endpoints
- View the Redis dashboard at
/dashboard/redis
Summary
In this module, we have successfully implemented Redis caching in our Book Catalog application. Here’s what we’ve accomplished:
-
Integrated Redis: We’ve added Redis as a caching layer to our application, which allows us to store frequently accessed data in memory for faster retrieval.
-
Configured Spring Boot Caching: We’ve set up Spring’s cache abstraction with annotations like
@EnableCaching
,@Cacheable
,@CachePut
, and@CacheEvict
to manage our cache. -
Custom Cache TTLs: We’ve implemented different cache expiration times for different types of data, optimizing for access patterns.
-
Cache Monitoring: We’ve added monitoring capabilities through Spring Boot Actuator and a custom dashboard, allowing us to observe cache performance.
-
Performance Testing: We’ve created endpoints to measure and compare the performance improvements from caching.
-
Category-Based Caching: We’ve extended our data model to include categories and implemented conditional caching based on category values.
-
Cache Management: We’ve added the ability to manually clear caches when needed.
-
Profile-Specific Configuration: We’ve set up different cache configurations for development and production environments.
By implementing these caching features, we’ve significantly improved the performance of our Book Catalog application. Database queries that would otherwise be executed repeatedly now utilize the cache, reducing latency and database load. The conditional caching strategies we’ve implemented ensure that the most appropriate TTL values are used for different types of data, optimizing both performance and resource usage.
By Wahid Hamdi