1. Od klucza w .properties do beana
Plik jest już podłączony do Environment przez @PropertySource i na tym etapie klucze stały się częścią ogólnego obrazu konfiguracyjnego aplikacji. Teraz pytanie staje się bardzo praktyczne: jak konkretna wartość trafia do klasy, która jej używa? Tu mamy dwa codzienne szlaki — krótki @Value i bardziej jawne czytanie przez Environment.
Z zewnątrz często wygląda to tak: napisałeś klucz w contextflow.properties, postawiłeś @Value i wartość jakoś sama przyjechała. Ale Spring nie jest telepatą. Wykonuje dość zrozumiałą sekwencję kroków: zbiera źródła ustawień, tworzy Environment, znajduje klucz, podstawia wartość, a potem wstrzykuje do beana. Jeśli trzymasz ten szlak w głowie, debugowanie staje się spokojniejsze, a @Value przestaje być magiczną naklejką.
Upraszczając, ścieżka wygląda tak:
flowchart TD A["Property sources
JVM props, env vars, .properties"] --> B["Environment
przechowuje i szuka właściwości"] B --> C["Placeholder resolution
${key} -> 'value'"] C --> D["Injection point
constructor param / @Bean param"] D --> E["Bean instance
gotowy do pracy"]
Tu ważne jest uchwycenie myśli: @Value — to nie „czytanie pliku". To instrukcja dla kontenera, żeby w konkretne miejsce podstawił wartość znalezioną przez Environment i reguły placeholder resolution. Dlatego w tej lekcji będziemy patrzeć na dwa style, które są naprawdę potrzebne w codziennym życiu: krótki i wygodny @Value, oraz bardziej jawne, „wszystko widzę rękami" podejście przez Environment.
2. @Value jako krótki kabel
@Value często lubi się za kompaktowość: napisałeś jedną linijkę — i już można drukować nazwę aplikacji w konsoli, nie ciągnąc do kodu czytania plików i parsowania. Ale @Value ma charakter: jeśli stosować go bez dyscypliny, klasa zamienia się w choinkę z anotacjami, a konfiguracja — w „magiczne stringi" po całym projekcie. Dlatego naszym celem — używać @Value tak, żeby pomagało czytelności, a nie psuło jej.
Zacznijmy od najbardziej typowego przykładu: ScenarioRunner chce znać nazwę aplikacji, żeby ładnie drukować start.
package com.example.contextflow.application.scenario;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ScenarioRunner {
private final String appName;
public ScenarioRunner(
// @Value czyta wartość przez Environment i placeholder resolution
// :ContextFlow — to domyślna wartość, jeśli klucz nie jest podany
@Value("${contextflow.app-name:ContextFlow}") String appName
) {
// Iniekcja przez konstruktor czyni zależność (ustawienie) częścią kontraktu klasy
this.appName = appName;
}
public void run() {
// Wartość przyszła z konfiguracji jeszcze przy starcie aplikacji
System.out.println("Starting " + appName); // Starting ContextFlow
}
}
Tu od razu kilka „właściwych" rzeczy. Wartość jest wstrzykiwana do konstruktora, czyli staje się częścią kontraktu klasy: żeby złożyć ScenarioRunner, potrzeba nazwy. Przy tym mamy domyślną wartość ContextFlow, więc aplikacja będzie mogła startować nawet jeśli klucz zapomniano podać (choć w prawdziwym projekcie dla nazwy zazwyczaj to ok, a dla innych parametrów — niekoniecznie).
Dokładnie tak samo można wstrzykiwać dwie-trzy proste wartości do serwisu. Na przykład ReportingService może znać stringowy format raportu i kod lokalizacji (na razie stringowy — do typowanego bindingu jeszcze nie doszliśmy).
package com.example.contextflow.application.reporting;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
private final String reportFormat;
private final String defaultLocale;
public ReportingService(
// Krótki wariant: wartości czytane bezpośrednio w miejscu użycia
@Value("${contextflow.report.format:text}") String reportFormat,
@Value("${contextflow.locale.default:pl}") String defaultLocale
) {
// Zapisujemy do pól final: ustawienie powinno pojawić się raz przy tworzeniu i nie "wędrować" po obiekcie
this.reportFormat = reportFormat;
this.defaultLocale = defaultLocale;
}
}
Uwaga dla zdrowego rozsądku: @Value doskonale działa, gdy wartości jest jedna-dwie i naprawdę „żyją" blisko miejsca użycia. Gdy wartości robi się 8–10, to już sygnał: albo klasa za dużo wie, albo czas przenieść część czytania ustawień na granicę konfiguracyjną (do @Configuration), albo projektujesz nie serwis, lecz „kombajn uniwersalny".
3. Wartości obowiązkowe i domyślne
Najgorszym błędem w konfiguracji jest nie padnięcie przy starcie, lecz „ciche nieprawidłowe zachowanie". Aplikacja jakby działa, ale wysyła powiadomienia nie tam, pisze pliki nie do tego folderu, a raporty „z jakiegoś powodu znikają". Dlatego ważne jest od samego początku rozróżnianie: które klucze są obowiązkowe, a które można spokojnie zastąpić domyślną wartością. Domyślna wartość — to nie sposób na „żeby na pewno nie padało", lecz świadoma decyzja: „w tym środowisku bezpieczne jest tak".
Jeśli wartość jest obowiązkowa, piszemy bez :default. Na przykład niech domyślny kanał powiadomień będzie obowiązkowym ustawieniem (w projekcie szkoleniowym można zrobić i domyślną wartość, ale teraz warto zobaczyć fail-fast).
package com.example.contextflow.application.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final String defaultChannel;
public NotificationDispatchService(
// Bez :default — klucz jest obowiązkowy. Brak klucza -> aplikacja padnie przy starcie (fail-fast)
@Value("${contextflow.notifications.default-channel}") String defaultChannel
) {
this.defaultChannel = defaultChannel;
}
}
Jeśli klucza nie ma, kontener nie będzie mógł stworzyć beana — i padnie przy starcie. To boli dokładnie raz, za to potem nie spędzasz wieczoru na dochodzeniu „dlaczego to wysyła nie tam".
A teraz przykład, gdzie domyślna wartość jest na miejscu. Powiedzmy, że mamy limit ponownych prób wysyłki (w prawdziwym życiu logika retries jest bardziej skomplikowana, ale do ilustracji wystarczy).
package com.example.contextflow.application.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class RetryPolicy {
private final int retryLimit;
public RetryPolicy(
// Domyślna wartość 3 — świadome bezpieczne ustawienie „na wszelki wypadek"
@Value("${contextflow.notifications.retry-limit:3}") int retryLimit
) {
// Spring sam skonwertuje string z konfiguracji na int (jeśli wartość jest poprawna)
this.retryLimit = retryLimit;
}
}
Tak, tu Spring już podstawia string i konwertuje na int. Nie wchodzimy w mechanikę conversion, po prostu utrwalamy: dla prymitywów i prostych typów Spring zazwyczaj radzi sobie sam i to normalne w ramach poziomu podstawowego.
4. Gdzie stawiać @Value
Gdy po raz pierwszy widzisz @Value, ręka ciągnie do zrobienia tak, jak często robi się z @Autowired: „no dobra, wrzucę do pola, szybciej przecież". Szybciej — tak. Lepiej — nie. Field injection wartości ukrywa ustawienia, psuje ideę „konstruktor pokazuje zależności", komplikuje testowanie i zmusza klasę do bycia mutowalną. A to jeszcze idealna gleba dla sytuacji „nie rozumiem, skąd wzięła się u mnie ta wartość".
Oto przykład, który technicznie działa, ale jako teaching default się nie nadaje:
package com.example.contextflow.application.scenario;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class AppBannerPrinter {
// Iniekcja przez pole: zależność ukryta, obiekt trudniej testować, pojawia się stan mutowalny
@Value("${contextflow.app-name:ContextFlow}")
private String appName;
public void print() {
System.out.println("== " + appName + " =="); // == ContextFlow ==
}
}
Główny problem tu nawet nie w anotacji, lecz w tym, że teraz „kontrakt" klasy jest rozmyty: patrząc na konstruktor, nie rozumiesz, że ten obiekt potrzebuje ustawienia. A oto wariant, który czyta się uczciwie:
package com.example.contextflow.application.scenario;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class AppBannerPrinter {
private final String appName;
public AppBannerPrinter(
// Iniekcja przez konstruktor: od razu widać, że obiekt potrzebuje ustawienia
@Value("${contextflow.app-name:ContextFlow}") String appName
) {
this.appName = appName;
}
}
Teraz zależność/ustawienie widoczne od razu. I tak, final — to nie dekoracja, lecz ochrona przed przypadkową mutacją: „ustawienie przyszło raz przy tworzeniu i dalej nie wędruje po obiekcie jak kot po klawiaturze".
Jeszcze jeden bardzo wygodny wariant (szczególnie dla infrastruktury) — stawianie @Value na parametrze metody @Bean. Szczególnie dobrze działa w modułowych configach: na przykład ReportingConfig może być konfiguracyjnym fragmentem reporting-części, który górny AppConfig dołącza do ogólnego składania. Wtedy czytanie właściwości zachodzi na granicy konfiguracyjnej, a twoja klasa pozostaje czystym POJO bez znajomości @Value.
package com.example.contextflow.config.reporting;
import com.example.contextflow.support.lifecycle.ReportOutputManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
public ReportOutputManager reportOutputManager(
// Czytamy ustawienie na granicy konfiguracyjnej (w @Configuration)
@Value("${contextflow.report.output-dir:build/reports}") String outputDir
) {
// Do klasy biznesowej/infrastrukturalnej trafia już gotowy parametr
return new ReportOutputManager(outputDir);
}
}
Ten styl jest często bardzo „dorosły": klasy biznesowe nie wiedzą, skąd przyszły ustawienia, a konfiguracja jawnie pokazuje, które klucze są używane przy składaniu.
5. Environment: kiedy lepiej czytać jawnie
Jako mapa źródeł i precedence Environment jest sam w sobie użyteczny. Tu ważniejsze jest bardziej praktyczne pytanie: kiedy czytać wartość jawnie, a kiedy wystarczy @Value. @Value jest dobry, gdy wartości jest jedna-dwie i chcesz podstawić ją bezpośrednio w miejsce użycia. Ale czasem lepiej zrobić krok w tył i powiedzieć: „Chcę sam odczytać ustawienie, sprawdzić je, zalogować, złożyć z kilku kawałków i dopiero potem stworzyć obiekt". W tym momencie Environment staje się bardzo wygodny. Jest nieco bardziej szczegółowy, ale widzisz moment czytania i możesz zachowywać się jak dorosły inżynier, a nie człowiek z zestawem magicznych stringów.
Najprostszy przykład — klasa, która oblicza ścieżkę dla raportów. Nie wchodzimy w złożone scenariusze, po prostu czytamy string i zamieniamy na Path.
package com.example.contextflow.infrastructure.reporting;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component
public class ReportPathResolver {
private final Path outputDir;
public ReportPathResolver(Environment environment) {
// Environment — to infrastrukturalne API Springa do dostępu do właściwości
// Drugi parametr — domyślna wartość, jeśli klucz nie zostanie znaleziony
String dir = environment.getProperty("contextflow.report.output-dir", "build/reports");
// Tu jawnie robimy konwersję na Path (a nie rozrzucamy tego po kodzie)
this.outputDir = Paths.get(dir);
}
public Path outputDir() {
return outputDir;
}
}
Zwróć uwagę: Environment nie czytamy w każdej metodzie generateReport(). Czytamy raz przy tworzeniu beana i zapisujemy wynik. To utrzymuje serwisy „stateless" i przewidywalne: konfiguracja przyszła przy starcie, dalej obiekt pracuje stabilnie.
Tak samo można czytać właściwości bezpośrednio w @Configuration — często wygląda to jeszcze logiczniej, bo konfiguracja i tak odpowiada za składanie.
I tak samo CoreConfig tutaj — to nie drugi entry point aplikacji, lecz jeszcze jeden modułowy config wewnątrz ogólnego AppConfig.
package com.example.contextflow.config.core;
import com.example.contextflow.support.lifecycle.ReportOutputManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
public class CoreConfig {
@Bean
public ReportOutputManager reportOutputManager(Environment environment) {
// Czytanie właściwości zachodzi w dokładnie jednym miejscu — w konfiguracji
String dir = environment.getProperty("contextflow.report.output-dir", "build/reports");
// Na wyjściu — zwykły obiekt, nie powiązany ze Springiem
return new ReportOutputManager(dir);
}
}
I tak, Environment przychodzi tu jako zwykła zależność. Spring umie go dać, bo Environment — to część „infrastruktury kontekstu".
@Value i Environment: kryteria wyboru
W życiu nie istnieje „rób zawsze tylko tak". Ale istnieją zdroworozsądkowe kryteria, które oszczędzają czas i sprawiają, że kod jest czytelniejszy. @Value i Environment — to nie konkurenci, lecz dwa narzędzia z jednej skrzynki. Najważniejsze — nie używać gwoździa jak śrubokręta i nie robić z młotka śrubokręta.
Mała tabelka, która zazwyczaj pomaga podjąć decyzję:
| Pytanie | Częściej pasuje @Value | Częściej pasuje Environment |
|---|---|---|
| Ile wartości trzeba odczytać? | 1–2 | 3+ lub jest logika wyboru |
| Gdzie czytamy? | blisko miejsca użycia | na granicy konfiguracyjnej |
| Czy potrzebna jest jawna obsługa/logowanie? | rzadko | często |
| Czy chcemy trzymać klasy jako „czyste POJO"? | można, ale jest anotacja | wygodnie: czytanie w @Configuration |
| Czy chcemy unikać „annotation soup"? | tak, jeśli wartości jest mało | tak, szczególnie jeśli ustawień jest dużo |
Tłumacząc na język ludzki: @Value — to „krótki kabel" od konfiguracji do miejsca użycia. Environment — to „panel sterowania", gdzie możesz akaratnie zebrać zasilanie, postawić bezpieczniki i dopiero potem zasilić urządzenie.
6. Mikro-inkrement ContextFlow: config w konstruktorze
Teraz zbierzemy wszystko w mały, ale bardzo charakterystyczny fragment ContextFlow. Bierzemy ten sam contextflow.properties, który jest już podłączony do górnego AppConfig, i patrzymy tylko na linie ważne dla tego scenariusza:
# Fragment tego samego contextflow.properties, który jest już podłączony do AppConfig
contextflow.app-name=ContextFlow
contextflow.notifications.default-channel=console
contextflow.report.output-dir=build/reports
contextflow.locale.default=pl
Tu default-channel działa jako klucz wyboru strategii w Map<String, NotificationSender>, dlatego wartość lower-case wygodnie trzymać taką samą jak nazwa odpowiadającego beana.
Tu ważne, że klucze są czytelne i „żyją" pod jednym prefiksem contextflow.*. To nie kaprys, lecz dyscyplina: za kilka tygodni podziękujesz sobie, bo konfiguracja nie zamieni się w wysypisko name, mode, path, value2.
Teraz przykład: serwis wysyłki powiadomień wybiera implementację po nazwie kanału. Nie robimy skomplikowanego routingu, po prostu pokazujemy, że default-channel teraz przychodzi z zewnątrz.
package com.example.contextflow.application.service;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final String defaultChannel;
private final Map<String, NotificationSender> senders;
public NotificationDispatchService(
// Ustawienie steruje tym, który nadawca zostanie wybrany domyślnie
@Value("${contextflow.notifications.default-channel:console}") String defaultChannel,
// Spring złoży Map po nazwach beanów: klucz = bean name, wartość = implementacja
Map<String, NotificationSender> senders
) {
this.defaultChannel = defaultChannel;
this.senders = senders;
}
public void dispatch(String message) {
// Bierzemy nadawcę po kluczu z konfiguracji
senders.get(defaultChannel).send(message);
}
}
A żeby Map<String, NotificationSender> było przewidywalne, dajemy czytelne bean names nadawcom (to było już w temacie o kilku implementacjach, tu po prostu z tego korzystamy).
package com.example.contextflow.infrastructure.notification;
import org.springframework.stereotype.Component;
@Component("console") // Jawnie ustalamy nazwę beana, żeby zgadzała się z wartością z konfiguracji
public class ConsoleNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("[CONSOLE] " + message); // [CONSOLE] Order created
}
}
I wreszcie niech nasz ScenarioRunner drukuje informacje startowe, żeby na własne oczy zobaczyć, że konfiguracja naprawdę działa.
package com.example.contextflow.application.scenario;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ScenarioRunner {
private final String appName;
public ScenarioRunner(
// Wartość można nadpisać przez contextflow.properties / env vars / JVM props
@Value("${contextflow.app-name:ContextFlow}") String appName
) {
this.appName = appName;
}
public void run() {
System.out.println("Starting " + appName); // Starting ContextFlow
}
}
Cała wartość tego kroku polega na tym, że teraz zmiana nazwy aplikacji lub kanału powiadomień — to nie „idziemy edytować klasę Java", lecz „zmieniamy konfigurację uruchomienia". Kod stał się mniej przywiązany do środowiska. I to dokładnie cel, dla którego Spring wprowadza warstwę konfiguracyjną: oddzielić „jak działa program" od „z jakimi wartościami uruchamia się dziś".
7. Typowe błędy przy pracy z @Value i Environment
Błąd nr 1: używanie @Value w polach jako główny styl.
Technicznie to działa i nawet wygląda krócej. Ale w efekcie ustawienia stają się ukryte, klasa traci przejrzystość kontraktu, pojawia się więcej stanu mutowalnego i przestajesz rozumieć, jakie parametry są potrzebne obiektowi do stworzenia. Dla kursu (i dla normalnego nawyku inżynierskiego) lepiej trzymać wartości w konstruktorze lub na granicy konfiguracyjnej.
Błąd nr 2: „zatykanie" każdego brakującego klucza domyślną wartością.
Domyślna wartość — to nie plaster „żeby nie padało". Jeśli stawiasz :default na wszystkim po kolei, zamieniasz błędy konfiguracyjne w ciche bugi. Lepiej niech aplikacja uczciwie padnie przy starcie, niż będzie działać „jakoś" i psuć dane lub pisać pliki w nieoczekiwane miejsca.
Błąd nr 3: wstrzykiwanie Environment do każdego serwisu i czytanie właściwości w metodach biznesowych.
Tak łatwo wpaść w styl „każda metoda sama sobie config". To sprawia, że zachowanie jest mniej przewidywalne i rozmywa granice odpowiedzialności. Dobry wzorzec — odczytać wartości raz przy tworzeniu beana (w konstruktorze lub @Configuration) i potem pracować ze zwykłymi polami.
Błąd nr 4: zbyt dużo @Value w jednej klasie (annotation soup).
Pięć-sześć @Value w jednym konstruktorze — i czytelność zaczyna spadać. To zazwyczaj sygnał, że klasa za dużo robi lub że czas przenieść konfiguracyjne czytanie do @Configuration i przekazywać do klasy już złożone, sensowne parametry.
Błąd nr 5: „magiczne stringi" kluczy bez dyscypliny nazewnictwa.
Jeśli gdzieś piszesz appName, gdzieś contextflow.app.name, a gdzieś contextflow.app-name, gwarantowanie urządzisz sobie quest po szukaniu „dlaczego się nie podstawia". Umowa co do prefiksu contextflow.* i staranne nazwy kluczy — to nudne, ale bardzo praktyczne. W programowaniu nudne zazwyczaj oznacza „niezawodne".
GO TO FULL VERSION