1. Kiedy Converter nie wystarcza
Jeśli uczciwie napisałeś jeden Converter<String, NotificationChannel>, to prawie gwarantowanie za kilka dni będziesz chciał drugiego. W naszym ContextFlow obok NotificationChannel pojawia się jeszcze jeden typizowany wybór z konfiguracji — na przykład ReportFormat (TEXT/CSV). I właśnie tu u nowicjusza pojawia się bardzo ludzka reakcja: „No dobra, po prostu skopiuję converter, zmienię typ i wszystko". Działa? Działa. Ale poczucie, że gdzieś już to widziałeś — też działa.
Wyobraźmy sobie typową sytuację. Mamy dwa enum-y:
public enum NotificationChannel {
// Kanał, przez który wysyłamy powiadomienia
EMAIL, SMS, CONSOLE
}
public enum ReportFormat {
// Format, w którym generujemy raport
TEXT, CSV
}
I z properties przychodzi mniej więcej to:
contextflow.notifications.default-channel=sms
contextflow.report.format=csv
Jeśli rozwiązywać zadanie ściśle przez Converter, szybko dostaniesz dwie prawie identyczne klasy. Różnica — tylko w tym, jaki enum stoi po prawej stronie strzałki. A identyczne kawałki — normalizacja stringa (trim, toUpperCase) i valueOf/switch. To nie katastrofa (naprawdę), ale to właśnie ten moment, gdy zaczyna pojawiać się powtarzalny wzorzec i Spring daje narzędzie nie „dla magii", lecz dla zmniejszenia copy-paste: ConverterFactory.
Ważne z góry ustawić właściwą ramkę. ConverterFactory nie jest obowiązkowy w małych projektach i nie można go postrzegać jako „prawidłowiej, bo bardziej skomplikowany". Staje się przydatny wtedy, gdy widzisz powtarzanie tej samej reguły przekształcania dla grupy podobnych docelowych typów. W naszym przypadku taka reguła brzmi śmiesznie prosto: „bierzemy string, czyścimy spacje, sprowadzamy do górnego rejestru i szukamy stałej enum-a o takiej samej nazwie".
Fabryka konwerterów, a nie „kolejny Converter"
Gdy po raz pierwszy widzisz ConverterFactory, może wydawać się, że to „po prostu kolejna wariacja converter-a, tylko z bardziej skomplikowanymi generykami". W rzeczywistości idea jest prostsza niż wygląda po sygnaturach: fabryka nie konwertuje bezpośrednio, ona wydaje konkretny Converter pod konkretny target-type. Czyli to sposób powiedzenia Springowi: „umiem robić konvertery według jednego wzorca, daj mi docelową klasę — złożę przekształcenie pod nią".
Żeby się nie pogubić, porównajmy to w tabelce (tabelki — nasz legalny sposób, żeby nie pisać list i przy tym nie zamieniać tekstu w papkę):
| Narzędzie | Co rejestrujesz w kontenerze | Kiedy to wygodne | Jak myśleć |
|---|---|---|---|
| Converter<S, T> | Jedno przekształcenie „S → T" | Gdy jest jeden konkretny przypadek i chcesz go w pełni kontrolować (aliasy, specjalne reguły, niestandardowe błędy) | „Tłumaczę dokładnie to na dokładnie tamto" |
| ConverterFactory<S, R> | Wzorzec przekształceń „S → (rodzina typów, będąca R)" | Gdy docelowych typów jest dużo, a reguła przekształcenia jest taka sama | „Wydaję converter pod dowolny T z rodziny" |
I tu ważny wniosek praktyczny. Jeśli masz unikalną logikę (np. NotificationChannel przyjmuje synonimy w stylu stdout → CONSOLE, lub chcesz obsługiwać text/plain → TEXT), to konkretny Converter zazwyczaj jest czytelniejszy. A jeśli rozumiesz, że już masz ReportFormat, AuditMode, LoyaltyLevel i jeszcze pięć enum-ów, i wszystkie pobierane są ze stringów tak samo — fabryka staje się uzasadniona.
2. ConverterFactory dla enum-ów
Przed napisaniem kodu małe ostrzeżenie. Enum-y w Java — to potężna rzecz, ale ich typizacja przez generyki czasem przypomina próbę złożenia szafy z IKEA bez instrukcji, za to z nadmiarem wkrętów. To nie twoja wina. To normalne. Zrobimy implementację jak najbardziej czytelną i uczciwie przyznamy, gdzie trzeba używać rzutowania typów.
Nasz cel: zrobić fabrykę, która umie zamieniać string na dowolny enum, ignorując rejestr i zbędne spacje. Oto kompaktowy wariant:
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;
// Fabryka konwerterów: po docelowym typie enum-a zwraca Converter<String, T>
public class StringToEnumIgnoringCaseConverterFactory
implements ConverterFactory<String, Enum> {
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
// source przychodzi z properties jako string
// targetType — konkretny enum, np. NotificationChannel.class lub ReportFormat.class
return source -> (T) Enum.valueOf((Class) targetType, source.trim().toUpperCase());
}
}
Co tu ważne zrozumieć po ludzku, a nie „wykuć":
Po pierwsze, ConverterFactory<String, Enum> oznacza: „źródło — string, docelowe typy — dowolne, będące Enum". Czyli to właśnie „rodzina".
Po drugie, metoda getConverter(Class<T> targetType) — to właśnie moment, gdy fabryka dostaje konkretny cel. Dziś to będzie NotificationChannel.class, jutro ReportFormat.class, pojutrze dowolny inny enum, który chcesz podstawiać przez @Value.
Po trzecie, Enum.valueOf oczekuje stringa pasującego do nazwy stałej, dlatego robimy trim() i toUpperCase(). I tak, to właśnie ten powtarzający się wzorzec, dla którego całe to zamieszanie.
Po czwarte, @SuppressWarnings tu nie dla urody, lecz żeby nie straszyć czerwonymi falującymi liniami. W prawdziwym projekcie możesz zostawić ostrzeżenia, możesz staranniej typizować, a możesz w ogóle zrezygnować z fabryki i napisać dwa osobne konvertery — to wybór inżynierski, a nie religia.
Jeśli chcesz, żeby błąd był bardziej przyjazny, możesz złapać IllegalArgumentException i przekierować do własnej z czytelnym tekstem. Ważne, żeby nie zamieniać converter-a w „mądry serwis", ale czytelny komunikat — to normalne:
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;
// Ta sama fabryka, ale z bardziej czytelnym błędem dla konfiguracji
public class StringToEnumIgnoringCaseConverterFactory
implements ConverterFactory<String, Enum> {
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
return source -> {
try {
// Normalizujemy string z konfiguracji
String normalized = source.trim().toUpperCase();
// Szukamy stałej enum-a o takiej samej nazwie
return (T) Enum.valueOf((Class) targetType, normalized);
} catch (IllegalArgumentException ex) {
// Błąd konfiguracyjny powinien być głośny i zrozumiały
throw new IllegalArgumentException(
"Cannot convert '" + source + "' to " + targetType.getSimpleName(), ex);
}
};
}
}
Tutaj główne — nie podmieniamy nieznanej wartości „rezerwową" (np. CONSOLE domyślnie). Uważamy nieznaną wartość za błąd konfiguracyjny i padamy jawnie. To odpowiada stylowi kursu: lepiej głośno paść przy starcie niż po cichu „jakoś pracować", a potem łapać dziwne bugi.
3. Podłączanie ConverterFactory do ConversionService
Po tym jak mamy fabrykę, ważne jest, żeby nie zrobić klasy, która „po prostu leży w projekcie" i nikomu nie jest potrzebna. Nasza reguła pozostaje taka sama: wszystkie konwersje — to część wspólnej infrastruktury kontenera. Znaczy fabryka musi być zarejestrowana w tym ConversionService, który żyje wewnątrz ApplicationContext.
Minimalna konfiguracja wygląda tak:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
@Configuration
public class ConversionConfig {
@Bean
public ConversionService conversionService() {
// Wspólny ConversionService dla ApplicationContext
DefaultConversionService cs = new DefaultConversionService();
// Rejestrujemy fabrykę dla wzorcowej reguły: String -> dowolny enum
cs.addConverterFactory(new StringToEnumIgnoringCaseConverterFactory());
return cs;
}
}
Zwróć uwagę na prawie „magiczną", ale w rzeczywistości bardzo pragmatyczną rzecz: nazwa metody conversionService(). W Springu jest to powszechna konwencja: jeśli w kontekście jest bean o nazwie conversionService, kontener stara się go używać jako ogólnego mechanizmu konwersji w różnych miejscach (w tym tam, gdzie wartości przychodzą jako stringi i muszą stać się czymś mądrzejszym).
Jest jeszcze jeden niuans, który warto trzymać w głowie, nawet jeśli dziś w to głęboko nie wchodzisz. Gdy rejestrujesz ogólny wzorzec (fabrykę) i jednocześnie gdzieś rejestrujesz konkretny Converter pod osobny enum, może dojść do konfliktu: oba umieją String -> NotificationChannel. W DefaultConversionService kolejność rejestracji ma znaczenie i zazwyczaj wygrywa „późniejszy/bardziej specyficzny". Dlatego w prawdziwym życiu zazwyczaj robi się tak: najpierw dodaje się ogólną fabrykę, a potem jeden-dwa konkretne converter-y dla tych typów, gdzie są specjalne reguły. W ramach tej lekcji wystarczy po prostu pamiętać: „ogólne reguły — wcześniej, specyficzne — później".
4. Conversion i binding
Do tej pory cały czas mówiliśmy: „string zamienia się w typ". To conversion. Ale w rzeczywistości kontener wykonuje dwie różne prace i mylenie ich — jeden z najpopularniejszych źródeł zamieszania w głowie. Conversion — to jak tłumacz: mówi ci, że "csv" oznacza ReportFormat.CSV. A binding — to jak kurier: ma dostarczyć ten sens w właściwe miejsce, czyli do parametru konstruktora lub do właściwości beana. I jeśli kurier pomyli adres, tłumaczenie będzie idealne, ale i tak dostaniesz błąd.
Dla naszego kursu ważne jest zachowanie prostego łańcucha:
1) Spring znajduje tekstową wartość w property sources (np. "csv").
2) Spring konwertuje ją na potrzebny typ (np. ReportFormat.CSV).
3) Spring wiąże (bind) tę wartość z punktem wstrzykiwania zależności.
Punktem wstrzykiwania zależności może być parametr konstruktora (nasz ulubiony teaching default) lub setter/pole (czego staramy się nie robić w kodzie biznesowym, ale co nadal istnieje w świecie).
Żeby obraz stał się zupełnie naoczny, oto mały schemat:
flowchart TD
A["contextflow.report.format = 'csv' (String)"] --> B["placeholder resolution"]
B --> C["ConversionService String -> ReportFormat"]
C --> D["binding"]
D --> E["constructor parameter ReportFormat format"]
I właśnie tu pojawia się BeanWrapper. To jeden z infrastrukturalnych mechanizmów Springa, który pomaga właśnie z drugą częścią — z bindingiem, szczególnie gdy chodzi o właściwości (properties) obiektu, czyli o model oparty na setter-ach. W naszym projekcie głównie używamy constructor injection, ale rozumienie BeanWrapper jest i tak przydatne z dwóch powodów. Po pierwsze, zaczniesz lepiej rozumieć, jak Spring może „wypełniać obiekt wartościami". Po drugie, przestaniesz postrzegać binding jako magię — to po prostu infrastrukturalny kod, który umie odwoływać się do właściwości obiektu po nazwie i w razie potrzeby używać conversion.
5. BeanWrapper jako mechanizm bindingu
Teraz będzie ważny moment: spojrzymy na BeanWrapper „ręcznie" nie dlatego, że chcemy żebyś pisał na nim logikę biznesową, lecz dlatego, że to jak zajrzeć pod maskę samochodu. Po tym nie musisz składać silnika w kuchni, ale przynajmniej zrozumiesz, dlaczego czasem hałasuje.
Najpierw zrobimy prostą klasę z „prawdziwymi" właściwościami Java (czyli z setter-ami i getter-ami). Niech to będą ustawienia raportu:
public class ReportSettings {
// Folder, gdzie składamy raporty (prosty String — bez konwersji)
private String outputDir;
// Format raportu (enum — wymaga konwersji ze stringa "csv"/"text")
private ReportFormat format;
public void setOutputDir(String outputDir) {
// BeanWrapper znajdzie ten setter po nazwie właściwości "outputDir"
this.outputDir = outputDir;
}
public void setFormat(ReportFormat format) {
// Tu powinien trafić już skonwertowany enum
this.format = format;
}
public ReportFormat getFormat() {
return format;
}
}
Teraz weźmiemy BeanWrapperImpl i spróbujemy „przywiązać" wartości stringowe tak, jak mogłaby to robić infrastruktura kontenera:
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.core.convert.support.DefaultConversionService;
public class BeanWrapperDemo {
public static void main(String[] args) {
// 1) Tworzymy ConversionService i rejestrujemy fabrykę dla enum-ów
DefaultConversionService cs = new DefaultConversionService();
cs.addConverterFactory(new StringToEnumIgnoringCaseConverterFactory());
// 2) Obiekt docelowy, do którego będziemy „bindować" właściwości
ReportSettings settings = new ReportSettings();
// 3) BeanWrapper pracuje ponad obiektem i umie ustawiać właściwości po nazwie
BeanWrapper wrapper = new BeanWrapperImpl(settings);
// 4) Ważne: bez ConversionService nie będzie mógł zrobić String -> ReportFormat
wrapper.setConversionService(cs);
// 5) Proste przypisanie stringa do stringowej właściwości
wrapper.setPropertyValue("outputDir", "build/reports");
// 6) Przypisanie stringa do właściwości enum-a: tu właśnie włącza się conversion
wrapper.setPropertyValue("format", "csv");
System.out.println(settings.getFormat()); // CSV
}
}
Co tu się dzieje, jeśli rozłożyć na kroki:
wrapper.setPropertyValue("outputDir", "build/reports") znajduje setter setOutputDir(String) i go wywołuje. Żadnej magii — to zwykłe wywołanie metody, po prostu znalezione przez JavaBeans-introspekcję.
wrapper.setPropertyValue("format", "csv") widzi, że setter oczekuje ReportFormat, ale przekazaliśmy string. I właśnie tu używa ConversionService (który jawnie ustawiliśmy przez setConversionService(cs)), żeby zamienić "csv" na ReportFormat.CSV i dopiero potem wywołuje setFormat(ReportFormat).
Jeśli spróbujesz pomylić się w nazwie właściwości, będzie to bardzo pokazowe:
wrapper.setPropertyValue("formatt", "csv"); // literówka
Wynikiem będzie wyjątek (np. NotWritablePropertyException). I to dobrze. Z punktu widzenia kontenera to właśnie wiring/configuration error: próbujesz przywiązać wartość tam, gdzie przywiązać nie można.
I tu dochodzimy do „ograniczonej roli" BeanWrapper w kursie. Pomaga zrozumieć, że binding — to infrastrukturalna praca: znaleźć właściwość, znaleźć setter, w razie potrzeby skonwertować, ustawić wartość. Ale jeśli zaczniesz używać BeanWrapper w serwisach biznesowych, prawie na pewno dostaniesz kod, który słabo się refaktoryzuje, słabo czyta i słabo sprawdzany przez kompilator. Bo string "format" — to nie typizowana referencja, lecz krucha umowa.
W kontekście ContextFlow ta reguła brzmi bardzo projektowo: niech @Value i konwersja żyją w settings/config beans, a serwisy dostają już normalne typy (ReportFormat, NotificationChannel) przez konstruktor. Wtedy warstwa biznesowa nawet nie będzie wiedziała, że gdzieś tam były stringi i spacje.
6. Typowe błędy: conversion i binding
Błąd nr 1: zamienianie ConverterFactory w „kombajn do wszystkiego".
Łatwo się zapędzić: skoro fabryka może wydawać konvertery, zróbmy jedną fabrykę, która rozumie i enum-y, i daty, i „tak/nie", i „może" (szczególnie „może"). W praktyce szybko zamienia się to w nieprzewidywalną logikę, którą trudno debugować. Dla projektu szkoleniowego reguła prosta: fabryka powinna pokrywać jeden zrozumiały wzorzec, na przykład „string → enum, bez uwzględniania rejestru".
Błąd nr 2: używanie ConverterFactory zbyt wcześnie, gdy wystarczyłby jeden Converter.
Jeśli masz tylko jeden NotificationChannel, osobny Converter<String, NotificationChannel> czyta się prościej i daje więcej kontroli (np. można jawnie obsługiwać aliasy lub czytelne komunikaty błędów). ConverterFactory staje się przydatna, gdy powtórzenie naprawdę się pojawiło. Do tego momentu komplikowanie — to po prostu komplikowanie.
Błąd nr 3: robienie „cichych domyślnych wartości" przy nieznanej wartości.
Czasem ręka ciągnie do napisania: „Jeśli wartość nieznana — weźmiemy CONSOLE". To brzmi „niezawodnie", ale w praktyce chowasz błąd konfiguracyjny. Znacznie lepiej paść przy starcie kontenera z czytelnym komunikatem. W konfiguracji błąd powinien być głośny — inaczej będziesz debugował zachowanie zamiast przyczyny.
Błąd nr 4: postrzeganie BeanWrapper jako normalnego codziennego narzędzia dla warstwy aplikacji/serwisów.
BeanWrapper — to infrastruktura. Pracuje z właściwościami po stringowych nazwach, a to oznacza: refaktoring IDE cię nie ochroni, kompilator nie pomoże, a błąd pojawi się dopiero w runtime. W kodzie biznesowym naszego kursu świadomie trzymamy się constructor injection i typizowanych zależności. BeanWrapper jest przydatny jako model mentalny, a nie jako „tak teraz będziemy robić wszędzie".
Błąd nr 5: oczekiwanie, że BeanWrapper „jakoś sam zgadnie", jeśli nie ma setter-a lub nazwa właściwości się nie zgadza.
BeanWrapper nie jest telepatą. Jeśli obiekt nie ma setFormat(...), to nie ma gdzie przywiązać wartości. Jeśli właściwość nazywa się outputDir, a piszesz "output-dir" — to też nie pasuje. Spring w różnych miejscach może wykonywać dodatkowe sztuczki z nazewnictwem, ale w modelu podstawowym (który budujemy w tym kursie) wszystko opiera się na jasnym kontrakcie JavaBeans: nazwa właściwości ↔ setter/getter.
GO TO FULL VERSION