Wzorzec Open Session in View w Spring Boot

blog.maczkowski.dev 4 lat temu

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.

  1. 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.
  2. 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.
  3. Przed wykonaniem zapytania konieczne jest również rozpoczęcie transakcji.
  4. Po wykonaniu zapytania można zakończyć transakcję poprzez jej zatwierdzenie (commit) lub wycofanie (rollback) lub wykonywać kolejne zapytania.
  5. 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 warning
Ale kto by zwracał uwagę na WARN-y :).

Podsumowując

Czy należy stosować Open Session in View w aplikacjach Spring Boot? Odpowiedź wydaje się bardzo oczywista i brzmi... to zależy :). W przypadku gdy aplikacja jest stosunkowo prosta, wykonuje mało zapytań i nie przewiduje obsługi dużego ruchu lub ma być szybkim prototypem - być może warto zostać przy domyślnej konfiguracji i nie zawracać sobie zbytnio głowy jakością oraz wydajnością poszczególnych zapytań. Z kolei w sytuacji gdy przewidujemy, iż aplikacja będzie obsługiwać ruch, a w związku z tym będzie konieczna równoczesna obsługa wielu żądań (a co za tym idzie wykorzystywania wielu połączeń do bazy danych jednocześnie) to konieczne może okazać się posiadanie większej kontroli nad komunikacją z bazą danych tak aby w gąszczu żądań, wywołań i zapytań nie wylądować w sytuacji gdy nasza aplikacja sama się zablokuje. Najważniejsza w tym wszystkim wydaje się jednak świadomość z jakiego trybu zarządzania sesjami korzystamy i wiedza jak zachowuje się aplikacja w każdym z nich oraz do jakich problemów może to doprowadzić.

Wartościowe źródła:

https://vladmihalcea.com/the-open-session-in-view-anti-pattern/ https://www.baeldung.com/spring-open-session-in-viewhttps://docs.jboss.org/hibernate/core/4.1/devguide/en-US/html/ch02.html
Idź do oryginalnego materiału