Spring Cache

Share on facebook
Share on twitter
Share on linkedin
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

Radosław Klimek

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