In this article you will see an example of a Spring Cache application backed by an Aerospike database.
Source code can be found here:aerospike-examples/simple-springboot-aerospike-cache - Simple example of using Spring Boot cache backed by Aerospike database
Introduction
Spring Cache with Aerospike database allows you to use annotations such as @Cacheable, @CachePut and @CacheEvict that provides a fully managed cache store using an Aerospike database to store the cached data.
In this example we are going to use these annotations on a UserRepository class methods to create/read/update and delete user’s data from the cache.
We will see that if a user is stored in the cache — calling a method with @Cacheable annotation will fetch the user from the cache instead of executing the method’s body that responsible for the actual user fetch from the database, if the user doesn’t exist in the cache it will fetch the user’s data from the database and put it in the cache for later usages (a “cache miss”).
With Spring Cache and Aerospike database we can achieve that with only few lines of code.
Motivation
Let’s say that we are using another database as our main data store, for example, Microsoft SQL Server. we don’t want to fetch the results from SQL Server every time we request the data, instead we want to get the data from a cache layer.
There are number of benefits for using a cache layer, here are some of them:
Performance— Aerospike can work purely in RAM but reading a record from Aerospike in Hybrid Memory (primary index in memory, data stored on Flash drives) is extremely fast as well (~1ms).
Reduce database load— Moving a significant part of the read load from the main database to Aerospike can help balance the resources on heavy loads.
Scalable — Aerospike scales horizontally by adding more nodes to the cluster, scaling a relational database might be tricky and expensive, so if you are facing a read heavy load you can easily scale up the cache layer.
Project
Setup
We will use docker for our Aerospike database and Spring Boot Initializr to setup our project, if you don’t already have an environment ready — check out steps 1 and 2.1 of the following article on how to setup what you need:
Dependencies
We need to add spring-data-aerospike dependency.
Add the following dependency to the pom.xml file:
<dependency>
<groupId>com.aerospike</groupId>
<artifactId>spring-data-aerospike</artifactId>
<version>3.0.0</version>
</dependency>
This article is relevant for spring-data-aerospike version 3.0.0/2.5.0 and above.
Make sure to load maven changes after adding the dependency
Code
We will not use an actual database as our main data store (SQL Server) for this demo, instead we will simulate a database access by printing a simulation message and replace a database read by just returning a specific User.
Configuration
AerospikeConfigurationProperties
package com.aerospike.cache.simplespringbootaerospikecache.configuration;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aerospike")
public class AerospikeConfigurationProperties {
private String host;
private int port;
}
AerospikeConfiguration
package com.aerospike.cache.simplespringbootaerospikecache.configuration;
import com.aerospike.client.AerospikeClient;
import com.aerospike.client.policy.ClientPolicy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.aerospike.cache.AerospikeCacheConfiguration;
import org.springframework.data.aerospike.cache.AerospikeCacheManager;
import org.springframework.data.aerospike.convert.AerospikeCustomConversions;
import org.springframework.data.aerospike.convert.AerospikeTypeAliasAccessor;
import org.springframework.data.aerospike.convert.MappingAerospikeConverter;
import org.springframework.data.aerospike.mapping.AerospikeMappingContext;
import org.springframework.data.mapping.model.SimpleTypeHolder;
@Configuration
@EnableConfigurationProperties(AerospikeConfigurationProperties.class)
@Import(value = {MappingAerospikeConverter.class, AerospikeMappingContext.class, AerospikeTypeAliasAccessor.class
,AerospikeCustomConversions.class, SimpleTypeHolder.class})
public class AerospikeConfiguration {
@Autowired
private MappingAerospikeConverter mappingAerospikeConverter;
@Autowired
private AerospikeConfigurationProperties aerospikeConfigurationProperties;
@Bean(destroyMethod = "close")
public AerospikeClient aerospikeClient() {
ClientPolicy clientPolicy = new ClientPolicy();
clientPolicy.failIfNotConnected = true;
return new AerospikeClient(clientPolicy, aerospikeConfigurationProperties.getHost(), aerospikeConfigurationProperties.getPort());
}
@Bean
public AerospikeCacheManager cacheManager(AerospikeClient aerospikeClient) {
AerospikeCacheConfiguration defaultConfiguration = new AerospikeCacheConfiguration("test");
return new AerospikeCacheManager(aerospikeClient, mappingAerospikeConverter, defaultConfiguration);
}
}
In the AerospikeConfiguration we will create two types of Beans:
AerospikeClient
Responsible for accessing an Aerospike database and perform database operations.
AerospikeCacheManager
The heart of the cache layer, to define an AerospikeCacheManager you need:
aerospikeClient (AerospikeClient)
aerospikeConverter (MappingAerospikeConverter)
defaultCacheConfiguration (AerospikeCacheConfiguration), a default cache configuration that applies when creating new caches. Cache configuration contains a namespace, a set (null by default meaning write directly to the namespace w/o specifying a set) and an expirationInSeconds (AKA TTL, default is 0 meaning use Aerospike server’s default).
Optional: initalPerCacheConfiguration (Map<String, AerospikeCacheConfiguration>), You can also specify a map of cache names and matching configuration, it will create the caches with the given matching configuration at the application startup.
Note: A cache name is only a link to a cache configuration.
Objects
User
package com.aerospike.cache.simplespringbootaerospikecache.objects;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.data.aerospike.mapping.Document;
import org.springframework.data.annotation.Id;
@Data
@Document
@AllArgsConstructor
public class User {
@Id
private int id;
private String name;
private String email;
private int age;
}
Repositories
UserRepository
package com.aerospike.cache.simplespringbootaerospikecache.repositories;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class UserRepository {
@Cacheable(value = "test", key = "#id")
public Optional<User> getUserById(int id) {
System.out.println("Simulating a read from the main data store.");
// In case the id doesn't exist in the cache it will "fetch" jimmy page with the requested id and add it to the cache (cache miss).
return Optional.of(new User(id, "jimmy page", "jimmy@gmail.com", 77));
}
@CachePut(value = "test", key = "#user.id")
public User addUser(User user) {
System.out.println("Simulating addition of " + user + " to the main data store.");
return user;
}
@CacheEvict(value = "test", key = "#id")
public void removeUserById(int id) {
System.out.println("Simulating removal of " + id + " from the main data store.");
}
}
The cache annotations requires a “value” field, this is the cache name, if the cache name doesn’t exist — by passing initialPerCacheConfiguration param when creating a Bean of AerospikeCacheManager in a configuration class, it will configure the cache with the properties of the given defaultCacheConfiguration (3.3.1 Configuration > AerospikeCacheManager).
Services
UserService
package com.aerospike.cache.simplespringbootaerospikecache.services;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import com.aerospike.cache.simplespringbootaerospikecache.repositories.UserRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@AllArgsConstructor
public class UserService {
UserRepository userRepository;
public Optional<User> readUserById(int id) {
return userRepository.getUserById(id);
}
public User addUser(User user) {
return userRepository.addUser(user);
}
public void removeUserById(int id) {
userRepository.removeUserById(id);
}
}
Controllers
UserController
package com.aerospike.cache.simplespringbootaerospikecache.controllers;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import com.aerospike.cache.simplespringbootaerospikecache.services.UserService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@AllArgsConstructor
public class UserController {
UserService userService;
@GetMapping("/users/{id}")
public Optional<User> readUserById(@PathVariable("id") Integer id) {
return userService.readUserById(id);
}
@PostMapping("/users")
public User addUser(@RequestBody User user) {
return userService.addUser(user);
}
@DeleteMapping("/users/{id}")
public void deleteUserById(@PathVariable("id") Integer id) {
userService.removeUserById(id);
}
}
Add @EnableCaching
SimpleSpringbootAerospikeCacheApplication
Add @EnableCaching to the class that contains the main method.
package com.aerospike.cache.simplespringbootaerospikecache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class SimpleSpringbootAerospikeCacheApplication {
public static void main(String[] args) {
SpringApplication.run(SimpleSpringbootAerospikeCacheApplication.class, args);
}
}
Test
We will use Postman to simulate client requests:
Add User (@CachePut)
a. Create a new POST request with the following url: http://localhost:8080/users
b. Add a new key-value header in the Headers section:
Key: Content-Type
Value: application/json
c. Add a Body in a valid JSON format:
{
"id":1,
"name":"guthrie",
"email":"guthriegovan@gmail.com",
"age":35
}
d. Press Send.
And we can now see that this user was added to the cache.
Read User (@Cacheable)
a. Create a new GET request with the following url: http://localhost:8080/users/1
b. Add a new key-value header in the Headers section:
Key: Content-Type
Value: application/json
c. Press Send.
Remove User (@CacheEvict)
a. Create a new DELETE request with the following url: http://localhost:8080/users/1
b. Add a new key-value header in the Headers section:
Key: Content-Type
Value: application/json
c. Press Send.
And we can now see that this user was deleted from the cache (thanks to the @CacheEvict annotation in the UserRepository).
Cache miss — Read User that is not in the cache (@Cacheable)
We can use the GET request that we configured before with an id that we know for sure that is not in the cache (yet).
Lets try calling the get request with the id 5:
We got the following user’s data for the request id 5:
{
"id": 5,
"name": "jimmy page",
"email": "jimmy@gmail.com",
"age": 77
}
If you remember correctly we wrote it hard-coded in the UserRepository to simulate an actual database fetch of a user id that doesn’t exist in the cache.
We can now also see that the user was added to the cache.
Conclusion
This was a demonstration of how simple it is to create a basic cache layer with Aerospike database using Spring Boot caching.
You can do much more with Spring Boot and Aerospike database, check out this medium article on how to start a simple web application using Java, Spring Boot, Aerospike and Docker.