Dzisiejszy post będzie z cyklu: "Wtf? Dlaczego to działa?". A dotyczy on pewnego wzorca, którego implementacja jak się okazuje jest we frameworku Spring Boot domyślnie włączona, czego nie wszyscy programiści mogą się spodziewać.
Geneza
Zaczęło się od tego, iż pisałem kolejny test integracyjny do kolejnego kontrolera. Test napisany, uruchamiam, zielono. No i przeglądam sobie jeszcze raz kod zanim wypchnę zmiany do review. I rzuciło mi się w oczy, iż zapomniałem dodać adnotacji @Transactional (do tej pory uważałem, iż to jest zawsze wymagane). I wtedy zadałem sobie przytoczone na wstępie pytanie. Dlaczego test przeszedł skoro mój serwis dociąga sobie obiekt oznaczony jako LAZY? Dużo się nagimnastykowałem by wpisać odpowiednie query do Google'a i znaleźć odpowiedź: Open Session in View. Ale o tym za chwilę.
Jak działa Hibernate
Żeby lepiej zrozumieć opisywane zagadnienie warto przypomnieć jakie kroki w uproszczeniu musi wykonać Hibernate podczas komunikacji z bazą danych.
- Aby móc w ogóle zarządzać encjami, musi zostać utworzona sesja. W sesji tej przechowywane są obiekty (encje) zarządzane przez Hibernate. Utworzenie takiej sesji nie oznacza wcale, iż zostało nawiązane jakiekolwiek połączenie z bazą danych.
- Przed wykonaniem jakiegokolwiek zapytania musi zostać nawiązane połączenie. W tym momencie sesja pożycza sobie jedno z połączeń bazodanowych z puli kontenera (Tomcat) na wyłączność do czasu jego zwolnienia.
- Przed wykonaniem zapytania konieczne jest również rozpoczęcie transakcji.
- Po wykonaniu zapytania można zakończyć transakcję poprzez jej zatwierdzenie (commit) lub wycofanie (rollback) lub wykonywać kolejne zapytania.
- Na końcu można już zamknąć sesję i dopiero w tym momencie zwalniane jest połączenie i zwracane do puli kontenera.
Open Session in View
Open Session in View (OSIV) jest wzorcem (lub raczej anty-wzorcem) pozwalającym powiązać sesję ORM w całości z cyklem życia pojedynczego żądania realizowanego z zasady w ramach jednego, odrębnego wątku aplikacji. OSIV jest w zasadzie implementacją wzorca opisywanego w kontekście ORM-ów jako Session per request. Koncepcja jest prosta: przed rozpoczęciem obsługi żądania otwierana jest sesja ORM, a zamykana dopiero po zakończeniu całego żądania. Dzięki temu sesja może być utrzymywana we wszystkich fazach przetwarzania żądania, m. in. w fazie renderowania widoku. I tu już można wyczuć pewien smród... Bo dlaczego chcielibyśmy sobie powiązać wszystkie warstwy jakie mamy w aplikacji skoro zwykle potrzeba jest odwrotna - separacja poszczególnych odpowiedzialności w celu umożliwienia testowania odseparowanych części systemu i zmniejszenia couplingu. Otóż zastosowanie OSIV pozwala na wykonywanie zapytań do bazy danych bezpośrednio z fazy renderowania widoku.
Jakie zastosowanie ma OSIV tak naprawdę? Jest przedstawiany jako sposób na zwiększenie produktywności programisty. Konkretnie jako rozwiązanie problemu pojawiających się problemów z leniwym dociąganiem danych do encji skutkujących często wyjątkami klasy LazyInitializationException. Wyjątki te już nie pojawiają się ponieważ Hibernate (jako implementacja ORM wykorzystywana przez Spring Boot) może w każdym momencie dociągnąć dane do encji, które przez cały czas są dostępne w tej samej sesji w ciągu przetwarzania tego samego żądania. Moim zdaniem jest to tylko wymówka na nieumiejętne zarządzanie transakcjami oraz nie rozdzielanie warstwy prezentacji od warstwy persystencji dzięki chociażby obiektów DTO. Jedyne sensowne zastosowanie widzę w prototypowaniu, kiedy chcemy w bardzo szybkim czasie, przy jak najmniejszym nakładzie sił uruchomić aplikację, która po prostu będzie spełniać wymagania dotyczące funkcjonalności.
Żeby nie było zbyt kolorowo
OSIV jest uznawany przez wielu jak anty-wzorzec. I są ku temu powody. Poniżej opiszę główne problemy powodowane przez ten mechanizm pomijając już ten, który odnosi się do strukturyzowaniu kodu, o którym wspomniałem wcześniej.
Pula połączeń
Umieszczenie kodu wykonującego zapytania do bazy danych wraz z kodem wykonującym czasochłonne połączenia do zewnętrznych serwisów lub inne czasochłonne operacje może doprowadzić do wyczerpania puli połączeń bazodanowych i w konsekwencji doprowadzenie do nieresponsywności aplikacji. Ponadto jest to sytuacja trudna do zdiagnozowania na środowisku produkcyjnym, jako iż operacje te są ze sobą teoretycznie niepowiązane (żądanie do zewnętrznej usługi nie powinno wymagać połączenia do bazy danych).
Jako przykład zasymuluję sytuację długiego czasu oczekiwania i zaprezentuję wyniki z aplikacją działającą w obu trybach. W mojej aplikacji utworzyłem prosty kontroler, który pozwala na wyszukanie listy produktów. Odwołuje się on do serwisu, który pobiera dane z bazy danych i przekształca w DTO. Następnie dzięki sleepa symuluję czasochłonną operację.
@GetMapping("products/search/{query}")public Page searchProductsByName(@PathVariable String query, Pageable pageable) {
Page allByNameContains = productService.findAllByNameContains(query, pageable);
if ("toy".equals(query)) {
try {
Thread.sleep(40_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return allByNameContains;
}
Domyślnie, pula połączeń do bazy danych wynosi 10. Natomiast timeout oczekiwania na połączenie wynosi 30s. Skrypt testujący: #!/bin/bash
for i in {1..20}
do
curl -s -I http://localhost:8080/products/search/toy &
done
wait
Wyniki prezentują się następująco. spring.jpa.open-in-view: true: HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 22 Feb 2020 12:20:27 GMT
Connection: close
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:20:37 GMT
I po zmianie flagi. spring.jpa.open-in-view: false: HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 6057
Date: Sat, 22 Feb 2020 12:11:58 GMT
Jak łatwo zauważyć, gdy OSIV było włączone, drugie 10 żądań zakończyło się niepowodzeniem. Nie one mogły skorzystać z połączenia do bazy danych, ponieważ wszystkie zostały przetrzymane przez poprzednie żądania. Natomiast gdy OSIV zostało wyłączone, wszystkie żądania zakończyły się powodzeniem.
Auto-commit mode
Nawigacja po polach obiektu oznaczonych jako LAZY powoduje późne dociąganie wierszy w trybie auto-commit co oznacza, iż każde dodatkowe zapytanie jest wykonywane w osobnej transakcji. Jest to dodatkowe obciążenie dla silnika bazy danych, który dla wszystkich takiego zapytania musi aktualizować log transakcji. Można sobie wyobrazić jak wiele zbędnych transakcji zostałoby wygenerowanych gdybyśmy dodatkowo nie rozwiązali w swojej aplikacji problemu n+1 zapytań. Optymalniejszym rozwiązaniem byłoby dociągnięcie wszystkich danych w ramach jednej transakcji.
Spring Boot
Jak już wspomniałem wcześniej, mechanizm OSIV jest włączony w Spring Boot (1.x oraz 2.x) domyślnie. Jak zatem go wyłączyć ? Wystarczy ustawienie odpowiedniej flagi w konfiguracji:
spring.jpa.open-in-view=falsePonadto, Spring Boot 2.x podczas uruchamiania ostrzega nas o domyślnej konfiguracji stosownym komunikatem: 2020-02-22 10:47:53.130 WARN 10356 --- [ main] aWebConfiguration$JpaWebMvcConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warningAle kto by zwracał uwagę na WARN-y :).