1. Introduction

In this article, we’re going to look at JetCache. We’ll see what it is, what we can do with it, and how to use it.

JetCache is a cache abstraction library that we can use on top of a range of caching implementations within our application. This allows us to write our code in a way that’s agnostic to the exact caching implementation and allows us to change the implementation at any time without affecting anything else in our application.

2. Dependencies

Before we can use JetCache, we need to include the latest version in our build, which is 2.7.6 at the time of writing.

JetCache comes with several dependencies that we need, depending on our exact needs. The core dependency for the functionality is in com.alicp.jetcache:jetcache-core.

If we’re using Maven, we can include this in pom.xml:

<dependency>
    <groupId>com.alicp.jetcache</groupId>
    <artifactId>jetcache-core</artifactId>
    <version>2.7.6</version>
</dependency>

We may need to include any actual cache implementations we want to use in addition to this if the core library doesn’t already include them. Without any extra dependencies, we have a choice of two in-memory caches that we can use – LinkedHashMapCache, built on top of a standard java.util.LinkedHashMap, and CaffeineCache, built on top of the Caffeine cache library.

3. Manually Using Caches

Once we’ve got JetCache available, we’re able to immediately use it to cache data.

3.1. Creating Caches

To do this, we first need to create our caches. When we’re doing this manually, we need to know exactly what type of cache we want to use and make use of the appropriate builder classes. For example, if we want to use a LinkedHashMapCache, we can build one with the LinkedHashMapCacheBuilder:

Cache<Integer, String> cache = LinkedHashMapCacheBuilder.createLinkedHashMapCacheBuilder()
  .limit(100)
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .buildCache();

Different caching frameworks might have different configuration settings available. However, once we’ve built our cache, JetCache exposes it to use as a Cache<K, V> object, regardless of the underlying framework used. This means that we can change the cache framework, and the only thing that needs to change is the construction of the cache. For example, we can swap the above from a LinkedHashMapCache to a CaffeineCache by changing the builder:

Cache<Integer, String> cache = CaffeineCacheBuilder.createCaffeineCacheBuilder()
  .limit(100)
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .buildCache();

We can see that the type of the cache field is the same as before, and all interactions with it are the same as before.

3.2. Caching and Retrieving Values

Once we’ve got a cache instance, we can use it for storing and retrieving values.

At its simplest, we use the put() method to put values into the cache and the get() method to get values back out:

cache.put(1, "Hello");
assertEquals("Hello", cache.get(1));

Using the get() method will return null if the desired value isn’t available in the cache. This means either that the cache has never cached the provided key, or else the cache ejected the cache entry for some reason – because it expired, or because the cache cached too many other values instead.

If necessary, we can use the GET() method instead to get a CacheGetResult object. This is never null and represents the cache value – including the reason why there was no value there:

// This was expired.
assertEquals(CacheResultCode.EXPIRED, cache.GET(1).getResultCode());

// This was never present.
assertEquals(CacheResultCode.NOT_EXISTS, cache.GET(2).getResultCode());

The exact result code returned will depend on the underlying caching libraries used. For example, CaffeineCache doesn’t support indicating that the entry expired, so it will return NOT_EXISTS in both cases, whereas other cache libraries may give better responses.

If we need to, we can also use the remove() call to manually clear something from the cache:

cache.remove(1);

We can use this to help avoid the ejection of other entries by removing entries that are no longer needed.

3.3. Bulk Operations

As well as working with individual entries, we can also cache and fetch entries in bulk. This works exactly as we’d expect, only with appropriate collections instead of single values.

Caching entries in bulk requires us to build and pass in a Map<K, V> with the corresponding values. This is then passed to the putAll() method to cache the entries:

Map<Integer, String> putMap = new HashMap<>();
putMap.put(1, "One");
putMap.put(2, "Two");
putMap.put(3, "Three");
cache.putAll(putMap);

Depending on the underlying cache implementation, this might be exactly the same as calling for each entry individually, but it might be more efficient. For example, if we’re using a remote cache such as Redis, then this might reduce the number of network calls required.

Retrieving entries in bulk is done by calling the getAll() method with a Set containing the keys that we wish to retrieve. This then returns a Map<K, V> containing all of the entries that were in the cache. Anything that we requested that wasn’t in the cache – for example, because it was never cached or because it had expired – will be absent from the returned map:

Map<Integer, String> values = cache.getAll(keys);

Finally, we can remove entries in bulk using the removeAll() method. In the same way as getAll(), we provide this with a Set of the keys to be removed, and it will ensure that all of these have been dropped.

cache.removeAll(keys);

4. Spring Boot Integration

Creating and using caches manually is easy enough, but working with Spring Boot makes things much easier.

4.1. Setting Up

JetCache comes with a Spring Boot autoconfiguration library that sets everything up for us automatically. We only need to include this in our application, and Spring Boot will automatically detect and load it at startup. In order to use this, we need to add com.alicp.jetcache:jetcache-autoconfigure to our project.

If we’re using Maven, we can include this in pom.xml:

<dependency>
    <groupId>com.alicp.jetcache</groupId>
    <artifactId>jetcache-autoconfigure</artifactId>
    <version>2.7.6</version>
</dependency>

Additionally, JetCache comes with a number of Spring Boot Starters that we can include in our Spring Boot apps to help configure specific caches for us. However, these are only necessary if we want anything beyond the core functionality.

4.2. Using Caches Programmatically

Once we’ve added the dependencies, we can create and use caches in our application.

