CodeGym /Kursy /Spring Core /ConverterFactory i BeanWrapper

ConverterFactory i BeanWrapper

Spring Core
Poziom 13, Lekcja 3
Dostępny

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.

1
Zadanie
Spring Core, poziom 13, lekcja 3
Niedostępne
Jedna fabryka konwerterów dla dwóch ustawień enum
Jedna fabryka konwerterów dla dwóch ustawień enum
1
Zadanie
Spring Core, poziom 13, lekcja 3
Niedostępne
Reczne wiazanie przez BeanWrapperImpl
Reczne wiazanie przez BeanWrapperImpl
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION