Spring Cache

Spring Cache

Zapewne nieraz chciałeś przyśpieszyć działanie swojej aplikacji jak najmniejszym kosztem, niejednokrotnie pewnie też to zrobiłeś korzystając z dumnie brzmiącego mechanizmu cachowania. Konstrukcja takiego cache mogła wyglądać np. tak:

private final Map<Long, User> userMap = new ConcurrentHashMap<>();

public User loadUser(Long id) {
    Assert.notNull(id, "Parameter 'id' can not be empty.");

    if (!this.userMap.containsKey(id)) {
        this.userMap.put(id, this.findUserById(id));
    }

    return this.userMap.get(id);
}

Oczywiście tego typu konstrukcje nie są czymś najgorszym i sam je nierzadko spotykam, zarówno ze zwykłą hashowaną mapą w roli głównej. 

Z drugiej strony czemu by nie poszukać czegoś “fajniejszego”, bo po co pisać tyle kodu – szczególnie gdy klas i metod, które chcemy objąć cachowaniem jest dość sporo. Dodatkowo samodzielne dbanie o tego typu logikę, testy i bezpieczeństwo rozwiązania jest po prostu kosztowne.

A gdyby powyższe zamienić na coś takiego?

@Cacheable("user-details")
public User loadUser(Long id) {
    Assert.notNull(id, "Parameter 'id' can not be empty.");
    return this.findUserById(id))
}

Prawda, że wygląda lepiej? Zacznijmy raz jeszcze.

Zależności

W naszym przypadku zależności dostarcza spring-boot-starter

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter</artifactId>
  <version>2.2.6.RELEASE</version>
</dependency>

By być bardziej precyzyjnym wypada zaznaczyć, że to spring-context dostarcza całego mechanizmy cachowania.

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context</artifactId>
  <version>5.3.4</version>
</dependency>

Wszystko to i kod obrazujący dalsze przykłady wrzucam na repozytorium, skąd można pobrać projekt i podejrzeć zależności. Dla lepszego zobrazowania problemu, pozwoliłem sobie zbudować prosty kontroler z kilkoma metodami restowymi. Oczywiście z pomocą niezawodnego Spring Boot.

Przykłady

Spójrzmy na nasz prosty kontroler, który to poprzez wywołania serwisu zarządza obiektem typu użytkownik.

@PutMapping("new")
public @ResponseBody User save(@RequestBody User user){
   return this.userService.save(user);
}

@GetMapping("list")
public @ResponseBody Collection<User> list(){
   return this.userService.list();
}

@GetMapping("details")
public @ResponseBody User findById(@RequestParam("id") Long id){
   return this.userService.getUser(id);
}

@DeleteMapping("delete")
public @ResponseBody void delete(@RequestParam("id") Long id){
   this.userService.deleteUser(id);
}

Do uruchomienia kontenera, niezbędna będzie poniższa klasa

@SpringBootApplication
@EnableJpaRepositories
@EnableCaching
public class SpringCacheApplication {
  public static void main(String[] args) {
     SpringApplication.run(SpringCacheApplication.class, args);
  }
}

Przedstawiona klasa, oprócz standardowej konfiguracji dla spring-boot oraz jpa posiada jeszcze adnotację odpowiedzialną za uruchomienie mechanizmu cachowania (@EnableCaching). Dokładniej, uruchamiamy tutaj mechanizm zarządzający procesami cachowania. O konfiguracji samych cachy porozmawiamy dalej.

Aplikację uruchamiamy z poziomu klasy SpringCacheApplication lub poprzez przygotowany przez mavena plik jar.

Domyślnie aplikacja łączy się do podniesionej w pamięci bazy danych i inicjuje ją odpowiednimi danymi. Usługi REST dostępne są lokalnie pod kontekstem http://127.0.0.1:8080/api/user/. Dla sprawdzenia wywołajmy usługę http://127.0.0.1:8080/api/user/list, która to zwróci listę, wcześniej dodanych użytkowników.

[
    {
        "id": 1,
        "firstName": "Jan",
        "lastName": "Kowalski",
        "email": "jan.kowalski@test.pl"
    },
    {
        "id": 2,
        "firstName": "Marian",
        "lastName": "Kowalski",
        "email": "marian.kowalski@test.pl"
    },
    {
        "id": 3,
        "firstName": "Wacław",
        "lastName": "Kowalski",
        "email": "waclaw.kowalski@test.pl"
    }
]

