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ń

Prześlij komentarz

Popularne posty z tego bloga

Sneaky throws, czyli checked i unchecked exception w jednym!

Często podczas pisania kodu Java korzystam z wyrażeń lambda wprowadzonych wraz z Java 8. Często prowadzi to też do pewnych nowych problemów i zmusza do szukania rozwiązań. Przyjrzyjmy się jednemu z nich. Checked exception w wyrażeniu lambda Często zdarza się, że metoda wywoływana wewnątrz lambdy rzuca wyjątek. Nie ma sprawy gdy wyjątek jest obiektem klasy będącej podklasą RuntimeException . Wtedy po prostu się nim nie przejmujemy. Wyjątek jest przekazywany w górę stosu wywołań. Problem zaczyna się gdy kompilator zmusza nas do obsługi wyjątku. Jak zwykle mamy 2 wyjścia: obsłużyć wyjątek za pomocą bloku try-catch , bądź zadeklarować przekazanie wyjątku dalej za pomocą słowa kluczowego throws . O ile wiemy co zrobić po złapaniu wyjątku to wszystko gra. Co natomiast gdy chcemy wybrać drugą opcję? Większość interfejsów stosowanych jako typy parametrów, często przekazywanych jako lambdy jak np: Function , Consumer czy Supplier nie deklarują, że mogą rzucić wyjątek. W takim wypadku druga

Pułapki logowania w Javie

Czy spotkałeś kiedyś się z kodem takim jak poniższy? if (logger.isDebugEnabled()) { logger.debug("Request: " + requestObject); } Ja tak. Jakiś czas temu napotkałem na mnóstwo takich fragmentów w kodzie, który analizowałem. Zastanowiło mnie po co została wykorzystana tutaj instrukcja warunkowa - a jako, że if-y uwazam za zło konieczne postanowiłem to zbadać. Czy nie do tego właśnie służą biblioteki do logowania i udostępniane przez nie metody jak debug żeby właśnie sterowane konfiguracją decydowały co zalogować a co nie? Co więcej, okazało się, że instrukcję warunkową wprowadził ktoś w ramach większej akcji. I jak się okazuje, prawdopodobnie znacznie zwiększył dzieki temu wydajność aplikacji. W dzisiejszym wpisie postaram się wyjaśnić jak korzystać z loggera aby nie zaszkodziło to wydajności. Po co if-y? Na początek wyjaśnić trzeba czemu ten if tak na prawdę służy. Korzystamy z biblioteki do logowania i spodziewamy się, że wykorzystujac wybraną metodę, zostanie