
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?