1. Scope w Springu: sens i lifetime
Scope często po raz pierwszy widzimy jako linijkę @Scope("prototype") i traktujemy jak przełącznik „jeden obiekt albo wiele obiektów". To prawda… ale to bardzo „boleśnie mało". W praktyce scope — to umowa między tobą a kontenerem o tym, jaki bean ma lifetime i w jakim miejscu ten bean w ogóle ma sens. Jeśli ignorujemy scopes, najczęściej dochodzimy albo do dziwnych protez („utwórzmy wszystko od nowa ręcznie"), albo do ukrytego stanu w singleton'ach (a potem dziwimy się: „dlaczego to samo się zmieniło?").
Spróbujmy zdefiniować scope „po dorosłemu", ale ludzkim językiem. Scope odpowiada jednocześnie na dwa pytania. Po pierwsze: ile instancji danego beana będzie istniało dla kontenera. Po drugie: jak długo kontener będzie uważał te instancje za „swoje" i będzie nimi zarządzał. Czyli scope — to nie kwestia urody kodu, lecz model czasu życia obiektu w aplikacji.
Żeby utrwalić obraz, warto trzymać w głowie taki schemat:
flowchart TD
A[ApplicationContext] --> B[BeanDefinition nazwa + typ + scope + metadane]
B -->|singleton| C[Jedna instancja na cały kontekst]
B -->|prototype| D[Nowa instancja przy każdym żądaniu do kontenera]
Dziś omówimy dwa podstawowe scope, których potrzebujesz już teraz: singleton i prototype. Stanowią one fundament, na którym później „osadzają się" pozostałe, w tym web-scopes.
2. Scope jako właściwość BeanDefinition
Na początku najłatwiej myśleć tak: „jest klasa AuditService, więc jest singleton". Ale Spring myśli inaczej: kontener zarządza nie klasami, lecz bean definitions. Klasa — to po prostu „co należy stworzyć". A „ile tworzyć i jak żyć" — to już metadane BeanDefinition. Dlatego tę samą klasę Java, ściśle mówiąc, można zarejestrować w kontenerze na różne sposoby (choć w prawdziwym życiu robi się to rzadko i ostrożnie).
Dlaczego to ważne? Bo znosi jedną szkodliwą iluzję: że scope — to coś w rodzaju final czy static, czyli na zawsze przyszyte do typu. Nie, scope jest wybierany w momencie rejestracji beana, a rejestracja może następować na różne sposoby: przez @Component/@Service (scanning), przez @Bean w konfiguracji, przez XML (kiedyś, bez paniki).
Wyobraź sobie codzienną sytuację. Masz „typ" — na przykład OrderProcessingSession. Może być „jednorazową kartką do jednej operacji". Albo „wspólnym dziennikiem, do którego piszemy wszystko". Typ jest ten sam (papier i papier), ale sens i czas życia są różne. W Springu wyraża się to właśnie tym, że scope — część definicji beana, a nie DNA klasy.
I jeszcze mała, ale praktyczna myśl. Gdy czytasz konfigurację projektu (a w ContextFlow chcemy umieć ją czytać), warto mentalnie zadawać pytanie: „Ten bean powinien żyć długo (jak serwis) czy krótko (jak obiekt konkretnej operacji)?" Odpowiedź na to pytanie prawie zawsze podpowiada właściwy scope.
3. singleton scope: domyślne zachowanie
Singleton — to domyślny scope. Czyli jeśli w ogóle nic nie napisałeś, Spring „po cichu" traktuje beana jako singleton. I w normalnym projekcie to… dobrze. Bo większość serwisów — to jak ekspres do kawy w biurze: jest jeden i nie robimy nowego ekspresu przy każdej okazji, po prostu korzystamy z tego samego, dopóki biuro istnieje.
W terminach kontenera singleton oznacza: w granicach jednego ApplicationContext istnieje jedna instancja danego beana, a przy każdym żądaniu kontener zwraca właśnie ją. Ważne, że to „jeden na kontekst", a nie „jeden na całą JVM, planetę i multiwersum". Już omawialiśmy różnicę między spring-singleton a GoF Singleton, więc dziś tylko krótkie przypomnienie: żadnego private static final INSTANCE Spring od ciebie nie wymaga.
Zobaczmy to w kodzie jak najbardziej wprost. Weźmy na przykład nasz serwis audytu (w ContextFlow jest jako AuditService). Domyślnie będzie singletonem:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Service;
@Service
class AuditService {
// Singleton domyślnie: kontener stworzy jedną instancję na ApplicationContext
}
class Demo {
public static void main(String[] args) {
// Uruchamiamy minimalny kontekst tylko z jednym komponentem
try (var ctx = new AnnotationConfigApplicationContext(AuditService.class)) {
// Dwa razy pobieramy ten sam bean od kontenera
AuditService first = ctx.getBean(AuditService.class);
AuditService second = ctx.getBean(AuditService.class);
// Dla singletona będzie to ten sam obiekt
System.out.println(first == second); // true
}
}
}
Tu ważne jest nie samo porównanie == (choć uczciwie pokazuje „ten sam obiekt"), lecz sens: kontener stworzył AuditService, zapamiętał go i potem zwraca tę samą instancję.
Jeszcze jeden niuans, który warto połączyć z poprzednim dniem o lifecycle. Singleton-beany (jeśli nie są lazy) zazwyczaj tworzone są przy starcie kontekstu, a przy jego zamknięciu kontener wywołuje destroy-callbacks (@PreDestroy, destroyMethod itd.). Czyli singleton ma lifecycle najczęściej „pełny": kontener nie tylko stworzył, ale i poprawnie zakończył.
4. prototype scope: nowa instancja przy każdym żądaniu do kontenera
Prototype — to drugi podstawowy scope, który naprawdę jest potrzebny w aplikacjach, gdzie istnieją krótkotrwałe obiekty. Sens prototype brzmi prosto: kontener tworzy nową instancję beana za każdym razem, gdy pobierasz tego beana od kontenera. Czyli ctx.getBean(...) zwrócił jeden obiekt, kolejny getBean(...) zwrócił inny obiekt — i to normalne, tak zamierzono.
To przypomina jednorazowy kubek w kawiarni: nie myjesz go i nie używasz ponownie „przez cały dzień", bierzesz nowy do każdej porcji. W aplikacji takimi „kubkami" bywają obiekty niosące stan jednej operacji: jednego przetwarzania zamówienia, jednego generowania raportu, jednego krótkiego kroku scenariusza.
Minimalny przykład wygląda tak:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope("prototype") // Prosimy kontener o tworzenie nowej instancji przy każdym getBean(...)
class OrderProcessingSession {
// Typowy prototype: obiekt-stan dla jednej operacji/scenariusza
}
class Demo {
public static void main(String[] args) {
// Kontekst zostanie uruchomiony i będzie znał jeden komponent
try (var ctx = new AnnotationConfigApplicationContext(OrderProcessingSession.class)) {
// Każde żądanie do kontenera tworzy nową instancję prototype-beana
OrderProcessingSession first = ctx.getBean(OrderProcessingSession.class);
OrderProcessingSession second = ctx.getBean(OrderProcessingSession.class);
System.out.println(first == second); // false
}
}
}
Teraz ważna uwaga, którą nowicjusz prawie zawsze pomija (a potem cierpi). Prototype oznacza „nowy przy każdym żądaniu do kontenera", ale nie oznacza „nowy przy każdym wywołaniu metody singleton-serwisu". Jeśli raz pobrałeś prototype i zapisałeś go w polu, przestał być „jednorazowym kubkiem" i stał się „kubkiem, który nosisz przez tydzień". Na razie ważne jest utrwalenie samej pułapki: prototype pozostaje krótkotrwały tylko wtedy, gdy za każdym razem przychodzisz po niego do kontenera.
Jest jeszcze jeden niuans, który dobrze połączyć z tematem lifecycle z poprzedniego dnia. Dla prototype-beana kontener gwarantuje tworzenie i wstrzykiwanie zależności, ale nie zarządza niszczeniem tak samo, jak dla singleton. Czyli Spring stworzył prototype, oddał ci go — i dalej jesteś dorosłym człowiekiem: jeśli są tam zasoby, musisz je zamknąć samodzielnie. W przykładach szkoleniowych zazwyczaj robimy prototype lekkim (bez uchwytów do plików i socketów), żeby nie wchodzić w tryb „kurs przetrwania".
5. Jak ustawiać scope: @Scope
Gdy po raz pierwszy widzisz @Scope("prototype"), chce się zapytać: „A czemu stringiem? Czemu nie enum? Czemu nie Scope.PROTOTYPE?" Witaj w rzeczywistości Java-enterprise, gdzie wiele rzeczy jest historycznie stringowych, i Spring tu nie jest wyjątkiem. Dobra wiadomość: tę linijkę będziesz pisał niezbyt często, bo domyślny jest singleton, a prototype potrzebny jest tylko do naprawdę krótkotrwałych rzeczy.
Scope można ustawić w dwóch popularnych miejscach. Pierwszy wariant — na komponencie, który trafia do kontenera przez scanning:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // Stała jest bezpieczniejsza niż string "prototype"
class OrderProcessingSession {
// Każde żądanie do kontenera zwróci nową instancję tego komponentu
}
Zwróć uwagę: używam stałej ConfigurableBeanFactory.SCOPE_PROTOTYPE, a nie stringa "prototype". Mniejsze ryzyko pomyłki w cudzysłowie i potem pół godziny patrzenia na aplikację, która „z jakiegoś powodu nie stała się prototype". Tak, to drobiazg. Ale właśnie z drobiazgów składa się życie.
Drugi wariant — ustawić scope na metodzie @Bean w klasie @Configuration. Przydatne gdy nie chcesz (lub nie możesz) oznaczać klasy jako komponent, albo gdy tworzenie wymaga jawnej konfiguracji:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
@Configuration
class SessionConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // Nowa instancja przy każdym odwołaniu do kontenera
OrderProcessingSession orderProcessingSession() {
// Tutaj jawnie opisujemy kontenerowi, jak tworzyć prototype-beana
return new OrderProcessingSession();
}
}
Kluczowa myśl jest tu metodyczna: znów widzimy, że scope — to cecha bean definition, a metoda @Bean jest właśnie „fabryką definicji beana". Klasa OrderProcessingSession sama z siebie nie „rodzi się prototype". Staje się prototype dlatego, że tak ją opisaliśmy kontenerowi.
Jeśli przyszedł ci do głowy pomysł, żeby napisać @Scope("singleton") — technicznie można, ale w większości projektów to zbędny szum. Singleton i tak jest domyślny, lepiej nie produkować „spam anotacyjny" dla złudzenia kontroli.
6. Scope a stan: reguła stateful/stateless
Scope staje się naprawdę zrozumiały nie w momencie, gdy zobaczyłeś @Scope, lecz gdy połączysz go z pytaniem: „Czy ten obiekt może przechowywać stan między wywołaniami?" Tu zaczynają się dorosłe rozmowy, ale trzymamy się ram pierwszego kursu, bez fanatyzmu.
Istnieje użyteczna reguła, która działa zaskakująco często: długotrwałe serwisy powinny być stateless i dlatego doskonale nadają się pod singleton. Stateless oznacza, że serwis nie przechowuje „bieżących danych operacji" w polach; dostaje dane jako parametry metody, wykonuje pracę i zwraca wynik. W ContextFlow takimi obiektami są OrderPlacementService, AuditService, ReportingService i inne serwisy orkiestracji: koordynują kroki, ale nie powinny stać się „skrzynką z bieżącym stanem".
A oto stateful obiekty, które sensownie żyją „przez jedną operację", często warto zrobić prototype. Na przykład „sesja przetwarzania zamówienia" może gromadzić pośrednie kroki, pomiary, znaczniki czasu, listę „co zostało zrobione". Nie musi żyć dłużej niż jedna operacja. I właśnie dlatego jest dobrym kandydatem na prototype.
Ale ważna granica. Prototype — nie jest obowiązkowy dla wszystkiego tymczasowego. Czasem prościej i uczciwiej jest stworzyć obiekt zwykłym new wewnątrz metody i trzymać go jako lokalną zmienną. Kontener jest potrzebny tam, gdzie obiekt jest prawdziwą zależnością, którą chcesz konfigurować/podmieniać/owijać mechanizmami Springa, lub gdzie jest logicznie częścią grafu obiektowego. Jeśli obiekt — to po prostu „tymczasowa lista trzech stringów", kontener nie musi o nim wiedzieć (inaczej Spring zamieni się w „menedżera wszystkiego", a ty — w jego zakładnika).
Żeby utrwalić różnicę, oto mała tabela, którą warto trzymać w głowie:
| Pytanie | Raczej singleton | Raczej prototype |
|---|---|---|
| Obiekt żyje tyle, co aplikacja | Tak | Nie |
| Obiekt — to serwis/infrastruktura, a nie „dane jednej operacji" | Tak | Czasem |
| Wewnątrz obiektu nie ma mutowalnego stanu powiązanego z konkretnym wywołaniem | Tak | Niekoniecznie |
| Obiekt niesie stan jednego scenariusza/operacji | Zwykle nie | Tak |
| Potrzebny „nowy stan" za każdym razem | To nie o scope, lecz o design | Często tak |
Dziś ważne, żeby nie wykuć tabeli, lecz poczuć myśl: scope — to narzędzie, które pomaga nie mylić czasu życia zachowania z czasem życia danych.
7. Mini-inkrement dla ContextFlow
Teraz zrobimy mały, uczciwy krok w kierunku naszego projektu ContextFlow. Nie będziemy jeszcze budować złożonych schematów, nie będziemy wstrzykiwać sesji do serwisów — to właśnie ten obszar, gdzie łatwo stworzyć błędne oczekiwanie. Nasze zadanie dziś jest prostsze: zaprowadzimy obiekt, który sensownie jest naprawdę „jednorazowy", nadamy mu scope prototype i upewnimy się, że kontener wyda różne instancje.
Dodamy do projektu klasę OrderProcessingSession. Niech przechowuje „kroki" przetwarzania — po prostu stringi. To wystarczająco życiowe (można potem używać do diagnostyki), a nie przeciąża projektu szkoleniowego:
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
class OrderProcessingSession {
private final List<String> steps = new ArrayList<>();
void addStep(String step) {
steps.add(step);
}
List<String> steps() {
return List.copyOf(steps);
}
}
Teraz — mała demonstracja w punkcie wejścia (to ważne: nie w serwisie biznesowym, lecz w kodzie bootstrap/demo). Po prostu poprosimy kontener o dwie instancje i sprawdzimy, że są różne:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class ScopeDemo {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(OrderProcessingSession.class)) {
var s1 = ctx.getBean(OrderProcessingSession.class);
var s2 = ctx.getBean(OrderProcessingSession.class);
System.out.println(s1 == s2); // false
}
}
}
Jeśli chcesz zobaczyć „coś bardziej namacalnego", możesz dodać kroki do pierwszej sesji i upewnić się, że w drugiej ich nie ma — bo to inny obiekt:
s1.addStep("validate");
System.out.println(s1.steps()); // [validate]
System.out.println(s2.steps()); // []
Tu na razie zrobiliśmy jedno: wprowadziliśmy do projektu kandydata na krótkotrwałą zależność i upewniliśmy się, że kontener wydaje różne instancje. To już wystarczy, żeby rozdzielić w głowie dwie role: „długo żyjące serwisy" i „stan jednej operacji" — to nie jest to samo.
8. Typowe błędy przy pracy ze scope
Błąd nr 1: myślenie, że scope — to właściwość klasy, a nie beana.
Czasem pojawia się sformułowanie „klasa OrderProcessingSession — prototype". Ale poprawniej: „bean orderProcessingSession — prototype". To nie pedanteria: gdy zaczniesz czytać konfigurację i rozumieć wiring, musisz myśleć terminami kontenera — czyli terminami bean definitions, a nie „aury klas".
Błąd nr 2: mylenie spring-singleton z globalnym GoF Singleton.
Jeśli w głowie żyje idea „singleton — czyli jeden na cały program na zawsze", łatwo zacząć projektować serwisy jakby były globalnymi statycznymi bytami. Spring tak nie działa: singleton — to jedna instancja na kontener. Kontenerów może być kilka (w testach, w osobnych modułach, w różnych runnerach) — i to normalne.
Błąd nr 3: włączanie prototype „na wszelki wypadek", bo tak bezpieczniej.
Prototype — nie jest „trybem zwiększonego bezpieczeństwa". To tryb innego czasu życia. Jeśli bez powodu robisz prototype serwis, który powinien być współdzielony, komplikujesz diagnostykę, zwiększasz liczbę obiektów i częściej trafiasz na nieoczekiwane zachowanie. Lepiej najpierw uczciwie odpowiedzieć: obiekt naprawdę niesie stan jednej operacji czy nie?
Błąd nr 4: oczekiwanie, że prototype automatycznie stanie się „nowym obiektem przy każdym wywołaniu metody" w singleton-serwisie.
To najczęstsza pułapka. Prototype jest tworzony przy każdym żądaniu do kontenera, ale jeśli pobrałeś go raz i trzymasz jako pole, przestał być „jednorazowy". Stąd pochodzi sformułowanie „prototype wewnątrz singletona nie działa" — choć w rzeczywistości działa, po prostu oczekiwanie było błędne.
Błąd nr 5: zapominanie, że kontener nie musi „niszczyć" prototype-beanów.
Po temacie lifecycle chce się wierzyć, że @PreDestroy wywoła się zawsze. Ale prototype ma inny model: kontener stworzył, oddał — i dalej ty zarządzasz czasem życia. Dlatego prototype lepiej trzymać lekkim, bez zasobów wymagających obowiązkowego zamknięcia, szczególnie w projekcie szkoleniowym.
GO TO FULL VERSION