Using Reactor Mono.cache() for Memoization In Spring
Memoization is an optimization technique used to speed up applications by storing the results of expensive function calls and reusing the cached result when the same inputs occur again. In the context of reactive programming, memoization helps avoid repeated executions of costly operations by caching their results. Let us delve into understanding how Spring Reactor Mono can be used as cache.
1. Introduction
The Mono.cache() operator in Project Reactor allows you to cache the result of a Mono and replay it to subsequent subscribers. This is particularly useful when dealing with expensive operations like network calls or database queries.
A marble diagram for Mono.cache()
would look like this:
(Expensive operation) --Mono--> (cache) --Mono--> (result)
In this diagram, the first subscriber triggers the expensive operation, and its result is cached. Subsequent subscribers receive the cached result without triggering the expensive operation again.
1.1 Advantages of Mono.cache()
- Improved Performance:
- Caching reduces the need for redundant computations or data fetch operations, leading to faster response times.
- Subsequent subscribers to the cached
Mono
can reuse the cached result, avoiding the overhead of repeated operations.
- Resource Optimization:
- Reduces load on backend services or databases by avoiding repeated calls for the same data.
- Optimizes network usage by minimizing the number of requests.
- Consistency:
- Ensures consistent results for multiple subscribers within the cache duration, as they all receive the same cached value.
- Controlled Caching:
- Allows fine-grained control over caching duration, ensuring data is refreshed periodically as needed.
1.2 Use Cases for Mono.cache()
- Expensive Computations:
- When performing computationally expensive operations, caching the result can significantly reduce the processing time for subsequent requests.
- Example: Complex data transformations, machine learning model inference.
- Frequent Data Access:
- When data is frequently accessed by multiple consumers but does not change often.
- Example: Configuration settings, user profile data in a session.
- API Rate Limiting:
- When dealing with external APIs that have rate limits, caching responses can help stay within those limits.
- Example: Third-party service integrations where the number of API calls is restricted.
- Static Data:
- Data that is static or changes infrequently can benefit from caching to avoid unnecessary fetch operations.
- Example: Country lists, static content for web pages.
- Database Query Results:
- Caching results of database queries that are costly in terms of execution time or resource usage.
- Example: Aggregated analytics data, reporting data.
- External Service Calls:
- When calling external services where latency can be high, caching the responses can improve overall application responsiveness.
- Example: Geolocation services, currency conversion rates.
2. Example Setup
Let’s set up a simple Spring boot example.
2.1 Dependencies
You can use Spring Initializr (https://start.spring.io/) to create a Spring Boot project. Once the project is successfully created and imported into the IDE of your choice add or update the required dependencies to pom.xml
(for Maven) or build.gradle
(for Gradle) file.
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.1</version> <relativePath /> <!-- lookup parent from repository --> </parent> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
Similarly, you can add the required dependencies in the Gradle file if you use a Gradle build platform.
2.2 Create a Data service
Create a data service class containing a fetchData
method to simulate an expensive operation by delaying the emission of the data by 2 seconds using delayElement(…)
method. The Mono.fromSupplier
method is used to create a Mono
that emits data when subscribed to.
package com.example.memoizationexample; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import java.time.Duration; @Service public class DataService { public Mono<String> fetchData() { return Mono.fromSupplier(() -> { System.out.println("Fetching data from service..."); return "Data from service"; }).delayElement(Duration.ofSeconds(2)); } }
2.3 Create a controller class
Create a controller class and expose the different HTTP GET mapping endpoints.
/data-no-cache
: Fetches data without memoization. Each request will trigger thefetchData
method, simulating the 2-second delay./data-cache
: Fetches data with memoization usingMono.cache()
. The first request will trigger thefetchData
method and subsequent requests will return the cached result./data-cache-duration
: Fetches data with memoization usingMono.cache(Duration.ofSeconds(10))
. The first request will trigger thefetchData
method and subsequent requests within 10 seconds will return the cached result. After 10 seconds, the cache will expire, and a new fetch operation will be triggered.
package com.example.memoizationexample; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; @RestController public class DataController { private final DataService dataService; private final Mono<String> cachedData; private final Mono<String> cachedDataWithDuration; public DataController(DataService dataService) { this.dataService = dataService; this.cachedData = dataService.fetchData().cache(); this.cachedDataWithDuration = dataService.fetchData().cache(Duration.ofSeconds(10)); } @GetMapping("/data-no-cache") public Mono<String> getDataNoCache() { return dataService.fetchData(); } @GetMapping("/data-cache") public Mono<String> getDataWithCache() { return cachedData; } @GetMapping("/data-cache-duration") public Mono<String> getDataWithCacheDuration() { return cachedDataWithDuration; } }
2.4 Create a main class
Create a main class of the Spring boot application. The main()
method initializes the application.
package com.example.memoizationexample; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class MemoizationExampleApplication { public static void main(String[] args) { SpringApplication.run(MemoizationExampleApplication.class, args); } }
2.5 Code output
When you run the application using the mvn spring-boot:run
command open the browser to access the endpoints on the 8080 port number. If you need to change the default port number feel free to add the server.port
attribute in the application.properties
file.
2.5.1 Fetching data without memoization
Every request to /data-no-cache
will trigger the fetchData
method, leading to a 2-second delay for each request.
[2024-07-26 10:00:02] Fetching data from service... [2024-07-26 10:00:06] Fetching data from service...
2.5.2 Fetching Data With Memoization
The first request to /data-cache
will trigger the fetchData
method, and subsequent requests will return the cached result without delay. The cache log is implicit in Mono
.
[2024-07-26 10:01:02] Fetching data from service... [2024-07-26 10:01:04] Using cached data...
2.5.3 Fetching Data With Cache Duration
The first request to /data-cache-duration
will trigger the fetchData
method. Subsequent requests within 10 seconds will return the cached result. After 10 seconds, the cache will expire, and a new fetch operation will be triggered. The cache log is implicit in Mono
.
[2024-07-26 10:02:02] Fetching data from service... [2024-07-26 10:02:04] Using cached data... [2024-07-26 10:02:16] Fetching data from service...
3. Conclusion
Using Mono.cache()
in Project Reactor is a powerful way to optimize your reactive applications by memoizing the results of expensive operations. This ensures that costly computations or I/O operations are performed only once, improving performance and efficiency.