Przejdź do głównej zawartości

Wzorzec Open Session in View w Spring Boot

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, że 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, że zapomniałem dodać adnotacji @Transactional (do tej pory uważałem, że 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, że 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 oprogramowania 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 za pomocą 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 że 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 za pomocą 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, że każde dodatkowe zapytanie jest wykonywane w osobnej transakcji. Jest to dodatkowe obciążenie dla silnika bazy danych, który dla każdego 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=false
Ponadto, 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, że 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-view https://docs.jboss.org/hibernate/core/4.1/devguide/en-US/html/ch02.html

Komentarze

  1. Bardzo klarownie wyjaśniony problem.
    Moja odpowiedź na pytanie kiedy stosować OSIV: tylko dla aplikacji poziom "Hello world".
    Drobna sugestia: zamiast skryptu w bashu użyłbym programu ab - idealne narzędzie do tego typu zadań

    OdpowiedzUsuń
    Odpowiedzi
    1. Dzięki za komentarz i cenną sugestię, być może przyda się kolejnym razem. Pozdrawiam :)

      Usuń

Publikowanie komentarza

Popularne posty z tego bloga

Spring Data - save vs saveAndFlush

Cześć, dzisiaj będzie znowu trochę o warstwie persystencji. Czasami kodzie aplikacji korzystającej ze Spring Data można napotkać użycia metody repozytorium save , a czasami saveAndFlush , a z kolei innym razem brak jakiejkolwiek z nich podczas zapisu obiektu. Wszystkie trzy metody mają swoje zastosowanie choć nieco się różnią. Teoria W teorii, różnica jest prosta. Metody save oraz saveAndFlush dodają obiekt do kontekstu persystencji danej sesji i zwracają obiekt zarządzany. Ponadto druga z nich wymusza wymusza wykonanie nagranych przez ORM akcji na bazie danych przez co dane zostają przesłane do silnika bazy. Może się to okazać przydatne w przypadku gdy w ramach jednej transakcji chcemy jeszcze wykonać kolejne zapytania, które mają być świadome wprowadzonych wcześniej zmian. Natychmiastowa synchronizacja może się również przydać gdy wykorzystujemy poziom izolacji READ_UNCOMMITTED . Czasami jednak w kodzie nie ma żadnej z nich. Wtedy możliwe jest wykonywanie tylko zapytań UPDATE

One-to-one i lazy loading

Hibernate jest frameworkiem, który nie przestaje zaskakiwać. Kryje się w nim mnóstwo tajemnic, pułapek i zagadek. Jedną taką pułapkę postaram się opisać w tym wpisie, a dotyczy ona nieoczywistego na pierwszy rzut oka zachowania adnotacji @OneToOne z parametrem fetchMode=LAZY . One-to-one Adnotacja @OneToOne służy w JPA do oznaczania pól encji, które odnoszą się do obiektów będących z nią w relacji jeden-do-jeden. W znormalizowanym schemacie relacyjnej bazy danych oznacza to sytuację, w której mamy do czynienia z dwoma tabelami A oraz B , a jedna z nich posiada kolumnę, której wartości wskazują na klucz identyfikujący krotkę w drugiej z nich. Np: Lazy fetch Adnotacja @OneToOne zawiera również atrybut fetch , który może przyjąć wartości EAGER lub LAZY (domyślnie EAGER ). O ile ustawienie wartości EAGER oznacza, że Hibernate musi pobrać dodatkowy wiersz natychmiast (za pomocą klauzuli JOIN bądź dodatkowego zapytania) to zastosowanie LAZY może ale nie musi spowodowa