Na początku lista będzie pusta, oprócz tego aplikacja w logach zaznaczy wywołania: Find all users list method.

Konfiguracja

W tej chwili możemy uruchomić cache dla wybranych metod/usług. Zobaczcie sami, jedna adnotacja @Cacheable(“user”) spowoduje, że oznaczona metoda będzie cachowana. Wynik zapytania będzie przechowywany pod indeksem “user” z domyślnie zbudowanym kluczem na podstawie parametrów metody. W naszym przypadku, usługa pobierająca szczegóły użytkownika została oznaczona jako cacheable, widać również że pierwsza linia metody loguje prosty komunikat.

@Cacheable(value = "user")
public User getUser(Long id){
   log.info("Find user method.");
   Assert.notNull(id, "Parameter 'id' can not be empty.");
   Optional<User> opt = repository.findById(id);
   return opt.orElse(null);
}

Nieco wcześniej, poprzez wywołanie innej usługi rest dodałem do bazy użytkownika, którego szczegóły spróbujemy wczytać. 

{
    "id": 1,
    "firstName": "Wacław",
    "lastName": "Kowalski",
    "email": "waclaw.kowalski@test.pl"
}

W logu aplikacja komunikuje wywołanie metody: Find user method.

Komunikat, winien być zalogowany jeden raz – to dowód na to, że przy każdej następnej próbie załadowania danych z tym samym kluczem aplikacja zwraca dane z cache.

W zasadzie to wystarczy aby rozpocząć pracę i cieszyć się najprostszą wersją cacha, działająca w ściśle określonych warunkach. Pytanie o jaki warunkach my rozmawiamy?

@CachePut & @CacheEvict

W sytuacji kiedy zaczynamy korzystać z cachy musimy mieć pełną świadomość tego jak ekosystem naszego systemu funkcjonuje, tak żeby nasz cache był pełnowartościowy. W naszym przypadku cache będzie działał prawidłowo do czasu, gdy ktoś nie wywoła metody modyfikującej szczegóły użytkownika, lub go po prostu nie usunie. Łatwo się domyśleć, że w takich sytuacjach należałoby poinformować cache o zachodzących zmianach. Tutaj z pomocą przychodzą nam adnotacje @CachePut i @CacheEvict odpowiedzialne za aktualizację cache w przypadku, operacji modyfikacji i usunięcia obiektów.

Usunięcie użytkownika

@CacheEvict(value = "user", key = "#user.id")
public void deleteUser(Long id) {
   log.info("Delete user method.");
   Assert.notNull(id, "Parameter 'id' can not be empty.");
   repository.deleteById(id);
}

Modyfikacja użytkownika

@CachePut(value = "user", key = "#user.id")
public User modify(User user) {
   ...
   return dbUser;
}

W powyższej konfiguracji spotykamy się dodatkowo ze wskazaniem na parametr key, który wskazuje cachowi klucz w celu znalezienia istniejącej wartości.

W ten sposób unikniesz przechowywania w cachu niepotrzebnych danych i co najważniejsze, nieaktualnych danych. Tutaj jeszcze raz przypomnę jak ważna jest znajomość architektury całego system, bez tego można narobić sobie wiele problemów, serwując nieaktualne dane.

@Caching

Z racji tego, że Java nie pozwala na wielokrotną deklarację tej samej adnotacji w obrębie jednej metody, potrzebować będziemy prostego kolektora. Pytanie po co? Zdarza się tak, że zachodzi potrzeba deklaracji więcej niż jednego cacha w obrębie jeden usługi. W takiej sytuacji z pomocą przychodzi adnotacja @Caching. Poniżej przykładowa deklaracja

@Caching(evict = {@CacheEvict(cacheNames = "user-list"), @CacheEvict(cacheNames = "user-details")})
public void removeUser(Long id) {
    ...
}

Powyższa konfiguracja pozwoli w ramach operacji usuwania użytkownika wyczyścić dwa cache, tak aby zachować spójność wszystkich usług.

Podsumowanie

Tym artykułem przedstawiłem Ci zagadnienie cachowania danych korzystając z frameworka Spring. Podziel się w komentarzu czy kiedykolwiek stosowałeś cache w swoich aplikacjach – jakiej technologii do tego używałeś, a może stworzyłeś własne rozwiązanie? Czy natrafiłeś na jakieś problemy z tym związane?

Radosław Klimek

Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x