1. Umieszczanie kodu startowego
Każdy deweloper prędzej czy później staje przed pokusą zrobienia „małej rzeczy przy starcie". Na przykład wypisania krótkiego podsumowania, sprawdzenia, czy aplikacja uruchomiła się w odpowiednim trybie, albo przygotowania cache w pamięci. W konsolowych aplikacjach Java zazwyczaj rękę ciągnie, żeby napisać to bezpośrednio w main(String...args). W Spring Boot szybko staje się to niewygodnym i ciężkim w utrzymaniu stylem: main() zamienia się w wysypisko śmieci, a logika startu zaczyna kolidować z tym, jak Boot zarządza cyklem życia i zależnościami.
Powiedzmy sobie szczerze: main() w projekcie Boot to bardziej „przycisk włączenia" niż „miejsce, w którym żyje logika biznesowa". Jeśli zaczniesz pisać w main() prawdziwy kod startowy, bardzo szybko pojawią się trzy typowe problemy.
Pierwszy problem polega na tym, że kod w main() prawie zawsze zaczyna tworzyć zależności ręcznie. Albo piszesz new (i tracisz DI), albo próbujesz „jakoś wyciągnąć bean" z kontekstu (i już to trąci dziwnością). Drugi problem polega na tym, że main() słabo się testuje i słabo czyta: powinna być prosta, bo inaczej każdy nowy deweloper będzie czytać twój start jak kryminał. Trzeci problem polega na tym, że main() wykonuje się zanim aplikacja się porządnie złożyła: wiele rzeczy (szczególnie te zarządzane przez Spring) nie jest jeszcze w stanie, w którym możesz je wygodnie używać.
Jeśli potrzebujesz obrazu, to main() to nie kuchnia ani bar, to wyłącznik światła przy wejściu. Nacisnąć – ok. Gotować barszcz na wyłączniku – to już trochę dziwne.
2. Runner jako hook startu
Żeby nie zamieniać main() w „pilot do sterowania wszystkim", Spring Boot daje bardzo zrozumiały i bezpieczny mechanizm: runners. Runner to komponent zarządzany przez Spring (czyli zwykły bean), który Boot wywołuje raz przy starcie aplikacji, kiedy kontekst jest już złożony i zależności można normalnie wstrzykiwać. To jak „ostatni krok przed powiedzeniem: aplikacja gotowa".
Ważne, żeby złapać ideę: runner to nie „alternatywny main". To właśnie hook (haczyk) w cyklu życia, do którego wygodnie wrzucić krótką logikę startową. W odczuciu przypomina to „po tym, jak wszystko wystartowało – zrób jedną małą i zrozumiałą rzecz na start".
Wizualnie sekwencja wygląda mniej więcej tak:
flowchart TD
A[JVM startuje] --> B["main(String... args)"]
B --> C["SpringApplication.run(...)"]
C --> D[Tworzenie ApplicationContext]
D --> E[Tworzenie beanów i wstrzykiwanie zależności]
E --> F["Wywołanie runners (1 raz)"]
F --> G[Aplikacja uważana za gotową]
Kluczowy moment: runner uruchamia się po tym, jak Spring złożył graf zależności. Znaczy to, że runner można pisać jak normalny komponent Spring z constructor injection: może otrzymać serwisy, repozytoria, ustawienia – wszystko, co istnieje w kontekście. Ale (i to ważne) runner powinien pozostać krótki i przewidywalny: to część startu, a start to miejsce, gdzie wszystko powinno być maksymalnie zrozumiałe, inaczej będziesz „debugował uruchomienie" zamiast pisać kod.
W Spring Boot są dwa bliskie interfejsy, które rozwiązują to zadanie: CommandLineRunner i ApplicationRunner. Zaraz dokładnie rozgraniczymy ich role.
3. CommandLineRunner: String... args
CommandLineRunner to najprostszy i najbardziej bezpośredni startup hook. Przydaje się w sytuacjach, gdy albo w ogóle nie obchodzą cię argumenty uruchomienia, albo wystarczy ci je dostać jako surową tablicę ciągów, bez „mądrego rozkładania". Dla początkującego dewelopera to często najlepszy start: prostszy podpis, mniej nowych pojęć, łatwiej wyczuć, że runner naprawdę uruchamia się we właściwym miejscu.
Poniżej znajdziesz kilka małych wariantów tego samego startup hooka. Nie trzeba trzymać ich wszystkich jednocześnie w catalog-service: to proby edukacyjne, żeby poczuć mechanikę i wybrać odpowiednią formę.
Minimalny przykład w catalog-service
Wyobraź sobie, że przy starcie chcemy po prostu zobaczyć: aplikacja dotarła do fazy runner i ile argumentów zostało jej przekazanych. Utwórzmy komponent (bean), który implementuje CommandLineRunner.
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class RawArgsRunner implements CommandLineRunner {
@Override
public void run(String... args) {
// Boot wywołuje tę metodę raz przy starcie, gdy kontekst jest już podniesiony
System.out.println("Raw args count = " + args.length); // Raw args count = 0
}
}
Zwróć uwagę na dwie rzeczy. Po pierwsze, to zwykły komponent Spring (@Component), więc nie trzeba go ręcznie tworzyć. Po drugie, run(...) dostaje String... args – te same argumenty, które JVM przekazuje do main(String[]args), a Boot dalej przepuszcza do środka uruchomienia.
Jeśli uruchomisz aplikację bez argumentów, otrzymasz 0. Jeśli dodasz kilka argumentów (np. w IDE w polu Program arguments), zobaczysz inną liczbę. Na razie nie będziemy zamieniać aplikacji w kombajn CLI, ważne, żeby poczuć mechanizm.
Alternatywa: deklaracja runnera przez @Bean
Czasem runner jest na tyle mały, że osobna klasa wydaje się „zbyt uroczysta". Można go zarejestrować jako bean w klasie konfiguracyjnej przez @Bean. To ten sam mechanizm, tylko inny sposób rejestracji.
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StartupConfiguration {
@Bean
CommandLineRunner helloRunner() {
// Wygodny wariant dla bardzo krótkiej logiki startowej bez osobnej klasy
return args -> System.out.println("Hello from runner"); // Hello from runner
}
}
To nie jest „lepiej" ani „gorzej" – to kwestia wygody. W projekcie edukacyjnym zazwyczaj łatwiej zacząć od osobnej klasy: lepiej się czyta i łatwiej rozszerza. Ale warto wiedzieć, że wariant z @Bean istnieje i działa. Jeśli wybierasz ten wariant, poprzednia klasa zwykle po prostu nie jest potrzebna.
4. ApplicationRunner: ApplicationArguments
ApplicationRunner rozwiązuje to samo zadanie co CommandLineRunner: daje ci miejsce na jednorazową logikę startu. Różnica polega na tym, że zamiast surowych ciągów dostaje ApplicationArguments – obiekt, który przechowuje argumenty uruchomienia i daje wygodniejsze API do ich odczytu. Jest to szczególnie przydatne, gdy chcesz odróżniać „flagi" od „wartości pozycyjnych" i nie pisać własnego ręcznego parsera na dziesięć ifów i dwa split("=") (co zwykle kończy się łzami i dziwnymi bugami).
Nie potrzebujemy tu całego API ApplicationArguments. Wystarczy zobaczyć sam fakt: runner może dostawać nie tylko String..., ale i bardziej ustrukturyzowany obiekt.
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SourceArgsRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// getSourceArgs() – to „surowa" oryginalna tablica ciągów, która przyszła do aplikacji przy uruchomieniu
int count = args.getSourceArgs().length;
System.out.println("Source args count = " + count); // Source args count = 0
}
}
Ten przykład też zazwyczaj zastępuje poprzedni probe, jeśli potrzebujesz właśnie ApplicationArguments, a nie żyje obok niego dla kolekcji runnerów.
Ten przykład jest celowo „prawie taki sam" jak w CommandLineRunner. Jest po to, żebyś zobaczył: mechanizm jest ten sam, punkt startu ten sam, ale dane wejściowe to już nie zwykła tablica ciągów. Do prawdziwego odczytu opcji ApplicationArguments ma containsOption(...), getOptionValues(...) i getNonOptionArgs(), ale tu ważniejsze jest utrwalenie samego rozwidlenia: są dwa interfejsy, a różnica między nimi tkwi w formie argumentów.
Jeszcze jeden ważny punkt: ApplicationRunner zazwyczaj wygrywa z CommandLineRunner nawet wtedy, gdy nie zamierzasz od razu czytać argumentów. Po prostu dlatego, że w prawdziwym projekcie „a dodajmy jedną małą opcję uruchomienia" to typowa prośba, i jeśli już jesteś na ApplicationRunner, jesteś na to gotowy.
5. Wybór interfejsu runnera
Wybór między tymi dwoma mechanizmami jest najczęściej niepraktyczny. Jest bardzo praktyczny: na ile ważne są dla ciebie argumenty i jak bardzo chcesz je czytać „po ludzku". Wewnątrz jednego projektu można je mieszać, ale zazwyczaj zdrowiej trzymać się jednej logiki: do jednego zadania – jeden runner, i najlepiej żeby był czytelny, a nie „kombajn do wszystkiego".
| Kryterium | |
|
|---|---|---|
| Sygnatura | run(String... args) | run(ApplicationArguments args) |
| Dane wejściowe | surowe ciągi | obiekt z API do odczytu argumentów |
| Kiedy wygodny | „argumenty w ogóle mnie nie obchodzą" albo „wystarczy mi tablica ciągów" | „chcę czytać argumenty bez ręcznego parsowania" |
| Typowy poziom komfortu w projekcie | startowy, najprostszy | zazwyczaj wygodniejszy na dłuższą metę |
Teraz – kilka „życiowych" scenariuszy, w których runnery naprawdę się przydają (bez wchodzenia w przyszłe tematy). Runner sprawdza się dobrze, gdy chcesz zrobić krótkie i przewidywalne działanie przy starcie. Na przykład wypisać do konsoli „dotarliśmy do fazy runner" – to proste sprawdzenie podczas nauki. Albo zrobić małe sprawdzenie niezmiennika, od którego zależy sens działania serwisu – na przykład upewnić się, że katalog nie jest pusty.
A w roli „przy starcie uruchamiam duży proces na 30 sekund, który chodzi po sieci i czyta pliki w pięciu miejscach" runner już wygląda podejrzanie. Nie dlatego, że „nie można", ale dlatego, że pogarszasz i komplikujesz start. Dziś chcemy właśnie odwrotnie: zrobić start obserwowalnym i zrozumiałym.
Krótka zasada, która pomaga nie popełniać błędów: jeśli opisujesz runnera słowami „przy starcie aplikacja robi tę jedną rzecz, a potem normalnie działa", to jesteś na właściwej drodze. Jeśli łapiesz się na myśli „runner to prawie osobny podsystem aplikacji", to już przesadziłeś.
Styl logiki runnera w catalog-service
Kiedy piszesz runnera w projekcie edukacyjnym, bardzo łatwo przypadkowo zrobić z niego „pudełko, do którego wrzucamy wszystko niejasne". Niby logika startu – to dawajmy tu inicjalizację, sprawdzenia, drukowanie parametrów, „szybkie poprawki", i jeszcze trochę, i… oto masz klasę na 300 linii, która psuje uruchomienie przez jeden dodatkowy spację. Dlatego teraz ustalamy styl, który będziemy trzymać w catalog-service.
Po pierwsze, runner powinien być wyraźnie nazwany. Nawet jeśli jest tymczasowy. RawArgsRunner – ok do eksperymentu edukacyjnego. Do kodu projektowego lepiej coś w rodzaju StartupSummaryRunner, CatalogSeederRunner, StartupChecksRunner – tak czytelność daje ci spokój ducha. Po drugie, trzymaj runnera w logicznym miejscu. W naszym projekcie to pakiet com.example.catalogservice.catalog.bootstrap: tam będą żyć „jednorazowe" rzeczy, które biorą udział w starcie (bootstrap – to właśnie „podnieść i przygotować do życia").
Po trzecie, runner powinien robić jedną rzecz. Jeśli chcesz i drukować podsumowanie, i sprawdzać krytyczne warunki, i coś jeszcze – to powód, żeby albo podzielić na dwa komponenty, albo (częściej) zastanowić się, czy nie chowasz prawdziwego problemu architektonicznego pod dywan „kodu startowego". Zdrowy styl jest tu prosty: krótki runner, staranne sprawdzenia i żadnej „drugiej fazy aplikacji" przy starcie.
I na koniec bardzo praktyczna uwaga: runner to bean, więc jego konstruktor powinien być nudny. Konstruktor – tylko do zależności. Żadnej logiki na poziomie „a teraz coś policzmy/przeczytamy/uruchomimy" w konstruktorze. Każde działanie – w run(...), bo to właśnie mówi wprost: „oto logika startowa".
6. Typowe błędy przy pracy z runnerami
Pierwsze błędy z runnerami zazwyczaj nie wynikają ze „złego programisty", ale ze starych nawyków: aplikacje konsolowe, ręczne zarządzanie obiektami, chęć szybkiego „sprawdzenia jednej rzeczy" i zapomnienia. Ale Boot jako platforma jest bardzo wyczulony na to, gdzie umieściłeś kod – dlatego lepiej te pułapki zauważyć zawczasu.
Błąd nr 1: pisanie logiki startowej w main() i ręczne tworzenie zależności.
W ten sposób bardzo szybko zamieniasz aplikację Boot w mieszankę „trochę Springa" i „trochę własnego DI". Pojawiają się dziwne duplikaty: część obiektów tworzy Spring, część tworzysz ty. Prawidłowo – zostaw main() maksymalnie krótki i przenoś jednorazową logikę startową do runnera, który jest beanem Springa i może dostać zależności przez konstruktor.
Błąd nr 2: duplikowanie tego samego zachowania w kilku runnerach „na wszelki wypadek".
Czasem pojawiają się dwa komponenty, które oba drukują argumenty uruchomienia, oba wypisują „started", oba robią podobne sprawdzenia. To tworzy szum i zamieszanie: już nie wiadomo, który z nich jest „główny" i dlaczego komunikaty w konsoli się powtarzają. Staraj się trzymać jeden runner na jedno zrozumiałe zadanie, a tymczasowe edukacyjne runnery usuwać lub łączyć.
Błąd nr 3: chowanie logiki startowej w konstruktorze beana.
Konstruktor jest wywoływany w momencie tworzenia beana, i jeśli dodasz tam logikę, będzie się wykonywać „po drodze", bez wyraźnego miejsca w cyklu życia. To utrudnia debugowanie: widzisz błąd startu, ale nie wiadomo, w której fazie wystąpił. Niech konstruktor tylko przyjmuje zależności, a prawdziwa logika startowa żyje w run(...).
Błąd nr 4: tworzenie zbyt długiego i nieprzewidywalnego runnera.
Runner powinien być krótki, bo stoi na krytycznej ścieżce startu. Jeśli w środku zaczyna się „połączymy się z tym", „przeczytamy tamto", „policzymy to", dostajesz wolne i kruche uruchomienie. Szczególnie nieprzyjemne, gdy start bywa szybki, a bywa wolny – takie problemy potem bardzo trudno złapać. Lepiej kilka małych, jasnych działań niż jeden „potwór startowy".
Błąd nr 5: traktowanie runnera jako miejsca do „zadań w tle".
Runner wykonuje się raz przy starcie i w tym samym wątku, który kończy startup sequence. To nie jest „harmonogramista" ani „worker w tle". Jeśli potrzebujesz czegoś cyklicznego lub asynchronicznego – to zupełnie inny temat (i wyraźnie nie dzisiejszy). Tu trzymamy fokus: runnery to jednorazowa logika startowa i wczesna diagnostyka, a nie „życie aplikacji po starcie".
GO TO FULL VERSION