1. Bundle'e i most do kontenera
Jeśli położysz messages.properties w src/main/resources, w projekcie będzie więcej plików, ale magii nie będzie: Spring nie zacznie „telepatycznie" wyciągać stamtąd tekstów do twoich serwisów. Żeby komunikaty stały się częścią aplikacji, kontener potrzebuje obiektu, który po kluczu i Locale zwraca string. Takim obiektem właśnie jest MessageSource.
Wyobraź sobie, że pliki bundle to „książka" na półce. Dobrze, że książka jest. Ale dopóki nie masz „bibliotekarza", który wie, gdzie stoi, jak ją otworzyć i którą stronę znaleźć, i tak będziesz pisać stringi ręcznie. MessageSource to właśnie ten bibliotekarz.
Poniżej uproszczony schemat tego, co chcemy uzyskać w runtime:
flowchart TD
S["Serwis OrderTextService"] --> MS["bean MessageSource"]
MS --> B["bundle komunikatów messages_pl.properties messages.properties"]
B --> T["Gotowy tekst: Zamówienie ORD-1 utworzone"]
Ważna subtelność: ApplicationContext potrafi wyszukiwać komunikaty, bo jest „szerszy" niż tylko kontener DI. Ale nie przechowuje tekstów wewnątrz siebie — deleguje do MessageSource. I tu pojawia się główny bohater wykładu: bean z właściwą nazwą i właściwą konfiguracją.
2. Nazwa beana messageSource jako kontrakt
Słowo „kontrakt" brzmi nieco surowo, ale tu jest na miejscu. Spring ma konwencję: jeśli w kontekście jest bean o nazwie messageSource, ApplicationContext użyje jego do wyszukiwania komunikatów. Jeśli takiego beana nie ma, Spring podniesie swój domyślny wariant (zwykle coś w stylu „delegującego" źródła), który przy i18n prawie nie pomoże.
Najbardziej nieprzyjemna pułapka dla nowicjusza wygląda tak: zrobiłeś swój ResourceBundleMessageSource, ale nazwałeś metodę na przykład messages() — i nagle zamiast wygodnej lokalizacji dostaniesz zagadkowy błąd autosejkowania, bo „nagle" w kontenerze będą dwa MessageSource: twój i domyślny.
Żeby nie było abstrakcyjnie, oto mała tabela zachowań:
| Co zrobiłeś w konfiguracji | Co dzieje się w kontekście | Typowy objaw |
|---|---|---|
| Nic nie skonfigurowano | Spring tworzy domyślny messageSource (serwisowy) | Teksty nie są znajdowane, getMessage działa „pusto"/dziwnie |
| Stworzono bean i nazwano go messageSource | Spring używa twojego źródła komunikatów | Wszystko działa, komunikaty czytane z bundle'i |
| Stworzono ResourceBundleMessageSource, ale nazwano bean inaczej | Spring i tak stworzy serwisowy messageSource + ty masz drugi | NoUniqueBeanDefinitionException przy wstrzykiwaniu MessageSource |
Nie ma tu „magii" w złym sensie. To po prostu reguła: nazwa beana jest częścią konfiguracji. Czasem nazwa to po prostu „jak będziesz go szukać". A czasem (jak tu) — to wbudowany punkt integracji Spring.
3. ResourceBundleMessageSource: minimum
Teraz potrzebujemy teaching default: najprostszy i najzrozumialszy sposób. Do bundle'i opartych na classpath świetnie nadaje się ResourceBundleMessageSource. Działa ponad znajomym modelem messages.properties, messages_pl.properties, messages_en.properties.
Ważne: basename podaje się bez sufiksów locale i bez .properties. Czyli jeśli pliki nazywają się messages*.properties, basename to po prostu "messages".
Minimalny @Bean (można go umieścić w obecnym AppConfig — nieważne, byle metoda nazywała się messageSource):
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;
@Bean
MessageSource messageSource() {
// Źródło komunikatów, które czyta bundle'e z classpath (src/main/resources)
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
// Bazowa nazwa bundle'i: bez _pl/_en i bez .properties
source.setBasename("messages");
// Jawnie ustawiamy kodowanie, żeby nie zależeć od środowiska/ustawień
source.setDefaultEncoding("UTF-8");
// Zwracamy jako MessageSource: serwisy nie muszą wiedzieć, jaka jest implementacja wewnątrz
return source;
}
Kilka wyjaśnień, żebyś nie czuł, że „po prostu skopiowałeś zaklęcie".
setBasename("messages") mówi: „szukaj bundle'i w classpath, zaczynając od messages". Czyli Spring dobierze właściwy plik pod Locale i reguły fallbacku, które omawialiśmy w poprzednim wykładzie.
setDefaultEncoding("UTF-8") we współczesnej Javie zwykle już nie jest krytyczne (od Java 9 właściwości w ResourceBundle czytane są w UTF-8), ale w projekcie dydaktycznym lubię to robić jawnie. Po pierwsze, daje spokój. Po drugie, jeśli kiedyś wrócisz do starszego kodu lub środowiska, nie będziesz musiał urządzać „festiwalu ŁłŃń".
A oto przykład tego, jak wygląda „oparte" po stronie zasobów (robiliśmy to już w wykładzie 2, tylko przypominam kontekst):
# src/main/resources/messages.properties
order.created=Order {0} created
# src/main/resources/messages_pl.properties
order.created=Zamówienie {0} utworzone
Zauważ, że klucz jest jeden i ten sam. Zmieniają się tylko wartości.
4. Weryfikacja przy starcie
Zanim zaczniemy wstrzykiwać MessageSource do serwisów, przydatne jest zrobienie małej „weryfikacji rur" (nie w sensie hydrauliki, a integracji): upewnić się, że kontekst naprawdę widzi bundle'e.
Tak, ApplicationContext potrafi wywoływać getMessage(...), bo implementuje MessageSource. To wygodne w części bootstrap aplikacji, gdzie i tak trzymasz kontekst w ręku. Wewnątrz serwisów biznesowych tak nie należy robić (omówimy to za chwilę).
Mini-weryfikacja w main():
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.util.Locale;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// defaultMessage ("N/A") potrzebny, żeby na starcie nie padać przez literówkę w kluczu
String msg = ctx.getMessage(
"order.created",
new Object[]{"ORD-1"}, // argumenty dla {0}, {1}...
"N/A",
Locale.ENGLISH // jawnie sprawdzamy konkretny locale
);
System.out.println(msg); // Order ORD-1 created
}
Tutaj używamy przeciążenia getMessage, gdzie jest defaultMessage. To świetny „stabilizator dydaktyczny": jeśli zrobiłeś literówkę w kluczu lub zapomniałeś pliku, aplikacja nie pada ogromnym stack tracem, ale daje zrozumiałe zabezpieczenie.
Jeśli chcesz specjalnie zobaczyć „twarde" zachowanie, możesz użyć przeciążenia bez defaultMessage. Wtedy przy braku klucza dostaniesz NoSuchMessageException. W kodzie produkcyjnym jest to czasem przydatne (fail-fast), ale na początku wygodniej mieć default.
5. Wstrzykiwanie MessageSource do serwisu
Teraz robimy najważniejsze: zaczynamy używać MessageSource jako zwykłej zależności, przez constructor injection. Tu wiele nowicjuszy przypadkowo skręca nie tam i zaczyna ciągnąć do serwisu ApplicationContext, bo „tam jest wszystko".
Tak, jest wszystko. Ale to jak nosić ze sobą cały supermarket, bo potrzebujesz chleba. Działa, ale dziwnie. I co ważne — zamienia twój serwis w service locator, a przez cały kurs uczyliśmy się czegoś dokładnie odwrotnego: zależności mają być widoczne i ustalone.
W ContextFlow wygodnie wydzielić mały serwis odpowiedzialny tylko za teksty: na przykład OrderTextService. Będzie budował krótkie komunikaty do konsoli, powiadomień lub audytu (właśnie krótkie frazy; duże szablony listów — to nadal Resource z poprzedniego dnia).
Przykład „serca" takiego serwisu:
import org.springframework.context.MessageSource;
import java.util.Locale;
public class OrderTextService {
private final MessageSource messages;
public OrderTextService(MessageSource messages) {
// Wstrzykujemy MessageSource jako zwykłą zależność, bez ApplicationContext
this.messages = messages;
}
public String orderCreated(String orderId, Locale locale) {
// {0} w properties zostanie zastąpione przez orderId
return messages.getMessage(
"order.created",
new Object[]{orderId},
"Order created", // default gdy klucza brak
locale
);
}
}
Zwróć uwagę na kilka rzeczy.
Po pierwsze, serwis nie wie, gdzie leżą pliki. Nie czyta resources. Nie otwiera strumieni. Mówi: „potrzebuję tekstu po kluczu". To sprawia, że kod jest czysty i lekki.
Po drugie, Locale na razie przekazujemy jako parametr. Tak, jest to trochę bardziej „gadatliwe" niż gdybyśmy schowali locale gdzieś globalnie. Ale za to od razu widać, że tekst zależy od locale, a nie „skądś po polsku".
Po trzecie, klucz "order.created" na razie jako string. To normalne na pierwszych krokach, ale pod koniec dnia zrobimy porządek, żeby klucze nie rozlazły się po całym projekcie jak konfetti po sylwestrowej imprezie (potem je się znajduje nawet w lipcu).
6. LocaleSelector i domyślne locale
W aplikacjach webowych Locale często przychodzi „samo" (przeglądarka, nagłówki, LocaleResolver). Mamy aplikację non-web, więc wszystko jest uczciwe: locale albo przechowywane jest w danych (np. u Customer), albo ustawione domyślnie w konfiguracji (contextflow.locale.default). I co najważniejsze — ta reguła wyboru locale nie powinna duplikować się w dziesięciu miejscach.
Do tego wygodny jest mały helper: LocaleSelector. Jego praca jest prosta: jeśli klient ma preferredLocale, używamy go, inaczej bierzemy domyślne.
Oto maksymalnie prosta implementacja:
import java.util.Locale;
public class LocaleSelector {
private final Locale defaultLocale;
public LocaleSelector(Locale defaultLocale) {
// Domyślny locale — scentralizowana reguła, a nie „w każdym serwisie po swojemu"
this.defaultLocale = defaultLocale;
}
public Locale choose(Locale preferredLocale) {
// Jeśli użytkownik nie ma locale, wracamy do domyślnego
return preferredLocale != null ? preferredLocale : defaultLocale;
}
}
A oto przykład, jak pobrać domyślne locale z properties (opieramy się na wcześniejszych dniach o @Value i konwersji; jeśli konwersja dla Locale nie jest skonfigurowana — można trzymać tag jako string i parsować ręcznie):
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import java.util.Locale;
@Bean
Locale defaultLocale(@Value("${contextflow.locale.default:en}") String tag) {
// Na przykład: "pl", "en", "pl-PL" — to wszystko language tagi
return Locale.forLanguageTag(tag);
}
Potem LocaleSelector też można zbudować jako bean (w tej samej konfiguracji):
import org.springframework.context.annotation.Bean;
@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
// Budujemy LocaleSelector z domyślnego locale, żeby reguła wyboru była jednorodna
return new LocaleSelector(defaultLocale);
}
Teraz OrderTextService można zrobić nieco staranniej: przyjmować preferredLocale i wybierać końcowe locale wewnątrz serwisu przez LocaleSelector. Dzięki temu kod biznesowy nie będzie za każdym razem pamiętać „a co jeśli preferredLocale jest null?".
7. Dyscyplina kluczy: MessageKeys
Gdy projekt jest mały, klucz jako string "order.created" wydaje się niegroźny. Gdy projekt rośnie, pewnego dnia dostaniesz sytuację: w jednym miejscu "order.created", w drugim "order.create", w trzecim "orderCreated", a potem przez dwie godziny szukasz, dlaczego „wszystko działało wczoraj".
Dlatego przydatna jest minimalna dyscyplina: trzymać klucze w jednym miejscu. Nie trzeba budować „super-architektury kluczy" z generowaniem i refleksją. Wystarczy nam prosta klasa-stała.
Na przykład tak:
public final class MessageKeys {
// Jeden punkt prawdy dla kluczy (żeby nie mnożyć literałów stringowych po projekcie)
public static final String ORDER_CREATED = "order.created";
public static final String ORDER_CANCELLED = "order.cancelled";
private MessageKeys() {
// Zakazujemy tworzenia instancji: to klasa użytkowa
}
}
I wtedy wywołanie staje się mniej kruche:
return messages.getMessage(
MessageKeys.ORDER_CREATED, // używamy stałej zamiast „ręcznego" stringa
new Object[]{orderId},
"Order created",
locale
);
Tak, to nadal stringi. Ale teraz to jeden punkt prawdy, a nie dwadzieścia „prawie identycznych" literałów po projekcie.
Mała tabelka „co leczymy":
| Jak wygląda | Czym jest złe | Jak lepiej w projekcie dydaktycznym |
|---|---|---|
| "order.created" w co drugiej klasie | Literówki, niespójność, trudno zmieniać | MessageKeys.ORDER_CREATED |
| Klucze jako całe frazy ("Order created successfully") | Zmiana tekstu = zepsuty klucz, trudno szukać | Krótki kod semantyczny (order.created) |
| Sklejanie stringów w kodzie | Słabo się lokalizuje, miszmasz logiki i tekstu | Parametry {0}, {1} + Object[] args |
Nie staramy się zrobić „idealnie". Staramy się zrobić tak, żeby projekt pozostał czytelny, a i18n nie zamieniał się w mini-kryminał.
8. Typowe błędy przy podłączaniu MessageSource
Gdy po raz pierwszy podłączasz MessageSource, błędy najczęściej nie są skomplikowane, lecz... przykre. Jak commit „fix typo" na 200 liniach. Poniżej najczęstsze pułapki, które zdarzają się właśnie w czystej aplikacji Spring bez Boota, i jak je rozpoznać po objawach.
Błąd nr 1: bean nie nazywa się messageSource i Spring sam tworzy kolejny messageSource.
To wygląda szczególnie podstępnie: niby stworzyłeś ResourceBundleMessageSource, a aplikacja pada przy starcie z niejednoznacznością, bo MessageSource są teraz dwa. Objaw często wygląda jak NoUniqueBeanDefinitionException dla MessageSource. Leczy się banalnie: metoda @Bean musi nazywać się messageSource() (lub podaj nazwę jawnie @Bean("messageSource")), żeby Spring nie podnosił domyślnej serwisowej implementacji równolegle.
Błąd nr 2: podałeś zły basename.
Jeśli pliki nazywają się messages_pl.properties, a napisałeś source.setBasename("message"), to kontener nie znajdzie bundle'i i będziesz dostawać tylko defaultMessage (jeśli go przekazałeś) lub wyjątek NoSuchMessageException. Warto pamiętać regułę: basename to wspólny korzeń nazwy pliku bez locale i bez rozszerzenia. W naszym dniu to ściśle "messages".
Błąd nr 3: oczekujesz, że MessageSource zastąpi Resource i zacznie przechowywać duże szablony.
MessageSource to krótkie komunikaty po kluczu, zwykle 1–2 linie. Tak, technicznie można tam wepchnąć „ścianę" tekstu na stronę, ale szybko zamieni się to w mękę: pliki properties słabo nadają się do dużych szablonów, a parametry {0} i escaping znaków specjalnych staną się twoim hobby. Do dużych tekstów, szablonów powiadomień i nagłówków raportów mamy już właściwe narzędzie: Resource i pliki w templates/.
Błąd nr 4: Locale wybierany jest w każdym serwisie po swojemu i zachowanie staje się nieprzewidywalne.
Łatwo zacząć pisać „jeśli preferredLocale null — weźmiemy Locale.ENGLISH" w jednym miejscu, „jeśli null — weźmiemy pl" w drugim, a w trzecim — w ogóle nie sprawdzać null. Potem widzisz, że część komunikatów jest po polsku, część po angielsku i myślisz, że „zepsułem lokalizację". W rzeczywistości zepsułeś dyscyplinę wyboru locale. Wyniesie się regułę do LocaleSelector i używaj go wszędzie jednakowo.
Błąd nr 5: klucze rozlazły się po projekcie jako stringi i łapiesz literówki dopiero w runtime.
W odróżnieniu od kodu Java, kluczy w properties kompilator nie sprawdza. Dlatego literówka to nie czerwone podkreślenie w IDE, a niespodzianka przy uruchomieniu. Minimalne lekarstwo — MessageKeys (lub inne jedno miejsce dla kluczy). Bardziej zaawansowane podejścia istnieją, ale na obecny kurs wystarczy nam nie mnożyć literałów stringowych.
GO TO FULL VERSION