Blog

  • Spring Boot vs Quarkus – czy stare wygrywa z szybkim?

    „Czemu jeszcze nie przepisaliśmy tego na Quarkusa?”

    To pytanie padło na spotkaniu technologicznym, które miało być spokojnym podsumowaniem roadmapy. Zamiast tego rozpoczęło jedną z bardziej emocjonalnych dyskusji od czasu, gdy ktoś zaproponował wprowadzenie GraphQL.

    Zacznijmy od początku.

    Spring Boot – działa od 2014

    Jeśli napisałeś w Javie więcej niż Hello World i nie używałeś nigdy Springa, to nie wiem, gdzie się uchowałeś. Ale nawet wtedy debugowałeś pewnie chociaż stack trace wygenerowany przez Spring Boota.

    Ten framework nie próbuje być sexy. Nie próbuje być lean. Chcesz jedną małą rzecz, on wciąga do Twojej aplikacji setki kilobajtów kodu. Ale działa. I to w 99% przypadków wystarczy. Miliony blogpostów, tysiące przykładów na StackOverflow, setki rozszerzeń – to wszystko sprawia, że Spring jest jak stary wygodny fotel: trochę wytarty, trochę trzeszczy, ale wiesz, gdzie co jest.

    Quarkus – działa w 0.04 sekundy

    A potem pojawia się on – Quarkus. Młody, szybki, zgrabny. Wchodzi, robi swoje, znika. Czas startu w trybie natywnym? 0.04s. Spring może o tym tylko pomarzyć. RAM? Żre mniej niż przeglądarka z otwartym StackOverflow. Integracja z GraalVM? Wbudowana.

    Brzmi jak marzenie. Prawda?

    Ok, ale jak to wygląda w kodzie?

    Weźmy przykład z życia wzięty: REST-owy endpoint zwracający zamówienia z bazy. Brzmi jak CRUD? Bo to CRUD.

    Spring Boot

    @RestController
    @RequestMapping("/orders")
    public class OrderController {
      private final OrderRepository repository;
    
      public OrderController(OrderRepository repository) {
        this.repository = repository;
      }
    
      @GetMapping
      public List<Order> getAll() {
        return repository.findAll();
      }
    }
    public interface OrderRepository extends JpaRepository {}

    Odpalasz, działa. H2 w pamięci, logi śmigają, jak coś nie działa, to marudzi już przy starcie, więc nie przeoczysz tego.

    Czas uruchomienia? ~1.5s.

    Quarkus

    @Path("/orders")
    public class OrderResource {
    
        @Inject
        EntityManager em;
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        public List<Order> getAll() {
            return em.createQuery("from Order", Order.class).getResultList();
        }
    }
    

    Zero Spring Data. Zero magii. Wszystko jawnie. ORM działa, ale jesteś bliżej metalu. Trochę czujesz, że trochę wróciłeś do czasów Hibernate 3 i pisania SQL-a na kartce.

    Czas uruchomienia? ~0.9s w JVM, ~0.04s w natywie. No robi wrażenie.

    A co z testami?

    W Springu wrzucasz @SpringBootTest i modlisz się, żeby test wstał przed kawą. Ale masz wszystko: kontekst, wstrzykiwanie, nawet MockMvc.

    W Quarkusie masz @QuarkusTest. W teorii działa jak SpringBootTest. W praktyce… działa, ale czasem trzeba przeklikać dokumentację. I nie wszystko działa tak samo.

    Środowiska enterprise

    Jeśli pracujesz w dużej organizacji, gdzie proces release’u ma więcej kroków niż Twój test end-to-end, a każda nowa technologia wymaga pięciu podpisów, to Spring Boot jest bezpiecznym wyborem. Większość aplikacji w korporacjach i tak już działa na Springu. Migracja do Quarkusa w takim miejscu najczęściej kończy się na Proof of Concept – jeśli w ogóle ktoś pozwoli go napisać.

    Spring Boot daje coś, co w enterprise ma ogromną wartość: przewidywalność.
    Każdy nowy developer, konsultant, audytor czy architekt wie, czego się spodziewać. Spring Data, konfiguracja YAML, klasyczny podział warstw – standard, który skraca onboarding i minimalizuje ryzyko.

    Framework dobrze integruje się z narzędziami klasy enterprise: monitoringiem (Actuator + Prometheus/Grafana), systemami logowania, brokerami wiadomości (Kafka, RabbitMQ), systemami autoryzacji (OAuth2, Keycloak), a także korporacyjnymi politykami bezpieczeństwa (czyt. braku internetu na build machine).

    Czas startu aplikacji? W tym środowisku to nie problem – ważniejsze, że działa w sposób powtarzalny. Restart mikroserwisu trwa 2 sekundy dłużej? Nikt tego nawet nie zauważy, bo i tak jest za reverse proxy i load balancerem.

    Startupy

    W startupie liczy się czas. Czas wejścia na rynek, czas testowania hipotezy, czas dostarczenia działającego MVP. Framework ma pomóc, a nie przeszkadzać. I tu sprawa robi się ciekawsza.

    Jeśli zespół zna Springa – to naturalny wybór. Dużo robi się samo: migracje schematów, walidacje, serializacja, dokumentacja (Springdoc/OpenAPI). Mało konfiguracji, dużo gotowych rozwiązań. Dobrze nadaje się do szybkiego prototypowania, o ile ktoś nie zacznie komplikować na siłę (patrz: custom autokonfiguracja w trzecim tygodniu sprintu).

    Ale jeśli zespół ma doświadczenie z cloud-native, zna Dockera, Kubernetes, GraalVM – Quarkus staje się realną alternatywą. Mniejsze zużycie pamięci, krótszy czas uruchamiania, natywne buildy do deployowania na FaaS (np. AWS Lambda) – wszystko to może dać oszczędności w środowisku, gdzie skaluje się przez mnożenie kontenerów.

    Oczywiście: brakuje „magii Springa”, więc część rzeczy trzeba napisać samemu. Ale startupy często i tak piszą własne rozwiązania – tylko szybciej. Tu Quarkus dobrze się wpasowuje: mniej domyślności, więcej jawności. Nie zawsze lepiej, ale bardziej świadomie.

    Mikroserwisy

    Jeśli myślisz o mikroserwisach jako o 30 kopiach tej samej aplikacji z innym endpointem – to Spring Boot wystarczy. Ale jeśli naprawdę projektujesz system rozproszony, z osobnymi cyklami życia serwisów, wysoką skalowalnością i optymalizacją zasobów – tu Quarkus zaczyna być realną opcją.

    Czas startu i zużycie pamięci w Quarkusie to nie tylko ciekawostka z benchmarku – to konkretna oszczędność przy dużej liczbie instancji. W środowisku serverless (FaaS, autoscaling, KNative) różnice robią się zauważalne. Aplikacja, która startuje w 40 ms zamiast 1.5 s i zużywa 70 MB zamiast 200 MB? Na poziomie kilkuset kontenerów dziennie to są wymierne liczby.

    Z drugiej strony: mikroserwisy to nie tylko runtime. To też: testy kontraktowe, logowanie skorelowane po traceId, health checki, obsługa błędów, retry, circuit breakers. Spring Boot ma to wszystko gotowe w Spring Cloud. W Quarkusie musisz to poskładać ręcznie – można, ale trzeba wiedzieć jak.

    W skrócie:

    • Spring Boot to dobry wybór do mikroserwisów klasy „REST + baza + monitoring”, szybko wdrażanych, łatwo rozwijanych.
    • Quarkus warto rozważyć przy większej skali, automatycznym skalowaniu, FaaS lub bardzo wysokich wymaganiach dotyczących czasu reakcji i zużycia zasobów.

    Co warto wiedzieć porównując oba rozwiązania?

    KryteriumSpring BootQuarkus
    Czas uruchamianiaDość szybkiBłyskawiczny (natywny tryb robi robotę)
    Dojrzałość ekosystemuAbsolutna dominacjaJeszcze rośnie, ale ambitnie
    Wsparcie w communityOd „jak to zrobić” po „czemu nie działa”Trochę dziki zachód
    Wersja Hello WorldJedna adnotacja i leciTrochę więcej dłubania
    Sytuacje awaryjneStackOverflow cię nie zawiedzieCzasem trzeba pogrzebać głębiej
    Próg wejściaNiski (albo przynajmniej znajomy)Trochę wyższy
    Praca w zespoleKażdy to znaKtoś musi „nauczyć resztę”

    A co z realnymi projektami?

    Nie widziałem jeszcze dużego polskiego software house’u, który produkcyjnie przeszedł w 100% na Quarkusa. Startupy? Tak. MVP, mikroserwisy, funkcje w chmurze – idealne środowisko. Ale w bankowości, telco czy e-comie? Spring króluje. Bo „działa” jest nadal ważniejsze niż „działa szybko”.

    Co więc wybrać?

    • Jeśli masz do utrzymania system z legacy i 10 letnią historią – Spring Boot.
    • Jeśli tworzysz mikroserwis na serverless z AWS Lambda – Quarkus.
    • Jeśli chcesz szybko dowieźć coś w zespole, który zna Springa – nie kombinuj.
    • Jeśli lubisz eksperymentować i nie boisz się, że trzeba będzie coś napisać samemu – spróbuj Quarkusa.

    Czy warto przechodzić z Spring Boot na Quarkusa?

    W większości projektów – nie. Quarkus nie jest „zamiennikiem” Springa. To framework dla innych przypadków użycia. Tam, gdzie dominują serwisy krótkotrwałe, działające w chmurze i uruchamiane setki razy dziennie – różnica w czasie startu i zużyciu pamięci może mieć znaczenie biznesowe. W projektach korporacyjnych, z rozbudowaną infrastrukturą CI/CD, monitoringiem i długim czasem życia procesów – przewaga Springa nadal jest wyraźna.

  • Piramida testów – czy to jeszcze ma sens?

    W poprzednim artykule opowiedziałem o piramidzie testów. Model ten jest użytecznym uproszczeniem i powinieneś się z nim zapoznać, jeśli jeszcze nie miałeś okazji. 

    Jednak wraz z doświadczeniem, zauważamy coraz więcej niuansów i odstępstw od wyuczonych reguł. Tak samo jest z testowaniem. Piramida testów jest użyteczna, jednak niekiedy istnieje konieczność zmodyfikowania tego podejścia.

    Pojęcie piramidy testów powstało już kilkanaście lat temu, gdy popularna stawała się metodologia TDD. To, że jeszcze o niej pamiętamy, świadczy o tym, że była ona użytecznym drogowskazem dla całego pokolenia programistów. Świat programowania ewoluuje jednak w wariackim tempie. Warto się zastanowić, co się zmieniło przez ten czas w sztuce programowania i czy to nie wymaga od nas zmiany spojrzenia na ten temat.

    Testy są tańsze

    Jak mogłeś przeczytać w poprzednim artykule, jednym z głównych problemów z testami wyższego poziomu (integracyjnymi i E2E) jest czas wykonywania. Wymagają one często uruchomienia całej aplikacji oraz otaczającej jej infrastruktury – bazy danych, kolejek, etc. Niekiedy nie da się (lub nie opłaca) tego zrobić automatycznie i trzeba przygotować takie środowisko testowe ręcznie.

    Choć nadal jest to problem, to już nie tak wielki, jak kilkanaście lat temu. Prawo Moore’a mówi, że moc komputerów podwaja się co 24 miesiące. Nawet jeśli ten rozwój nie jest już tak szybki jak kiedyś, kilkanaście lat to okres, w którym komputery stały się dużo potężniejsze. Do tego takie technologie jak Docker i Test Containers ułatwiły przygotowywanie infrastruktury pod wykonanie testów. Także same frameworki testowe rozwinęły się i testy integracyjne stały się łatwiejsze w pisaniu i wykonaniu, szybsze i bardziej niezawodne. 

    Frameworki rządzą

    Dziś mało kto wyobraża sobie tworzenie kontrolerów HTTP lub dostępu do bazy danych tak, jak robiło się to kilkanaście lat temu. Ilość kodu do napisania jest coraz mniejsza. Duża część jest generowana przez framework na bazie adnotacji lub w inny sposób. Przyniosło to ulgę wielu programistom zmęczonym pisaniu niepotrzebnego, nadmiarowego i powtarzalnego kodu. 

    Jednak taki kod okazuje się często niemożliwy do przetestowania jednostkowo. W Spring Data możemy zdefiniować repozytorium przez stworzenie interfejsu. To, jak działa aplikacja, nie bierze się z kodu, ale konwencji, którą stosuje framework. Nie da się tu wymyślić testu czysto jednostkowego, który byłby użyteczny. Jedyne sensowne podejście, to test integracyjny.

    Nie jest to zjawisko nowe, frameworki towarzyszą nam od lat. Jednak ten trend się rozwija i coraz więcej rzeczy robi się za ich pomocą. 

    100% code coverage wyszło z mody

    Był to trend w początkach TDD, jednak z czasem wiele osób zorientowało się, że pełne pokrycie testami często jest sztuką dla sztuki. Osobiście miałem okazję pracować z osobami, którym testowanie jednostkowe „weszło za mocno”. Efekt? Kod zabetonowany testami, które w praktyce nic nie sprawdzały.

    Testy getterów i setterów, byle tylko dobić do wymaganego przez build poziomu? Brzmi jak świetny pomysł! Jedyne co robi klasa to przekazanie wartości z parametru do kolejnego obiektu? To nic, piszemy test. A że przy okazji takie testy nic nam nie mówią o tym, czy kod robi to, do czego jest napisany? To nic, metryki się przecież zgadzają.

    Co więc z tymi unit testami?

    Nie chcę przekonywać do porzucenia idei testów jednostkowych. Ba, prawdopodobnie to one wciąż powinny być podstawą kontroli jakości Twojej aplikacji. Jak więc sprawić, by wciąż pełniły one swoją rolę, a jednocześnie nie były sztuką dla sztuki i nie prowadziły do zabetonowania aplikacji? Jak wybrać, jakiego rodzaju test w danym miejscu napisać?

    Chciałbym przekonać Cię do traktowania piramidy testów w mniej dogmatyczny sposób. To że piszesz nowy kod, nie musi oznaczać, że 80% testów, jakie napiszesz, to będą testy jednostkowe. Skąd więc to wiedzieć?

    Moduł płytki czy głęboki?

    Chciałbym zapoznać Cię z ideą podziału modułów w aplikacji na głębokie i płytkie. Jest to ciekawa koncepcja, która przydaje się przy dokonywaniu decyzji architektonicznych, jak na przykład właśnie wybór strategii testowania.

    Moduły głębokie 

    Moduł głęboki to taki, w którym duża złożoność jest ukryta za prostym interfejsem. Patrząc z zewnątrz, moduł może wydawać się prosty, jednak logika, która kryje się pod spodem, jest rozbudowana. 

    Przykładem takiego modułu mógłby być kod, który pozwala na wyświetlenie, jaką trasą możemy najbardziej optymalnie przejechać z punktu A do punktu B. Efekt końcowy może być prosty, bo może to być jedynie lista miast, przez które musimy przejechać. Jednak pod spodem dzieje się sporo, od pobrania danych z jakiegoś rodzaju bazy danych przez zaawansowane algorytmy wyznaczania ścieżek do zaprezentowania tego w formie przyjaznej do użytkownika. 

    Ponieważ interfejs jest prosty,  a wnętrze skomplikowane, trudno jest zrozumieć, a także opisać działanie tego modułu, patrząc jedynie z zewnątrz. To jest cecha testów E2E-owych, że testują głównie interfejs modułu lub aplikacji. Dlatego tego rodzaju testy nie będą tu użyteczne. Pewnie warto napisać kilka testów integracyjnych, żeby upewnić się, że całość kodu działa tak, jak tego chcieliśmy. Jednak do testowania logiki użyjemy testów jednostkowych. Pozwalają nam one „wkopać” się do wnętrza modułu i testować jego pojedyncze aspekty w izolacji.

    W modułach głębokich prawdopodobnie tradycyjna piramida testów będzie dobrym rozwiązaniem. W bardzo głębokich modułach możliwe nawet, że testów E2E będzie kilka, a jednostkowych setki. Możliwe też na przykład, że testy integracyjne w ogóle nie będą potrzebne, ponieważ moduł będzie zawierał jedynie czystą logikę biznesową, bez żadnych integracji, więc piramida może być nawet bardziej „agresywna”, niż zwykle, z większą przewagą unit testów

    Moduły płytkie

    Z kolei moduł płytki jest odwrotnością tego pierwszego. Pod interfejsem nie kryje się złożona logika, a zwykle patrząc na sam interfejs można dość łatwo wyobrazić sobie, jaka jest jego implementacja. 

    Przykładem może być moduł, który w odpowiedzi na zapytanie HTTP odczytuje dane z bazy danych i pakuje je w odpowiedź bez nawet szczególnej konwersji na jakąś inną strukturę. W bazie danych jest tabelka z dziesięcioma kolumnami, w odpowiedzi HTTP jest JSON z obiektem z dziesięcioma polami. Typowy CRUD (Create-Read-Update-Delete). Wiadomo, co się dzieje pod spodem i nie są to szczególnie złożone algorytmy, sam kod też jest dość przewidywalny. W tym przypadku raczej będziemy mieli kontroler i repozytorium. Być może znajdzie się jakiś serwis, ale nawet jeśli, to nie robi on wiele więcej, niż wywołanie repozytorium i zwrócenie danych.

    W takiej sytuacji pisanie testów jednostkowych może być wspominaną wcześniej sztuką dla sztuki. Kontroler i repozytorium przetestujemy raczej integracyjnie, zaś test jednostkowy serwisy (o ile w ogóle taki będzie tu istniał) nie powie nam więcej o działaniu modułu, niż dobry test E2E, który sprawdzi, czy całość działa poprawnie i otrzymujemy prawidłową odpowiedź HTTP.

    W takiej sytuacji nasza strategia testowania może być bardzo inna, niż klasyczna piramida testów. W skrajnych przypadkach możliwe, że nie napiszemy ani jednego testu jednostkowego, a pozostaniemy jedynie przy integracyjnych i E2E-owych. Prawdę mówiąc, miałem okazję pracować przy projektach, gdzie nie było żadnych unit testów i nie odczuwałem ich braku (choć w tym podejściu też można przesadzić). Możemy więc skończyć z czymś, co będzie raczej odwróconą piramidą testowania, gdzie im wyższy poziom testów, tym jest ich więcej.

    Jeśli moduł jest płytki, to struktura testów może przybrać nawet kształt odwróconej piramidy

    Prawdziwy świat

    Pewnie moduły, które będziemy pisać, nie będą ani tak głębokie, jak podany przykład aplikacji do wyznaczania trasy, ani tak proste jak wspomniany prosty CRUD. Raczej coś pomiędzy. Dlatego też nasza strategia testowania nie będzie idealną tradycyjną piramidą, ani też odwróconą piramidą. 

    Można sobie na przykład wyobrazić sytuację, gdzie mamy dość złożoną aplikację, ale jej złożoność polega na integracji z wieloma zewnętrznymi systemami. Łączymy się z jakąś bazą danych, potem z kolejką, wysyłamy zapytanie do zewnętrznego API REST-owego i dopiero otrzymujemy wynik. W takiej sytuacji możemy otrzymać system, gdzie najwięcej będzie testów integracyjnych, a tych jednostkowych i E2E-owych będzie stosunkowo niewiele.

    Tego typu struktura testów może mieć sens, jeśli Twój moduł integruje się bardzo mocno z zewnętrznymi systemami

    Co z tym wszystkim zrobić?

    To, co chciałbym, żebyś wyciągnął z tego artykułu, to idea, że niekoniecznie należy się trzymać kurczowo jednej strategii testowania. Raczej przyglądaj się modułom, które piszesz i decyduj, jakie testy najlepiej zapewnią Ci przekonanie, że Twój projekt jest dobrze przetestowany. Do tego przyda Ci się wspomniany wcześniej koncept modułów głębokich i płytkich.

    Z tego wszystkiego można też wyciągnąć wniosek, z którym się zresztą spotykałem, że jakiekolwiek strategie testowania nie mają za bardzo sensu. I tak decyzje o użycie konkretnego typu testu podejmujemy w zależności od tego, jaki typ kodu właśnie piszemy. Czemu by po prostu nie oceniać tego na bieżąco i nie pisać testów, jakie w danej sytuacji uznamy za stosowne? Po co myśleć o tym, jak procentowo rozkłada się udział różnych testów w naszej aplikacji i w jaki kształt to się układa?

    Nie wydaje mi się jednak, żeby całkowite porzucenie tej idei było najlepszym pomysłem. Spoglądanie na strategie testowania pozwala nam nie stracić z oczu dużego obrazka. Wciąż (choćby przybliżone) porównanie, ile mamy testów na którym poziomie, jest przydatną metryką, która pozwala zauważyć, czy idziemy w dobrą stronę. Może zwrócić uwagę na przykład, że trochę za bardzo postawiliśmy na testy E2E i warto kilka z nich przepisać na unit testy. Taka sytuacja zdarzyła mi się zresztą całkiem niedawno.

    Więc nie porzucajmy całkiem piramidy testowania. Jednak zdawajmy sobie sprawę, że tak jak w każdej dziedzinie, dawne wzorce zmieniają się. Pracując w takiej branży jak IT, ignorowanie tego nie jest dobrym pomysłem.

  • Piramida testów

    Gdy młody programista zaczyna swoją przygodę z testowaniem, często napotyka się po raz pierwszy na pojęcie piramidy testów. Stało się ono bardzo popularne kilka lat temu, wraz z tym, jak testy automatyczne „weszły pod strzechy” i stały się częścią codziennej rzeczywistości programistów.

    Warto sobie zdawać sprawę, że to podejście nie zawsze musi być optymalne i istnieją odstępstwa od tego modelu. Piramida testów jest przydatnym uproszczeniem i jeśli dopiero zaczynasz rozumieć, o co chodzi w testowaniu, to naprawdę, NAPRAWDĘ, polecam, abyś zrozumiał to podejście i stosował je w praktyce. W wielu przypadkach jest ono dobre, a w najgorszym razie nie jest dramatycznie złe.

    Zacznijmy od wyjaśnienia, jak rozumiemy poszczególne poziomy testów.

    Testy jednostkowe

    Pierwszym rodzajem testów są testy jednostkowe. Dla niektórych to synonim zautomatyzowanych testów, jednak warto wiedzieć, że to jedynie jeden z ich rodzajów. Ich cechą jest to, że testujemy w nich jedynie… cóż, jednostkę. Zawsze tu pojawia się dyskusja – czym jest jednostka (unit)? Co programista, to opinia, ja nie zamierzam Ci narzucać żadnej.

    Jedynie zaznaczę, że dla mnie unit nie musi być koniecznie jedną klasą. Jeśli mamy zespół kilku klas, które ściśle ze sobą współpracują i nie mają sensu w oderwaniu od siebie, nie widzę sensu, by testować każdą z nich osobno. Takie testy bardziej zaciemniają sytuację i „betonują” kod – sprawiają, że jakikolwiek refactoring jest bolesny, bo trzeba zrobić zmiany w wielu testach, mimo że sama funkcjonalność się nie zmienia.

    Co jednak z pewnością charakteryzuje test jednostkowy to szybkość wykonywania. Aby test wykonywał się szybko, musimy maksymalnie go uprościć. Nie ma mowy o łączeniu się do bazy danych, stawiania serwera HTTP, tworzeniu kontekstu springowego. Testujemy tylko klasę lub kilka klas, w izolacji od całego świata. Dzięki temu, wykonanie takiego testu nie powinno zająć więcej niż kilka milisekund.

    Testy integracyjne

    Drugim poziomem są testy integracyjne. Tu też znajdą się różne definicje, jednak wyznacznikiem jest wyjście poza sam kod. Jeśli testujemy nie tylko nasz kod, ale też framework, którego używamy (np. Spring), jeśli łączymy się z bazą danych lub wysyłamy coś na topic Kafki, to prawdopodobnie mamy już do czynienia z testem integracyjnym.

    Dobrym przykładem mógłby być test repozytorium Spring Data. Mogłoby się wydawać, że przedmiotem testu jest tylko klasa, tak więc test jest jednostkowy, jednak sam kod repozytorium nie ma większego sensu, jeśli nie zostanie odpowiednio zinterpretowany przez Springa. Twój test nie sprawdza tak naprawdę Twojego kodu, tylko czy dobrze zintegrowałeś się z frameworkiem, jakim jest Spring Data. 

    Innym przykładem ze świata Springa mógłby być test kontrolera. Znów, klasa kontrolera sama w sobie nie robi wiele ciekawych rzeczy. Cała magia dzieje się, gdy Spring Web analizuje adnotacje, którymi oznaczyłeś klasę i na tej podstawie tworzy endpointy HTTP.

    Bez testów integracyjnych trudno wyobrazić sobie dziś dobrą aplikację. Jakie są ich wady? Z pewnością szybkość. W przypadku testów springowych test odpala tak naprawdę cały kontekst, co jest kosztowne. Czas wykonania przeciętnego testu integracyjnego może wynosić nawet kilkaset milisekund.

    Testy E2E

    Na górze piramidy zobaczymy testy E2E (end-to-end, obejmujące całą aplikację). W czasach, gdy tworzono pojęcie piramidy testów, tutaj zwykle pojawiały się już testy manualne. Oddelegowana osoba ręcznie przechodziła różne ścieżki w aplikacji w celu wyłapania błędów. Jeśli nawet takie testy były zautomatyzowane, to często wciąż zajmowała się nim osoba spoza zespołu programistów. Nie trzeba chyba mówić, że takie podejście było bardzo kosztowne, ponieważ ludzie zawsze będą drożsi w utrzymaniu, niż kawałek kodu.

    Jednak technologia poszła do przodu i dziś pisanie niezawodnych testów E2E nie jest aż takim wyzwaniem. Często zajmuje się tym sam programista. Wciąż jednak są one dość drogie w utrzymaniu, ponieważ są bardzo wolne. Wymagają często uruchomienia całego środowiska, a niekiedy łączą się z zewnętrznymi systemami, które zawsze będą miały narzut czasowy związany z przesyłem danych.

    Są też mniej stabilne- zmiana każdej niewielkiej części kodu może skutkować w zmianie wyniku testu. Z tego wynika jeszcze jeden problem. Ponieważ test obejmuje dużą ilość kodu, trudniejsze jest dostrzeżenie przyczyny, jeśli wykonanie go się nie powiedzie. Test jednostkowy zwróci negatywny wynik jeśli dana klasa jest źle zaimplementowana. Jeśli nie powiedzie się test E2E – musimy dopiero rozpocząć poszukiwanie źródła błędu.

    Mimo to, testy E2E są niezastąpione do upewnienia się, że całość aplikacji działa tak, jak sobie to wyobraziliśmy. Pełnią więc często rolę testów akceptacyjnych.

    Piramida testów

    Teraz, gdy już wiemy, co kryje się pod poszczególnymi kategoriami testów, możemy wyjaśnić, co kryje pod swoją nazwą piramida testów. Kilkukrotnie wspominałem o szybkości działania i koszcie utrzymania poszczególnych testów. Najszybsze są testy jednostkowe, najwolniejsze są testy E2E. To wymusza już na nas pewną strategię testowania. Jeśli napiszemy mnóstwo testów integracyjnych i E2E, taki zestaw testów będzie się wykonywał godzinami (uwierz mi, widziałem takie systemy). Takim ograniczeniom nie podlegają testy jednostkowe. Setki lub tysiące takich testów to wciąż kwestia kilku sekund. Dzięki temu programista może bez problemu puszczać takie testy co kilka minut bez negatywnego wpływu na swoją efektywność (i stan psychiczny).

    Nazwa piramidy bierze się od liczby testów, które chcemy mieć na poszczególnych poziomach. Oto, jak można taką strategię testów zwizualizować.

    Staramy się więc, aby zdecydowaną większość naszych testów stanowiły testy jednostkowe. Są one tanie w pisaniu i utrzymaniu, są szybkie, więc możemy puszczać ich setki lub tysiące naraz, utrzymując krótki czas wykonania oraz produkujemy je niejako „przy okazji” developmentu (przynajmniej tak powinno być przy użyciu podejścia TDD).

    To były podstawy wiedzy o strategii testowania. Jeśli chcesz dowiedzieć się więcej, przeczytaj też mój kolejny artykuł:

    Piramida testów na głowie

  • Given-When-Then to ściema? O BDD w testach

    Pisanie testów jednostkowych w manierze TDD już od dawna było dla mnie czymś oczywistym. Tego dnia mogę powiedzieć, że byłem naprawdę zadowolony z kodu oraz z testów, które napisałem.

    git commit, git push i wystawiam pull requesta. Czekam na review od kolegi.

    Dostaję komentarz:

    Wszystko spoko, fajnie, testy mi się podobają, tylko dopisz komentarze given when then.

    Pierwsza reakcja: „A no tak, rzeczywiście, zapomniałem”. Pisanie testów w takim stylu było u nas niepisaną regułą. Już miałem odpisać, że zaraz poprawię, jednak w tym momencie zatrzymałem się… i zapytałem sam siebie: „Zaraz, ale właściwie… to po co?”

    Mimo popularności, jest kilka rzeczy, o których zwolennicy tej metody nie wspominają. O nich właśnie jest ten artykuł.

    Właśnie, po co to Given When Then?

    Given-When-Then to podejście do pisania testów wywodzący się z ruchu Behaviour-Driven Development. Bardziej konkretnie związane jest to z ideą Specyfikacji przez przykład. Zapoznanie się z linkami może pomóc zrozumieć to podejście, jednak co musisz wiedzieć, to sprowadza się to do zapisywania testów bardziej „ludzkim” językiem – takim, aby był zrozumiały też dla osób nietechnicznych.

    Są narzędzia, które wspierają pisanie testów (z założenia głównie akceptacyjnych) w takiej manierze. Wiele osób kojarzy pewnie takie narzędzia jak Cucumber lub JBehave. Taki sposób pisania testów wyciekł również do naszych codziennych testów jednostkowych i najczęściej realizuje się go za pomocą komentarzy sygnalizujących każdą sekcję.

    Rozważmy prościutki test unitowy:

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      //given
      Store store = new Store();
      store.addItems("pencil", 100);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getResult()).isFalse();
    }

    Co tu jest nie tak? Na pewno nie jest to BDD! I prawda jest też taka, że konwencja given-when-then już zdążyła się od BDD „odkleić” i żyje teraz własnym życiem. Wiele osób stosuje to trochę bezrefleksyjnie. Wydawałoby się nawet, że traktują tę formułę jako pewnego rodzaju zaklęcie, które ma w magiczny sposób poprawić design ich testów.

    Jest given-when-then, jest approve!

    Potrafi być to niestety podejście zgubne. Fakt, że given when then może wprowadzić nieco przejrzystości, wskazując, które części za co odpowiadają. Potrafi to być jednak zdradliwe.

    Problemy z komentarzami w kodzie

    Pewnego razu miałem wątpliwą przyjemność oglądać kod, gdzie komentarze given when then były poumieszczane w taki sposób, że jedynie zaciemniały obraz sytuacji. Pierwsze co chciałem zrobić, to poprzesuwać kod testowy, aby miało to wszystko sens. Zauważyłem jednak, że nie jest to możliwe przez to, jak powiązany ze sobą cały kod był. Co więc zrobiłem zamiast tego, to ich zwyczajnie te komentarze usunąłem.

    Dlaczego ta historia tak się zakończyła? Odpowiedź podpowiada dość znana zasada czystego kodu:

    Komentarze kłamią!

    Każdy programista świeżo po przeczytaniu książki „Clean code” Uncle Boba

    Nawet nie musi to być zła wola czy też brak staranności lub wiedzy osoby piszącej test. Możliwe, że sposób, w jaki Ty umieścisz te komentarze, będzie zupełnie w porządku. Jednak pamiętaj, że z chwilę wpisania komendy git push kod przestaje być Twoją wyłączną własnością. Od tej pory każdy ma do niego dostęp i niekoniecznie będziesz w pobliżu, gdy ktoś postanowi go zmienić, abyś mógł go powstrzymać. 

    Ktoś może się oburzyć, że przecież nie możemy wziąć odpowiedzialności za przyszłe cudze błędy. Czy na pewno? Jeśli ułatwiamy komuś pomylenie się, część odpowiedzialności spada na nas. Tak samo, jeśli nie zrobiliśmy wszystkiego, co możliwe, by to utrudnić.

    Zgodnie z ideą poka-yoke za pomyłki nie należy winić ludzi, ale procesy. Odpowiedzią jest czynienie tego co robimy błędoodpornego lub, jak kto woli, idiotoodpornego.  Kiedy ktoś próbuje zmienić Twój kod, mamy pewne mechanizmy bezpieczeństwa. Kompilator oraz zestaw testów powinien powstrzymać innych (i nas samych po kilku latach) do robienia przynajmniej tych bardziej oczywistych głupot. Rzecz jasna, jeśli ktoś chce sobie zrobić krzywdę, to znajdzie sposób, jednak po co to ułatwiać?

    Przenosimy given when then na nowy poziom

    Rozwińmy zasadę, którą przytoczyliśmy wcześniej:

    Komentarze kłamią, kod nigdy!

    My nie będziemy w okolicy, gdy ktoś zechce zrobić coś głupiego z naszymi testami (na przykład dodać kod przygotowujący środowisko już w sekcji When). Natomiast kompilator raczej tam będzie. Więc spróbujmy zrobić z tego użytek i przeróbmy nasze testy.

    Przypomnijmy sobie kod z wcześniejszego przykładu:

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      //given
      Store store = new Store();
      store.addItems("pencil", 100);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getResult()).isFalse();
    }

    Jak już mówiliśmy, nie są to testy w stylu BDD. Kod nie byłby czytelny dla osoby nietechnicznej. Do tego komentarze zawsze są problematyczne, bo kompilator nigdy ich nie sprawdza. Możemy pozbyć się obu tych problemów za jednym zamachem.

    Wyjdźmy z koncepcji specyfikacji przez przykład i spróbujmy „opowiedzieć” sobie, co sprawdza dany test. Wraz z tą opowieścią pojawi się nam w głowie automatycznie odpowiedź na pytanie, co tak naprawdę jest ważne dla danego test case’a. Właśnie te istotne szczegóły chcemy jak najlepiej wyostrzyć. W naszym przykładzie widzę następującą historię:

    • Zakładając że w sklepie jest 100 ołówków
    • Kiedy składam zamówienie na 150 ołówków
    • To zamówienie nie może być zrealizowane

    Więc po prostu przenieśmy tę opowieść jeden do jednego do naszego testu.

    Poprawiamy test

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      givenItemsAdded("pencil", 100);
    
      whenOrderingItems("pencil", 150);
    
      thenOrderCannotBeExecuted();
    }

    Pozostaje nam jedynie zdefiniowanie, co oznaczają poszczególne elementy opowieści:

    private Store store = new Store();
    private Order order;
    
    // test cases
    
    public void givenItemsAdded(String item, int quantity) {
      store.addItems(items, quantity);
    }
    
    public void whenOrderingItems(String item, int quantity) {
      order = store.makeOrder(items, quantity);
    }
    
    public void thenOrderCannotBeExecuted() {
      assertThat(order.getResult()).isFalse();
    }

    Taki kod posiada tę zaletę, że jest napisany jak specyfikacja. Metody ukrywają przed nami szczegóły implementacji, powodując, że bardzo łatwo na pierwszy rzut oka zrozumieć, jaki jest cel danego testu. Skupiamy się na aspektach biznesowych, na tym co robi nasz kod, a nie – jak.

    Do tego zauważmy, że nowoutworzone metody tworzą bardzo zgrabny zestaw elementów, za pomocą których możemy łatwo dopisywać kolejne test case’y:

    @Test
    void shouldAcceptOrderGivenEnoughItems() {
      givenItemsAdded("pencil", 200);
    
      whenOrderingItems("pencil", 150);
    
      thenOrderIsExecuted();
    }
    
    public void thenOrderIsExecuted() {
      assertThat(order.getResult()).isTrue();
    }

    Poczekaj! To jeszcze nie koniec zalet!

    Kolejną zaletą jest to, że ponieważ szczegóły implementacyjne są enkapsulowane. Wyobraźmy sobie, że chcemy pobierać informację o dostępności produktu z zewnętrznego systemu zamiast ustawiać to w obiekcie.

    Tymczasem, dzięki enkapsulacji, przy zmianie sposobu działania testowanej metody musimy dokonać zmiany tylko w jednym miejscu. Same test case’y pozostają bez zmian. Co logiczne, ponieważ tak naprawdę wymagania biznesowe pozostały te same. Z punktu widzenia użytkownika system wciąż działa tak samo. Zobaczmy jak mogłaby wyglądać zmieniona metoda do ustawiania warunków początkowych testów, jeśli do sprawdzania dostępności produktów używalibyśmy zewnętrznego systemu.

    public void givenItemsAdded(String item, int quantity) {
      when(externalSystem.checkAvailability(item)).thenReturn(quantity);
    }

    I tyle. Reszta kodu testów pozostaje co do znaku identyczna.

    Zastosowanie w bardziej skomplikowanym teście

    Powyższy przykład jest banalnie prosty i można stawiać tezę, że zysk z zastosowania tej techniki nie przewyższa poniesionego wysiłku. Tak naprawdę jednak prawdziwa moc tego podejścia pokazuje się, gdy nasza logika jest bardziej złożona. Zobaczmy kolejny przykład, gdzie składanie zamówienia staje się dużo bardziej skomplikowane.

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      //given
      when(availabilitySystem.checkAvailability("pencil"))
        .thenReturn(200);
      when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
      when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(1050)
      
      store.loadAvailabilityInformation();
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getPrice()).isEqualTo(60);
    }

    Zwróćmy najpierw uwagę na linijkę nr 5. Wygląda to na setup, który prawdopodobnie będzie wspólny dla wszystkich test case’ów. Po zweryfikowaniu, że tak będzie, przenosimy więc to do osobnej metody setupowej. Pamiętajmy, że jedną z dewiz nam przyświęcających jest to, aby w teście znajdował się jedynie kod, który pomoże zrozumieć, na czym polega dany use case, więc korzystajmy z takich okazji, by nasze testy uprościć.

    @BeforeEach
    void setup() {
      when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
    }

    Idąc dalej, spróbujmy wydzielić pozostałe klauzule when() w postaci metod, które opisują ustawienia testu w sposób bardziej biznesowy

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      //given
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(1050);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getPrice()).isEqualTo(60);
    }
    
    void givenItemAvailability(String item, int quantity) {
      when(availabilitySystem.checkAvailability(item)).thenReturn(quantity);
      store.loadAvailabilityInformation();
    }
    
    void givenLoyaltyPoints(int points) {
      when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(points)
    }

    Możemy również dodać metody enkapsulujące asercje oraz samą akcje, podobnie jak we wcześniejszym przykładzie. W tym momencie dochodzimy do wniosku, że komentarze given when then są w zasadzie zbędne, więc możemy je usunąć. Zobaczmy, jak wyglądałby ostateczny kształt naszego testu. Zwróćmy uwagę, jak udało się przy okazji stworzyć cegiełki, z których można łatwo budować następne test case’y.

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(1050);
    
      whenOrderingItems("pencil", 150);
    
      thenPriceIs(60);
    }
    
    @Test
    void shouldGiveCustomerAStandardPriceIfHeHasNotEnoughLoyaltyPoints() {
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(950);
    
      whenOrderingItems("pencil", 150);
    
      thenPriceIs(75);
    }
    
    @Test
    void shouldNotExecuteOrderIfNotEnoughItems() {
      givenItemAvailability("pencil", 100);
    
      whenOrderingItems("pencil", 150);  
    
      thenOrderIsNotExecuted();
    }
    
    public void thenOrderIsNotExecuted() {
      assertThat(order.getResult()).isFalse();
    }

    Dopisywanie kolejnych test case’ów, z których każdy byłby równie dobrze zrozumiały dla osoby nietechnicznej jest teraz prostsze niż kiedykolwiek. Działa to też w drugą stronę. Dzięki przetłumaczeniu konceptów biznesowych na techniczne możemy łatwo tłumaczyć wymagania na nowe testy, ułatwiając sobie pisanie kodu.

    Podsumowując

    Koncepcja pisania testów według maniery given when then nie jest pozbawiona sensu. Chciałem jednak pokazać, że można pójść dalej, aby móc wycisnąć z tego podejścia więcej i pozbyć się pewnych problemów z nim związanych. Oparcie się na kodzie zamiast komentarzach sprawia, że kod naszych testów staje się bardziej skupiony na problemie biznesowym, a mniej na technicznej implementacji oraz jest mniej podatny na rozjazd testów z kodem produkcyjnym w przyszłości. To wszystko jest kolejną małą cegiełką czyniącą nasze testy bardziej użytecznymi.

  • Ucieczka z piekła N+1. Problem i rozwiązania

    Prawdopodobnie każdy, kto choć raz miał okazję ubiegać się o pracę jako programista, usłyszał na rozmowie kwalifikacyjnej pytanie: „Czy wie Pan, czym jest problem N+1?

    Problem N+1 to absolutna klasyka. Mimo tego widzę, że nadal mnóstwo programistów wie o nim niewiele. W najlepszym przypadku znają teorię, ale niekoniecznie zwracają na niego uwagę w codziennej pracy, a nawet, gdy go napotykają, ignorują go, zazwyczaj przez brak pomysłu, co z tym fantem zrobić. A rozwiązanie potrafi być naprawdę proste.

    Dlaczego powinieneś zdawać sobie sprawę z problemu? Jak sobie z nim poradzić, nie wpadając jednocześnie w inne pułapki? Oczywiście, wszystko można znaleźć na StackOverflow. Niestety, odpowiedzi, jakie tam znajdziemy, są często pójściem po linii najmniejszego oporu i jeśli nie rozumiemy, jaka jest prawdziwa przyczyna problemu, bardzo łatwo zrobić sobie krzywdę. Stąd ten artykuł, który jest kompletnym naświetleniem m0żliwych rozwiązań i pozwoli Ci dobrać to odpowiednie w większości sytuacji, z jakimi się spotkasz.

    Problem N+1 na przykładzie

    W ramach wyjaśnień: Będę używać adnotacji JPA tam gdzie to możliwe (oraz niektórych adnotacji Hibernate, które nie mają odpowiednika w JPA). Będę też czasem zamiennie posługiwał się nazwami JPA oraz Hibernate w sytuacji, gdy drugie jest jedynie implementacją pierwszego i nie wprowadza nic, czego w JPA nie ma. Jaka jest relacja między JPA i Hibernate – o tym przeczytacie na przykład w tym temacie na Stack Overflow.

    Na tym wszystkim używam Spring Data, który staje się coraz bardziej standardem w przemyśle i który ułatwia wykonywanie wielu rzeczy, a mam wrażenie że jest traktowany przez niektórych jako usprawiedliwienie swojego niezrozumienia tego, co dzieje się „pod maską”.

    Nie będę wyjaśniał w szczegółach całego kodu, tylko tyle, ile jest potrzebne do zrozumienia problemu. Źródła znajdziesz na moim GitHubie. Każde z rozwiązań problemu posiada swój własny branch, do którego odnośnik znajdziesz na początku każdej części.

    Wyobraźmy sobie aplikację przechowującą informacje o zamówieniach (kolejny absulutny klasyk, tym razem w dziedzinie przykładów kodu). Każde podsumowanie zamówienia (OrderSummary) składa się z jakiejś liczby pozycji (Item):

    @Entity
    public class OrderSummary {
        @Id
        Long id;
    
        Long userId;
    
        String name;
    
        @OneToMany()
        @JoinColumn(name = "SUMMARY_ID")
        List<Item> items = new ArrayList<>();
    
        public Long getUserId() {
            return userId;
        }
    
        public String getName() {
            return name;
        }
    
        public List<Item> getItems() {
            return items;
        }
    }

    @Entity
    public class Item {
        @Id
        Long id;
    
        String name;
        BigDecimal price;
    
        public Long getId() {
            return id;
        }
    
        public String getName() {
            return name;
        }
    
        public BigDecimal getPrice() {
            return price;
        }
    }
    

    Jako interfejs komunikacji z bazą danych wykorzystuję repozytorium stworzone za pomocą Spring Data.

    @Component
    public interface OrderSummaryRepository extends JpaRepository<OrderSummary, Long> {
        public List<OrderSummary> findByUserId(Long userId);
    }
    

    Bazę danych inicjalizujemy następującymi danymi

    INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (1, 1, 'Zamowienie 1');
    INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (2, 1, 'Zamowienie 2');
    INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (3, 1, 'Zamowienie 3');
    INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (4, 1, 'Zamowienie 4');
    INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (5, 1, 'Zamowienie 5');
    
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (0, 1, 'Zel do wlosow', 11.99);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (1, 2, 'Gumowa kaczuszka', 2.49);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (2, 2, 'Mapa Imperium Lechitow', 119.00);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (3, 2, 'Maska antysmogowa', 89.90);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (4, 3, 'Zlota szczeka dziadka', 999.00);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (5, 3, 'Tajemniczy przedmiot 1', 324.00);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (6, 4, 'Tajemniczy przedmiot 2', 654.00);
    INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (7, 5, 'Tajemniczy przedmiot 3', 12.00);

    Mając takie mapowanie encji w bazie danych, wyobraźmy sobie bardzo prostą i typową operację, jaką moglibyśmy chcieć na tych danych wykonać: Chcemy pobrać wszystkie zamówienia danego użytkownika i sprawdzić, ile w sumie jest w nich wszystkich przedmiotów. W tym celu piszę test:

    @Autowired
    private SummaryRepository summaryRepository;
    
    @Test
    void shouldExtractItemsFromOrders() {
        List<OrderSummary> summaries = summaryRepository.findByUserId(1L);
    
        List<Item> items = summaries.stream()
          .map(OrderSummary::getItems)
          .flatMap(Collection::stream)
          .collect(Collectors.toList());
    
        assertThat(items).hasSize(8);
    }
    

    Odpalamy nasz test:

    Wspaniale, wszystko się zgadza, możemy odtrąbić sukces, iść do domu i następnego dnia na stand-upie pochwalić się wykonaniem świetnej roboty.

    ZARAZ ZARAZ…

    Spójrzmy na chwilę w logi. To powód, dla którego warto czasem włączyć logowanie operacji na bazie danych, jakie wykonuje Hibernate:

    org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?
    
    org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?
    
    org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?
    
    org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?
    
    org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?
    
    org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?
    

    Dużo niełatwego do przeanalizowania tekstu (mimo że zostawiłem tylko te fragmenty, które przydadzą nam się w analizie, w logach jest oczywiście znacznie więcej), jednak warto się w niego wczytać. Możemy zauważyć, że zostało wykonanych aż sześć Selectów na bazie danych! Jakikolwiek student kierunku pokrewnego z informatyką potrafiłby skonstruować pojedyncze zapytanie, które osiągnęłoby ten sam efekt.

    To właśnie problem N+1 w całej okazałości. Jeśli jeszcze się nie zorientowałeś, 6 zapytań, jakie zostały wykonane, to jedno dotyczące wszystkich zamówień i po jednym, aby uzyskać listę pozycji dla każdego z nich. 5 zamówień: 5+1 = 6 zapytań. 20 zamówień: 20+1 = 21 zapytań. I tak dalej. Stąd N+1.

    I w czym tu problem?

    Oczywiście, można dyskutować: sześć zapytań, co to za problem? Przeciętna aplikacja nawet nie odczuje tych sześciu zapytań. Ok, sześciu może nie. Gorzej, jeśli – co się często dzieje – operujemy raczej na tysiącach rekordów. Wyobraź sobie wysyłanie do bazy danych dziesiątek tysięcy zapytań, by wyświetlić prostą listę. Coś takiego może zabić wydajność każdego systemu.

    Czemu więc Hibernate nie potrafi (lub nie chce) wczytać danych „mądrzej”, tylko potrzebuje aż 6 zapytań? Spójrzmy jeszcze raz na to, jak zdefiniowaliśmy nasze mapowanie pomiędzy Zamówieniami i Pozycjami:

    @OneToMany()
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    Warto sobie zdawać sprawę, że mapowanie OneToMany domyślnie ładuje się leniwie (Lazy Loading). Oznacza to, że Hibernate, wczytując listę zamówień, nie prosi bazy danych o informację o konkretnych pozycjach się na nie składających. Nie jest to głupie – ostatecznie tych danych może być bardzo, bardzo dużo, nawet miliony wierszy, a często tak naprawdę wcale nie potrzebujemy po nie sięgać.

    Wyobraźmy sobie, że chcemy jedynie wyświetlić użytkownikowi nazwę zamówień, a listę pozycji – dopiero po wybraniu któregoś z nich. Wtedy Lazy Loading jest wybawieniem. Co prawda robimy dwa zapytania, zamiast jednego, jednak nie dostajemy mnóstwa danych o zamówieniach, które nas nie interesują. Baza danych nie musi ich wysyłać, Hibernate ich nie przetwarza, pamięć nie jest przez nie zaśmiecona. Czyli w wielu sytuacjach jest to naprawdę dobra strategia.

    Odpowiedź na pytanie, czy Lazy Loading jest dobrym rozwiązaniem brzmi więc „To zależy…”. Tym, co odróżnia przeciętnego programistę od dobrego jest dokonywanie dobrych wyborów, gdy sytuacja nie jest oczywista i rozeznanie się w niej wymaga wiedzy i często intuicji. Tym razem, wygląda na to że Lazy Loading to nie jest coś, czego chcemy. Jeśli ładujemy zamówienia tylko po to, by po chwili przetierować przez wszystkie pozycje, to optymalne byłoby jedno zapytanie. Spróbujmy więc zmusić Hibernate’a, by zachował się w taki właśnie sposób.

    Pierwsze co może Ci przyjść do głowy…

    Skoro problem N+1 wynika z leniwego ładowania, to może po prostu wyłączmy Lazy Loading? JPA oferuje taką możliwość. Spróbujmy więc.

    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    Zobacz kod na moim GitHubie

    Można by się spodziewać, że powyższy kod rozwiąże nasz problem. Jednak po spojrzeniu w logi zauważymy, że… są dokładnie takie same.

    Wciąż generujemy N+1 zapytań: jedno dla zamówienia i po jednym dla każdej pozycji. Natomiast nastąpiła jedna różnica, którą może zobrazować następujący kod:

    PersistenceUtil persistenceUtil = Persistence.getPersistenceUtil();
    
    @Test
    void shouldExtractItemsFromOrders() {
         List<OrderSummary> summaries = summaryRepository.findByUserId(1L);
     
        // Elementy kolekcji są już załadowane przez Hibernate'a
        assertThat(persistenceUtil.isLoaded(summaries.get(0).getItems()))
            .isEqualTo(true);

    Hibernate nie czeka aż zechcemy przejrzeć pozycje zamówienia. Ładuje je natychmiast, kiedy zostaje wczytane samo zamówienie. Nie zmienia to faktu, że wciąż wykonywanych jest tych samych 6 zapytań do bazy danych. W tym momencie można powiedzieć, że mamy najgorsze z dwóch światów: nie mamy Lazy Loadingu i mamy problem N+1. Jak przegrywać, to po całości…

    To Ty, kiedy twoja aplikacja nie ma lazy loadingu i wciąż ma problem N+1

    Tutaj warto się zatrzymać i wyjaśnić jedną rzecz. Wielu użytkowników, którzy na co dzień posługują się EntityManagerem, spodziewałoby się tutaj jednego zapytania zrealizowanego za pomocą SQL-owego joina. Jest to domyślne zachowanie Hibernate’a dla FetchType.EAGER. Jednak zostanie to wzięte pod uwagę tylko, gdy używamy metody .find() z EntityManagera. Spring Data pod spodem powyższego kodu tworzy zapytanie równoważne (zapytanie w JPQL):

    select os from OrderSummary os where os.userId = ?1

    To jedna z zawiłości Hibernate’a, która nie jest oczywista nawet dla zaawansowanych użytkowników. W momencie, gdy pobieramy dane za pomocą zapytania (i tu wlicza się zarówno jawne napisanie JPQL-a, zastosowanie Criteria API, jak i kod wygenerowany stworzone przez Spring Data repozytorium), biblioteka generuje zapytanie takie, jak powyższe, co oznacza pobranie danych za pomocą selectów. Tak już jest, czy się nam to podoba czy nie.

    Rozwiązanie 1: Ładowanie z użyciem joina

    Zobacz kod na moim GitHubie

    Poznajmy adnotację @Fetch:

    import org.hibernate.annotations.Fetch;
    import org.hibernate.annotations.FetchMode;
    
    ...
    
    @OneToMany()
    @Fetch(FetchMode.JOIN)
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    Hibernate oferuje nieco więcej niż JPA jeśli chodzi o ładowanie danych. Nie znajdziecie tej adnotacji w standardzie JPA, a daje to naprawdę duże możliwości. W powyższym przypadku, mówimy Hibernate’owi, że zamiast używać osobnych selectów do pobrania encji z tabeli Order i Item, powinien zrobić na nich joina, by wszystkie dane zdobyć jednym zapytaniem.

    Mniej więcej coś takiego zrobilibyśmy, gdybyśmy chcieli załadować te wszystkie dane za pomocą zapytania ręcznie. Więc wydaje się, że to jest właśnie to, czego nam trzeba.

    Odpalamy kod i… cóż, znowu rozczarowanie! Widzimy dobrze nam znane 6 selectów.

    Sytuacja jest dokładnie ta sama jak w przypadku użycia FetchType.EAGER. Hibernate zwyczajnie ignoruje nasze zalecenia. Nie żegnajmy się jeszcze z adnotacją FetchMode, jeszcze zdążymy ją docenić. Póki co jednak widać, że jeśli chcemy wymusić na naszym ORM-ie wykonanie joina, musimy podejść inaczej…

    Więc jak…?

    Dochodzimy do pierwszego działania, które rzeczywiście zadziała (przepraszam za spoiler… tak, będzie happy end, już wiecie).

    Wspominałem już, że problem wynika z tego, jakie zapytanie jest generowane przez Hibernate’a. Przypomnijmy je:

    select os from OrderSummary os where os.userId = ?1

    Tutaj odezwą się z poczuciem wyższości fani używania Hibernate’a w najbardziej podstawowej postaci, bez nakładek typu Spring Data. Tam rozwiązanie byłoby całkiem proste. JPA posiada dyrektywę join fetch, która nakazuje bibliotece zrobić dokładnie to, czego chcemy. W tym samym zapytaniu, w którym ładujemy zamówienie, prosimy od razu o dane o pozycjach. Zapytanie to wyglądałoby następująco:

    select distinct os from OrderSummary os join fetch os.items where os.userId = ?1

    Ale tutaj zwrot akcji: Spring Data pozwala nam działać na tym poziomie. Wystarczy w naszym repozytorium użyć adnotacji @Query, aby dostosować, jakiego zapytania ma użyć Hibernate!

    @Query("select distinct s from OrderSummary s join fetch s.items where s.userId = ?1") List<OrderSummary> findByUserId(Long userId);

    Po tej zmianie uruchamiamy nasz test, by zobaczyć wreszcie upragniony rezultat:

     org.hibernate.SQL                        :  select distinct ordersumma0_.id as id1_1_0_, items1_.id as id1_0_1_, ordersumma0_.name as name2_1_0_, ordersumma0_.user_id as user_id3_1_0_, items1_.name as name2_0_1_, items1_.price as price3_0_1_, items1_.summary_id as summary_4_0_0__, items1_.id as id1_0_0__ from order_summary ordersumma0_ inner join item items1_ on ordersumma0_.id=items1_.summary_id where ordersumma0_.user_id=?

    Tak, to wszystko! Jedno zapytanie pobiera wszystkie dane. Możemy więc odtrąbić sukces i iść do domu, prawda?

    Prawda…?

    No, niekoniecznie. W tym przypadku ładowanie wszystkich danych do razu jest nam na rękę, to prawda. Jednak jak pisaliśmy wcześniej, lazy loading ma swoje oczywiste zalety. Do tego ładowanie danych niesie ze sobą problem iloczynu kartezjańskiego (nie będę go już w tym artykule omawiać, warto jednak zdawać sobie sprawę z jego istnienia). Uh, to dużo różnych czynników, jednak warto wiedzieć, że różne sytuacje wymagają różnych rozwiązań.

    Kiedy join będzie naszym przyjacielem? Gdy wiemy, że zawsze będziemy potrzebować wszyskich danych z zagnieżdżonych kolekcji, więc sensownie jest je załadować od razu oraz gdy takich kolekcji nie jest więcej niż jedna (ze względu na wspomniany proble iloczynu kartezjańskiego).

    Rozwiązanie 2: Ładowanie z użyciem drugiego zapytania

    Zobacz kod na moim GitHubie

    Dzięki użyciu dyrektywy fetch join problem N+1 stał się niewyraźnym już wspomnieniem. Z zadowoleniem obserwujemy naszą aplikację, która pobiera wszystkie dane dotyczące zamówienia i składających się na nie pozycji za jednym zamachem, jedynie raz odpytując bazę danych.

    Nasza aplikacja jednak się rozrasta i zaczęliśmy używać naszej klasy OrderSummary w zupełnie nowym kontekście, gdzie nie potrzebujemy informacji znajdujących się w klasie Item. Zaczynamy dostrzegać niedociągnięcia naszego podejścia i nieco tęsknić za lazy loadingiem, który przecież również się przydaje. Pamiętamy o koszmarze, jakim był problem N+1, jednak ładowanie tylu niepotrzebnych danych… Gdyby dało się znaleźć jakiś kompromis i używać leniwego ładowania bez N+1 zapytań…

    Jeśli znasz SQL-a, to bez problemu wyobrazisz sobie takie rozwiązanie. Możemy w pierwszym zapytaniu pobrać wyłącznie informacje o zamówieniu, a w drugim – o wszystkich pozycjach. Nie jest to zbyt trudne.

    Okazuje się, że Hibernate oferuje nam taką możliwość. Wróćmy do adnotacji @Fetch .

    @OneToMany()
    @Fetch(FetchMode.SUBSELECT)
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    Poprzednie podejście do jej użycia było nieudane i musieliśmy sięgać po inne rozwiązania. Jak będzie tym razem? Przekonajmy się!

     org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?
    
     org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (select ordersumma0_.id from order_summary ordersumma0_ where ordersumma0_.user_id=?) 

    Działa! To dokładnie to, czego potrzebujemy w wielu przypadkach. Mamy leniwie ładowaną kolekcję, a przy tym nie mamy problemu N+1 ani problemu iloczynu kartezjańskiego. Win-win.

    Kiedy subselect powinien być naszym wyborem? Prawdopodobnie będziemy zadowoleni z takiego rozwiązania, jeśli chcemy mieć lazy loading i nie chcemy zawsze ładować od razu wszystkich danych, a jednocześnie wiemy, że jeśli już ładujemy dane w zagnieżdżonej kolekcji, to będziemy zawsze używać ich wszystkich.

    Rozwiązanie 3: Ładowanie danych porcjami

    Zobacz kod na moim GitHubie

    Hibernate oferuje jeszcze jedną ciekawą opcję, która jest już dużo bardziej sytuacyjna. Spójrzmy na poniższy kod:

    @OneToMany()
    @Fetch(FetchMode.SELECT)
    @BatchSize(size = 20)
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    Jeśli przy czytaniu poprzedniego punktu zastanawiałeś się, czy możemy poprosić Hibernate’a o załadowanie tylko części danych – tak, da się i oto właśnie sposób. Przy powyższych ustawieniach pozycje będą ładowane „paczkami”. Przy zapytaniu o pozycje z pierwszego zamówienia, zostaną od razu załadowane wszystkie pozycje z zamówień 1-20. Kiedy już przez nie przeiterujemy i odniesiemy się do zamówienia 21, Hibernate załaduje treść kolejnych 20 zamówień. I tak dalej.

    Zobaczmy jak zadziała kod podobny do powyższego (jedynie zmniejszymy rozmiar „paczki danych” do 2, żeby łatwiej nam było zobaczyć działanie na naszym przykładzie).

    @BatchSize(size = 2)
     org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?

    org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (?, ?)
    binding parameter [1] as [BIGINT] - [1]
    binding parameter [2] as [BIGINT] - [2]

    org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (?, ?)
    binding parameter [1] as [BIGINT] - [3]
    binding parameter [2] as [BIGINT] - [4]

    org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id=?
    binding parameter [1] as [BIGINT] - [5]

    Przykładem, kiedy takie podejście się sprawdzi, jest na przykład stronicowanie. Jeśli na jednej stronie jest 20 wyników, a następne pojawiają się dopiero po naciśnięciu przycisku „Następna strona”, @BatchSize sprawdzi się doskonale.

    Zwróćmy jednak uwagę, że jeśli będziemy chcieli załadować wszystkie pozycje, wciąż mamy problem N+1, tylko na mniejszą skalę (można powiedzieć, że jest to teraz problem (N/pageSize)+1. Jest to więc rozwiązanie, które sprawdzi się jedynie w bardzo konkretnych sytuacjach.

    Problem N+1 – Podsumowując

    Teraz mamy w swoim arsenale cztery główne podejścia. Podsumujmy je:

    Leniwe ładowanie z osobnymi zapytaniami (domyślne) – Lazy loading
    – Problem N+1
    Ładowanie danych paczkami – Lazy loading
    – Wciąż problem N+1, choć mniej zapytań
    Ładowanie za pomocą joina (wszystko w jednym zapytaniu) – Problem N+1 wyeliminowany – brak lazy loadingu
    – problem iloczynu kartezjańskiego
    Ładowanie za pomocą subselecta (dwa zapytania) – Problem N+1 wyeliminowany
    – Nadal mamy lazy loading
    – dodatkowe zapytanie w stosunku do joina

    Każde z rozwiązań ma swoje plusy i minusy. Każde z nich będzie najlepsze w pewnych konkretnych sytuacjach i warto sobie zdawać sprawę, w jakich. Osobiście uznaję subselect za całkiem dobry kompromis w wielu sytuacjach, jego wadą jest to, że nie jest to feature JPA, a Hibernate’a, więc jeśli używamy innego ORM-a, to niekoniecznie mamy to do dyspozycji.

    Co dalej?

    Kiedy już znamy możliwe rozwiązania i wiemy, jak je zaimplementować, możemy pójść nieco dalej. Moglibyśmy chcieć stosować w ramach jednej aplikacji różne podejścia do ładowania danych w sposób dynamiczny. Na przykład, czasem wiemy, że będziemy potrzebowali wszystkich danych i wtedy użyjemy joina lub subselecta, a czasem jedynie ich części i to nie od razu. Wtedy podeście z osobnymi zapytaniami może się okazać najlepsze. JPA i Hibernate również oferują taką możliwość i w przyszłości z pewnością opiszę to w szczegółach.

    Jeśli czujesz, że dynamiczne podejście to coś, czego potrzebujesz, proponuję zapoznać się na przykład z tym artykułem, który opisuje zastosowanie Entity Graphów, czyli kolejnego ciekawego feature’a JPA.

  • Gra w programowanie – Motywacja i produktywność w jednym

    Siedzę i wpatruję się skupiony w ekran. Jestem absolutnie pochłonięty tym, co robię. Nie wiem nawet, ile czasu minęło, od kiedy zacząłem. Godzina, dwie? Chyba straciłem poczucie rzeczywistości…

    “Marcin, masz minutę?” — głos mojego kolegi, chociaż niezbyt głośny, brzmi dla mnie w tym momencie jak odgłos młota pneumatycznego albo startującego samolotu. A to dlatego, że dosłownie sekundę wcześniej byłem w zupełnie innym świecie. W świecie problemów do rozwiązania. Albo by być bardziej precyzyjnym — Problemu do rozwiązania. Ponieważ w tym momencie dla mnie istniał tylko jeden Problem. Ja i Problem — można powiedzieć, że nosiło to pewne znamiona romantycznego zbliżenia, bo czułem, że właśnie dobieram się swojemu Problemowi do tyłka. Już miałem wszystko poukładane w głowie, wystarczyło przelać moje myśli na kod. I właśnie wtedy głos kolegi…

    Kolega zdecydował, że Mój Problem nie jest ani w ułamku tak ważny jak Jego Problem. I uznał, że może przerwać moje wysiłki. Z jego perspektywy, nic wielkiego. Zadał pytanie, odpowiedziałem, całość zajęła może pół minuty i wróciłem do pracy. Z mojego punktu widzenia, to było zdarzenie, które być może zdecydowało o tym, że nie udało mi się dowieźć ważnej funkcjonalności na czas.

    Jak to możliwe, że tak banalne zdarzenie okazało się mieć tak doniosłe znaczenie dla mojej wydajności? I jak udało mi się użyć pewnej techniki, aby stać się nie tylko znacznie bardziej wydajnym, ale też czerpać dużo więcej radości ze swojej pracy?

    Trochę wcześniej…

    Siedzę i wpatruję się skupiony w ekran. Jestem absolutnie pochłonięty tym, co robię. Nie wiem nawet, ile czasu minęło, od kiedy zacząłem. Godzina, dwie? Chyba straciłem poczucie rzeczywistości…

    “Marcin, masz minutę?” — aż podskakuję na krześle, słysząc te słowa. Odwracam się. “Ile już grasz? Wyłącz ten komputer i zajmij się lekcjami!” “Mamo! Kiedy tu weszłaś? Nie mogę teraz skończyć, walczę z końcowym bossem!” — patrzę błagalnym wzrokiem. “Dosyć gadania, wyłączaj!


    Grałeś kiedyś w naprawdę wciągającą grę wideo? Pewnie tak. Kiedy jesteś zanurzony w grze, czas i świat dookoła przestaje istnieć. Jesteś skupiony i gotowy poświęcić wiele, by osiągnąć swoje cele. Niektórzy są w stanie nawet poświęcić niektóre potrzeby fizjologiczne… nie wchodźmy może w ten temat…

    Cóż, granie pewnie nie jest najbardziej produktywną rzeczą, jaką możesz robić w swoim życiu (chyba że jesteś profesjonalnym, wygrywającym międzynarodowe turnieje gamerem, wtedy… może?). Jednak wciąż, mechanizm, któremu ulega gracz siedzący przed ekranem przez wiele godzin, jest naprawdę potężny i… użyteczny. Można go wykorzystać w wielu obszarach życia. Ten mechanizm został opisany jako Flow, czyli Przepływ (przyznam, że angielska nazwa dużo bardziej przypadła mi do gustu).

    Flow

    Jeśli kiedykolwiek grałeś w jakąś grę przez cały dzień lub próbowałeś rozwiązać jakiś problem w pracy i po prostu straciłeś poczucie czasu albo na przykład układałeś puzzle i nawet się nie zorientowałeś, ile czasu minęło, prawdopodobnie doświadczyłeś stanu Flow. To stan, w którym jesteś naprawdę zanurzony w to, co robisz. W którym tracisz poczucie czasu i zapominasz o swoich codziennych problemach.

    Pomysł pochodzi z książki Flow: Psychologia optymalnego doświadczenia, której autorem jest Mihaly Csikszentmihalyi. Opisuje on ten stan następująco:

    Człowiek staje się tak zaangażowany w to, co robi, że aktywność staje się spontaniczna, prawie automatyczna. Przestaje być świadomy samego siebie jako osobnego bytu, czegoś oddzielnego od tego, co robi.

    Dlaczego wspomniałem o grach wideo? Są one naprawdę dobre w stymulowaniu Przepływu. Naprawdę… cholernie dobre. Tak właściwie, to są one tworzone dokładnie w tym celu, by pozwalać nam wejść w ten stan.

    Jeśli jesteś zainteresowany szczegółami, odsyłam do książki, jest to naprawdę dobra i ważna lektura. Co musisz wiedzieć w tym momencie, to istnieją trzy najważniejsze składniki pozwalające na osiągnięcie Flow:

    • Jasny cel
    • Natychmiastowa informacja zwrotna
    • Wyzwanie dostosowane do kompetencji

    Jeśli jeszcze raz spojrzysz na gry komputerowe, łatwo zauważysz, że te trzy warunki są spełnione w każdej dobrej grze. To dlatego są one tak wciągające.

    Masz jasny cel — rozgrywka narzuca Ci pewne zrozumiałe zadania do wykonania. Zabij potwora, znajdź pokemona, zdobądź miasto.

    Gdy Ci się udaje, zazwyczaj od razu dostajesz o tym informację i jakąś formę nagrody za to osiągnięcie — przedmioty pozostawione przez potwora, punkty doświadczenia, pieniądze. To jest natychmiastowa informacja zwrotna.

    Co więcej, gry zwykle starają się dopasować swoją trudność do gracza. Pomijając już poziomy trudności, zwykle zaczynamy rozgrywkę od samouczka, później dostajemy proste zadania. A podczas gdy uczysz się rozgrywki, wyzwania stawiane przed Tobą są coraz większe.

    Czy z takim interfejsem praca nie byłaby dużo ciekawsza?

    To właśnie te trzy elementy w połączeniu czynią gry naprawdę pochłaniającymi.

    Potraktuj pracę jako grę!

    Te same mechanizmy, które działają na nasz umysł doskonale, by utrzymać naszą uwagę przed monitorem, podczas grania w ulubioną grę, działają dokładnie tak samo w każdym momencie (o ile spełnione są wspomniane warunki)

    Granie w gry pewnie nie jest Twoim celem życia (mam nadzieję…). Jednak może jesteśmy w stanie nauczyć się czegoś od twórców gier i użyć tej wiedzy w naszej codziennej pracy (a może nie tylko)? Skoro gry są tak dobre w utrzymywaniu naszej uwagi, to czemu nie… potraktować naszej codziennej pracy jak gry!

    Więc, jak to osiągnąć?

    Jasny cel

    Jeśli grałeś kiedykolwiek z jakąkolwiek dłuższą grę, pewnie zauważyłeś pewne prawidłowości w tym, jak jesteśmy przez nią prowadzeni. Ostatecznym celem jest pokonanie największego wroga, odnalezienie kogoś czy podbicie świata. Jednak nigdy gra nie rzuca nam tego wyzwania już na samym początku i nie zostawia z tym samych sobie.

    W większości gier mamy do czynienia z czymś na kształt systemu misji. Musimy je “odhaczyć”, co prowadzi nas powoli do ostatecznego zadania. Przy okazji dostajemy też jakieś nagrody. Chęć uzyskania kolejnej nagrody trzyma nas przy ekranie, nawet jeśli robimy aktualnie coś bardzo nudnego.

    W taki sposób przez cały czas wiemy, co mamy robić. Wykonywanie mniejszych zadań pozwala nam nie pogubić się w ogromie możliwości, cały czas mieć jakiś cel w zasięgu wzroku i móc się cieszyć z sukcesów od samego początku rozgrywki. Te małe zwycięstwa sprawiają, że bardziej się angażujemy. Wyobraź sobie, że aby wygrać, musisz najpierw przemierzać wirtualny świat przez wiele godzin, nie mając po drodze wiele do roboty. Brzmi jak przepis na baaardzo nudną grę…

    Mniejsze zadania, jak misje w grach, pozwalają nam cały czas mieć jakiś cel w zasięgu wzroku

    Podobnie jest w przypadku pracy, gdzie idealnie jest mieć niewielkie zadania, których wykonanie to bardziej kwestia godzin, niż dni. Taki drobny podział pozwala na uchwycenie całości tego, co mamy do zrobienia i koncentrację na rozwiązaniu obecnego problemu, co sprzyja wejściu w stan Flow. Problem w tym, że taki podział jest często utopią. W praktyce często to, nad czym pracujemy, jest bardzo duże, albo nie do końca na początku zdefiniowane. Można grzmieć na Product Ownera, który źle podzielił pracę zespołowi, a można próbować radzić sobie z tym samemu.

    Świetnie, jeśli user story lub zadanie, za które się zabieramy, jest już podzielone na mniejsze subtaski. Jednak nawet jeśli tak nie jest, warto podzielić je sobie samemu na własne potrzeby. Można stworzyć własną checklistę i odhaczać sobie poszczególne zadania.

    Podzielenie zadania na mniejsze punkty do odhaczenia pozwala nam nie pogubić się w działaniach

    Najlepiej jeśli jedno zadanie nie jest przewidziane na więcej niż godzina, dwie. Wtedy w każdym momencie pracy mamy na horyzoncie osiągnięcie jakiegoś małego celu, co ogranicza ryzyko błądzenia bez celu — z moich doświadczeń jednego z większych zabójców produktywności.

    Natychmiastowa informacja zwrotna

    Pamiętasz sytuacje, gdy napisałeś fragment kodu i, żeby upewnić się czy działa, musiałeś przebudować i postawić na nowo całe środowisko testowe? Gdy musiałeś czekać kilka minut, patrząc tępo w ekran i czekając na efekt uruchomienia jakiegoś skryptu?

    Jesteśmy niecierpliwi i jeśli musimy czekać więcej niż kilka sekund na ocenę naszych starań, to zabija naszą produktywność, bo od razu nasza uwaga zaczyna odpływać. Przypomnijmy sobie, jak działają gry — tam informacja o rezultatach naszych działań i ewentualna nagroda są natychmiastowe.

    Dlatego właśnie uwielbiam TDD. Piszę linię kodu, odpalam przygotowany wcześniej zestaw testów… i natychmiastowy werdykt: działa albo nie. Od razu mogę wrócić do pracy i poprawić kod albo napisać następny test. Złapałem się nawet na tym, że zielona ikonka obok nazwy testu w IntelliJ-u działa na mnie jak nagroda. Zupełnie jak punkty doświadczenia po zabiciu potwora. Strzał dopaminy, który daje mi motywację do dalszej pracy w oczekiwaniu następnych nagród.

    Zdarza mi się napisać z góry sporą liczbę testów, które dany fragment kodu ma przejść. Potem staram się po kolei spełniać kolejne wymagania i patrzę, jak wyniki testów stopniowo zmieniają kolor z czerwonego na zielony. Takie podejście potrafi być naprawdę satysfakcjonujące. Swoją drogą, czy widzisz tutaj także pewien mechanizm występujący powszechnie w grach?

    Pasek postępu typowy dla gier RPG
    Pasek postępu typowy dla gry w programowanie

    Trochę więcej o testowaniu możesz przeczytać również w moim wpisie:

    Więcej niż Given-When-Then

    Wyzwanie

    Patrzę do backlogu, by wyjąć z niego nowe zadanie. Spoglądam na pierwszego taska i… opadają mi ręce. Kolejne zadanie polegające na dodaniu checkboxa na UI-u, kolejne kopiuj-wklej. Cóż, to się zdarza i takie sytuacje skutecznie zabijają zapał do pracy.

    Tego typu zadania niekiedy zajmują mi więcej czasu niż te dużo bardziej skomplikowane. Po prostu nie mogę się zebrać w sobie, by wziąć się do roboty.

    Wyzwanie sprawia, że mam chęć do zmierzenia się z nim. Jeśli takiego wyzwania nie widzę, pozostaje bezmyślne klepanie, które przyjemne nie jest, więc odsuwam to od siebie tak długo, jak mogę. Unikanie za wszelką cenę banalnych zadań może nie przejść i czasem coś takiego nam się trafi.

    Jednak dużo zależy od naszego podejścia. Niekiedy wyzwanie możemy sobie stworzyć sami. Można potraktować proste zadanie jako okazję, by dokonać dawno proszącego się refactoringu w klasie, w której mamy dokonać zmiany. Może istnieje przestrzeń do poprawy wydajności kodu?

    Jeśli spojrzymy tylko odrobinę poza kawałek funkcjonalności, nad którą pracujemy, często widać dużo pola do poprawy.

    Programiści mają tendencję do narzekania na brak czasu na refactoring. Takie zadania to idealna okazja, by wykonać je błyskawicznie i mieć trochę czasu na zmianę, do której od dawna się przymierzaliśmy. Nie tylko stajemy się w ten sposób lepszym programistą, ale tworzymy wyzwanie tam, gdzie pozornie go nie ma i sprawiamy, że praca staje się bardziej wciągająca.

    Co jest jednak istotne, wyzwanie nie może być zbyt duże. Problem pojawia się w momencie, gdy zaczynamy mieć poczucie, że nie jesteśmy wystarczająco kompetentni do wykonania zadania. Zabija to Flow jeszcze skuteczniej niż brak wyzwania. Znalezienie odpowiedniej równowagi jest kluczowe.

    Co jednak, jeśli konieczność zmierzenia się z pozornie zbyt dużym wyzwaniem jest nieuniknione? Można zacząć od prostszej części. Odhaczając coś z naszej listy do zrobienia, nadajemy naszej działalności pęd i zabranie się do tej trudniejszej części może być prostsze.

    Jeśli jednak już ostatecznie utknąłeś, nie wahaj się, tylko szukaj pomocy u kogoś, kto może znać rozwiązanie. W szczególności nie próbuj rozwiązywać koła na nowo, jeśli wiesz, że ktoś już wcześniej zmierzył się z podobnym wyzwaniem.

    W ostateczności, jeśli nie ma innego wyjścia, a jest to możliwe, możesz nawet oddać to zadanie komuś innemu, kto akurat w tym poradzi sobie lepiej. Nikt nie lubi przyznawać się do porażki, jednak uwierz mi, ugrzęźnięcie na wiele tygodni w zadaniu, które pogrąża nas w frustracji, jest dużo gorsze.

    Poszukiwania

    Sposoby, które podałem działają doskonale dla mnie. Możliwe, że w przypadku niektórych z Was skuteczniejsze okażą się inne techniki. Warto poszukać swoich własnych rozwiązań. Pamiętaj jedynie, że muszą one wspierać któryś z tych trzech elementów.

    Na przykład możesz zapewnić sobie lepszą informację zwrotną przez używanie narzędzi do analizy jakości kodu, jak choćby Sonar, by od razu wiedzieć, jak dobre oprogramowanie piszemy. W ten sposób możesz sobie też zapewnić wyzwanie, celując w napisanie kodu bez żadnych nowych brzydkich zapachów 🙂

    Jak widać, jeden sposób może działać na więcej niż jeden element. Tego typu pomysły można mnożyć i na pewno niektóre z nich już stosujesz, niekoniecznie zdając sobie z tego sprawę. Jeśli właśnie zdałeś sobie z tego sprawę, napisz w komentarzu, jaki to sposób i jak pomaga Ci w wciągnięciu się w pracę (z którym z trzech elementów się wiąże).

    Ostatni składnik

    Trzy składniki wymienione wyżej powinny pomóc Ci w wejściu w stan Flow. Jednak pamiętasz historię z początku artykułu, o koledze, który przerwał mi moją pracę? Wchodzenie w stan Przepływu jest niełatwe i może zająć Ci wiele minut (czasem nawet godzin), jednak utracenie go to kwestia niekiedy paru sekund. Tak było też wtedy — krótka zmiana kontekstu okazała się bardzo kosztowna dla mojej produktywności.

    Naszą uwagę atakują w każdej chwili tysiące różnych bodźców. Koledzy w pracy, artykuły w Internecie i oczywiście najwięszy złodziej uwagi…

    CallOnTheGo Mobile auto dialer app Call Center http://www ...

    Oj, telefon to straszliwa bestia, jeśli idzie o kradzież czasu i koncentracji…

    Walka przeciwko rozpraszaczom jest naprawdę ciężka. Jesteśmy przyzwyczajeni do sprawdzania powiadomień na telefonie co kilka minut, plotkowania ze współpracownikami i częstego czytania newsów. Doszliśmy do takiego punktu, że utrzymanie uwagi przez wiele godzin jest prawdopodobnie poza zasięgiem większości z nas.

    Jednak choć nie każdy jest w stanie utrzymać skupienia przez dwie godziny, prawdopodobnie każdy jest zdolny do skupienia się przez krótszy okres czasu. Na przykład 25 minut. To nie tak długo, prawda?

    Technika pomodoro jest chyba najpopularniejszym modelem przerywanej pracy w skupieniu, pewnie jest znana części z was. Polega ona na nastawieniu minutnika (w przypadku autora techniki to urządzenie miało kształt pomidora, stąd jej nazwa) na mierzenie określonego czasu — zwykle jest to 25 minut. I przez te 25 minut pracujemy tylko nad jednym zadaniem.

    Bycie skupionym kilka godzin to brzmi dziś jak coś niemożliwego. Ale już 25 minut…

    Po prostu usiądź i zajmij się robieniem tego, co sobie wyznaczyłeś. Nie szukaj sobie pracy, nie skacz od jednej sprawy do drugiej, nie sprawdzaj powiadomień na telefonie, pełne skupienie na jednej rzeczy. Jeśli coś Cię rozproszy lub coś Ci przyjdzie do głowy, po prostu to zapisz na kartce i wróć do zadania. Wrócisz do tego, gdy minie czas.

    Po 25 minutach nie zapomnij, żeby zatrzymać się i zrobić sobie przerwę, nawet jeśli czujesz (a to zdarza mi się często), że idzie Ci tak dobrze, że nie chcesz przerywać. Technika wydaje się trywialna, ale konsekwentnie stosowana, naprawdę sprawia, że szybciej niż się spodziewaliśmy, robota jest zrobiona.

    Ja używam banalnie prostego minutnika na stronie tomato-timer.com. Otwórz ją na nowej karcie i wróć do niej, gdy już w pełni skupienia dokończysz czytanie, na razie jeszcze chwila koncentracji! 🙂

    Nie jest to artykuł o technice Pomodoro, jednak to co napisałem, to esencja, która pozwoli Ci wystartować i już daje Ci pewnie 90% korzyści, jakie możesz z niej wyciągnąć. Jeśli chcesz wejść w temat głębiej, w internecie łatwo znajdziesz mnóstwo artykułów rozkładających ją na czynniki pierwsze i doradzających, jak można wykorzystywać ją jeszcze lepiej.


    Warto zwrócić uwagę, że przytoczone wyżej sposoby nie tylko pozwalają nam być bardziej produktywnymi pracownikami. Wręcz uważam, że to mniej ważna z korzyści. Podejście do pracy jak do gry i wchodzenie w stan Flow nie tylko pozwala nam lepiej pracować. Dzięki temu zwyczajnie czerpiemy więcej radości z pracy. Wymaga to trochę poświęcenia, jednak efekty są naprawdę satysfakcjonujące.

    Złóżmy wszystko w całość

    Przypomnijmy jeszcze raz, aby zwiększyć swoje szanse na osiągnięcie Flow:

    • Wyznacz sobie jasny cel i skup się tylko na nim
    • Zapewnij sobie szybką informację zwrotną
    • Dopasuj wyzwanie do swoich kompetencji

    Do tego, aby z użyźnionej przez powyższe składniki gleby wykiełkował nam optymalny stan, potrzebujemy okresu skupionej pracy, w czym doskonale pomaga Pomodoro.

    Jak już wspominałem, warto szukać własnych technik. Nikt nie mówi, że na przykład Pomodoro musi być najlepsze dla Ciebie, choć na pewno jest dobre na start. Z czasem sam poczujesz, czy lekka zmiana podejścia nie będzie korzystna.

    Jeśli dla Ciebie lepiej niż to, o czym pisałem działa co innego, koniecznie podziel się tym z innymi w komentarzach. Przyglądanie się swojemu działaniu i zmienianie go ma wielką wartość. Ostatecznie nawet z szukania sposobów na osiągnięcie Flow można uczynić… grę.