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

  1. Start a Redis container:
docker run --name redis -p 6379:6379 -d redis
  1. 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

  1. Run your Spring Boot application

  2. 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
  1. Check the cache statistics:
  • /api/cache/stats
  1. Monitor the application health with Redis:
  • /actuator/health
  1. View cache

Step 18: Observe Cache Metrics in Actuator

  1. Access the Actuator metrics endpoint for caches:
  • /actuator/metrics/cache.gets
  • /actuator/metrics/cache.puts
  • /actuator/metrics/cache.evictions
  1. Look for cache-specific metrics by cache name:
  • /actuator/metrics/cache.gets?tag=name:books
  • /actuator/metrics/cache.puts?tag=name:books
  1. 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

  1. Start your Spring Boot application
  2. Add a few books with different categories
  3. Make requests to get books by category and observe cache behavior
  4. Test cache eviction when updating or deleting books
  5. Monitor cache performance using Actuator endpoints
  6. 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:

  1. 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.

  2. Configured Spring Boot Caching: We’ve set up Spring’s cache abstraction with annotations like @EnableCaching, @Cacheable, @CachePut, and @CacheEvict to manage our cache.

  3. Custom Cache TTLs: We’ve implemented different cache expiration times for different types of data, optimizing for access patterns.

  4. Cache Monitoring: We’ve added monitoring capabilities through Spring Boot Actuator and a custom dashboard, allowing us to observe cache performance.

  5. Performance Testing: We’ve created endpoints to measure and compare the performance improvements from caching.

  6. Category-Based Caching: We’ve extended our data model to include categories and implemented conditional caching based on category values.

  7. Cache Management: We’ve added the ability to manually clear caches when needed.

  8. 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