Using JetCache with Spring Boot automatically exposes a bean of type com.alicp.jetcache.CacheManager. This is similar in concept to the Spring *org.springframework.cache.*CacheManager but designed for JetCache usage. We can use this to create our caches instead of doing so manually. Doing this will help ensure that the caches are correctly wired into the Spring lifecycle and can help define some global properties to make our lives easier.

We can create a new cache with the getOrCreateCache() method, passing in some cache-specific configuration to use:

QuickConfig quickConfig = QuickConfig.newBuilder("testing")
  .cacheType(CacheType.LOCAL)
  .expire(Duration.ofSeconds(100))
  .build();
Cache<Integer, String> cache = cacheManager.getOrCreateCache(quickConfig);

We can do this wherever it makes the most sense – whether that’s in an @Bean definition, directly in a component, or wherever we need to. The cache is registered against its name, so we can get a reference to it directly from the cache manager without needing to create bean definitions if we desire, but alternatively, creating bean definitions allows them to be autowired in easily.

The Spring Boot setup has the concept of Local and Remote caches. Local caches are entirely in memory within the running application – for example, LinkedHashMapCache or CaffeineCache. Remote caches are separate infrastructures that the application depends on, such as Redis, for example.

When we create a cache, we can specify whether we want a local or remote cache. If we don’t specify either, JetCache will create both a local and remote cache, using the local cache in front of the remote cache to help with performance. This setup means that we get the benefit of a shared cache infrastructure but reduce the cost of network calls for data that we’ve seen recently in our application.

Once we’ve got a cache instance, we can use it exactly as we did before. We’re given the exact same class, and all the same functions are supported.

4.3. Cache Configuration

One thing to notice here is that we never specified the type of cache to create. When JetCache integrates with Spring Boot, we can specify some common configuration settings using the application.properties file, following the standard Spring Boot practice. This is entirely optional, and if we don’t do so, then there are sensible defaults for most things.

For example, a configuration that will use LinkedHashMapCache for local caches and Redis with Lettuce for remote caches might look like:

jetcache.local.default.type=linkedhashmap
jetcache.remote.default.type=redis.lettuce
jetcache.remote.default.uri=redis://127.0.0.1:6379/

5. Method-Level Caches

In addition to the standard use of the caches that we’ve already seen, JetCache has support within Spring Boot applications for wrapping an entire method and caching the result. We do this by annotating the method by which we want to cache the results.

In order to use this, we first need to enable it. We do this with the @EnableMethodCache annotation on an appropriate configuration class, including the base package name in which all of our classes we want to enable caching for are to be found:

@Configuration
@EnableMethodCache(basePackages = "com.baeldung.jetcache")
public class Application {}

5.1. Caching Method Results

At this point, JetCache will now automatically set up caching on any suitable annotated methods:

@Cached
public String doSomething(int i) {
    // .....
}

We need no annotation parameters at all if we’re happy with the defaults – an unnamed cache that has both local and remote caches, no explicit configuration for expiry, and uses all of the method parameters for the cache key. This is the direct equivalent of:

QuickConfig quickConfig = QuickConfig.newBuilder("c.b.j.a.AnnotationCacheUnitTest$TestService.doSomething(I)")
  .build();
cacheManager.getOrCreateCache(quickConfig);

Note that we do have a cache name, even if we didn’t specify one. By default, the cache name is the fully qualified method signature that we’re annotating. This helps ensure that caches never collide by accident since every method signature must be unique within the same JVM.

At this point, every call to this method will be cached against the cache keys, which default to the entire set of method parameters. If we subsequently call the method with the same parameters and we have a valid cache entry, then this will be immediately returned without calling the method at all.

We can then configure the cache with annotation parameters exactly as we would if we configured it programmatically:

@Cached(cacheType = CacheType.LOCAL, expire = 3600, timeUnit = TimeUnit.SECONDS, localLimit = 100)

In this case, we’re using a local-only cache in which elements will expire after 3,600 seconds and will store a maximum of 100 elements.

In addition, we can specify the method parameters to use for the cache key. We use SpEL expressions to describe exactly what the keys should be:

@Cached(name="userCache", key="#userId", expire = 3600)
User getUserById(long userId) {
    // .....
}

Note that, as always with SpEL, we need to use the -parameters flag when compiling our code in order to refer to parameters by name. If not, then we can instead use the args[] array to refer to parameters by position:

@Cached(name="userCache", key="args[0]", expire = 3600) User getUserById(long userId) { // ..... }

5.2. Updating Cache Entries

In addition to the @Cached annotation for caching a method’s results, we can also update the already-cached entries from other methods.

The simplest case of this is to invalidate a cached entry as a result of a method call. We can do this with the @CacheInvalidate annotation. This will need to be configured with the exact same cache name and cache keys as the method that did the caching in the first place and will then cause the appropriate entry to be removed from the cache if called:

@Cached(name="userCache", key="#userId", expire = 3600)
User getUserById(long userId) {
    // .....
}

@CacheInvalidate(name = "userCache", key = "#userId")
void deleteUserById(long userId) {
    // .....
}

We also have the ability to directly update a cache entry based on a method call by using the @CacheUpdate annotation. This is configured using the exact same cache name and keys as before but also with an expression defining the value to store in the cache:

@Cached(name="userCache", key="#userId", expire = 3600)
User getUserById(long userId) {
    // .....
}

@CacheUpdate(name = "userCache", key = "#user.userId", value = "#user")
void updateUser(User user) {
    // .....
}

Doing this will always call the annotated method but will populate the desired value into the cache afterward.

6. Conclusion

In this article, we’ve given a broad introduction to JetCache. This library can do much more, so why not try it out and see?

All of the examples are available over on GitHub.