Może nie tyle problem z ObjectMapper
, a autokonfiguracją Spring Boot, która jest odpowiedzialna za utworzenie beana tejże klasy. Jednak na początku zaznaczę od krótkiego przywitania. Nie było mnie tutaj na blogu już chyba od ponad 3 miesięcy… Wracam do Was po raz kolejny niczym feniks. Nie będę ukrywał, że bardzo mi tego brakowało. Dlatego przychodzę do Was dzisiaj z tematem z nagłówka, który ostatnio napotkałem w pracy. So, let’s dive into it!
Studium przypadku
Załóżmy na start, że mamy pewną strukturę danych reprezentującą człowieka. Jest ona trywialna na potrzeby tego artykułu. Posiada tylko imię, nazwisko i datę urodzenia.
1
2
3
4
5
6
7
8
@Value
public class Person {
String firstname;
String lastname;
LocalDate dateOfBirth;
}
Będziemy chcieli wysłać jej dane do zewnętrznego systemu w postaci formatu JSON. Niezbędny do tego nam będzie ObjectMapper
. Akurat w Spring Boot mamy go za darmo. Wstrzykniemy go do dedykowanego serwisu i serializujemy instancję naszej klasy do JSONa.
1
2
3
4
5
6
7
Person person = new Person(
"Jan",
"Kowalski",
LocalDate.of(1970, 10, 3));
String personJson = objectMapper.writeValueAsString(person);
System.out.println(personJson);
1
{"firstname":"Jan","lastname":"Kowalski","dateOfBirth":"1970-10-03"}
Jak widać na załączonym wyżej kodzie, wszystko działa prawidłowo.
Przychodzi nowe wymaganie…
Po jakimś czasie istnienia projektu zaistniała potrzeba zmiany formatu daty dla aktualnego klienta. Chciałby on od nas ją otrzymywać w innym formacie, powiedzmy amerykańskim (MM/dd/yyyy). Nie jest to rocket science. Można tego łatwo dokonać na wiele różnych sposobów. Jednym z nich, który najbardziej nas interesuje, jest modyfikacja modułu ObjectMapper
odpowiedzialnego za czas. Na ten moment przyjmijmy, że inne dostępne moduły ObjectMapper
nas nie interesują.
W tym celu tworzymy nową instancję klasy ObjectMapper
. Dodajemy do niej moduł JavaTimeModule
, w którym rejestrujemy formatter do daty. Wykorzystujemy do tego przyjemne fluent API.
1
2
3
4
5
6
7
8
9
10
11
@Bean
ObjectMapper unitedStatesDateObjectMapper() {
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addDeserializer(
LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(UNITED_STATES_DATE_FORMAT)));
javaTimeModule.addSerializer(
LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(UNITED_STATES_DATE_FORMAT)));
return new ObjectMapper()
.registerModule(javaTimeModule);
}
Oczywiście, jak widać w kodzie powyżej, od razu rejestrujemy nasz nowy obiekt jako bean Springa. Teraz uruchamiamy nasz kod odpowiedzialny za serializowanie instancji klasy Person
do JSON i sprawdzamy rezultat czy udało nam się osiągnąć postawiony cel.
1
2
3
4
5
6
7
Person person = new Person(
"Jan",
"Kowalski",
LocalDate.of(1970, 10, 3));
String personJson = objectMapper.writeValueAsString(person);
System.out.println(personJson);
1
{"firstname":"Jan","lastname":"Kowalski","dateOfBirth":"10/03/1970"}
Całość działa zgodnie z nowymi założeniami. Jednak, jak to zwykle bywa, wszystko się zmienia, a zwłaszcza wymagania. Z tego powodu musimy wprowadzić nową zmianę w kodzie.
Ciąg dalszy nowych wymagań
Również inny zewnętrzny system chce uzyskać od nas te same dane. Żeby nie było za prosto, nie jest to klient amerykański. Przyszedł do nas właściciel firmy z Polski. Jak można się domyślić, wymaga on od nas formatowania daty w innym formacie - dzień-miesiąc-rok. Bez zastanowienia tworzymy kolejny ObjectMapper
metodą copy-pasta.
1
2
3
4
5
6
7
8
9
10
11
@Bean
ObjectMapper polishDateObjectMapper() {
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addDeserializer(
LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(POLISH_DATE_FORMAT)));
javaTimeModule.addSerializer(
LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(POLISH_DATE_FORMAT)));
return new ObjectMapper()
.registerModule(javaTimeModule);
}
Rejestrujemy go w kontekście Springa, uruchamiamy aplikację i… niestety, wyłożyła się. Okazuje się, że mamy dwa ObjectMapper
i Spring nie wie, z którego ma korzystać w sytuacjach konfliktowych. Nie pomoże nam nawet tutaj adnotacja @Qualifier
na polu, gdzie potrzebujemy wybranego ObjectMapper
.
1
*Parameter 0 of method mappingJackson2HttpMessageConverter in org.springframework.boot.autoconfigure.http.JacksonHttpMessageConvertersConfiguration$MappingJackson2HttpMessageConverterConfiguration required a single bean, but 2 were found…*
Wychodzi na to, że inny komponent (MappingJackson2HttpMessageConverter
) Spring Boota też potrzebuje ObjectMapper
. Co zrobić w takiej sytuacji? Ktoś mógłby pomyśleć, dajmy @Primary
na jednym z beanów. Powstaje pytanie, na którym? Tym z amerykańskim formatem daty czy tym z polskim? Żadna z opcji nie wydaje się dobrym wyborem, oczywiście bez nakreślonego kontekstu. Co w takim razie zrobić?
Przyjrzenie się problemowi bliżej
Jeśli zaczniemy grzebać w bebechach Springa dokopiemy się w końcu do klasy JacksonAutoConfiguration
. W niej znajdziemy wewnętrzną klasę, której zadaniem jest utworzenie beana klasy, jak się można domyślić, ObjectMapper
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration(
proxyBeanMethods = false
)
@ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
static class JacksonObjectMapperConfiguration {
JacksonObjectMapperConfiguration() {
}
@Bean
@Primary
@ConditionalOnMissingBean
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}
}
Przyglądając się uważniej dostrzeżemy adnotację @ConditionalOnMissingBean
, której zadaniem jest zarejestrowanie danego beana o ile nie został on już wcześniej zarejestrowany. To jest właśnie magia Spring Boota, która dostarczyła nam domyślnego ObjectMapper
. Nie musieliśmy nic zrobić na początku, aby z niego skorzystać. Oczywiście nic poza jego wstrzyknięciem i wykorzystaniem. Dopiero jak zarejestrowaliśmy bean unitedStatesDateObjectMapper
, ten od Springa został pominięty. Ten nowy stał się domyślnym i był wykorzystywany w każdym dostępnym miejscu w aplikacji. Finalne dodanie beana polishDateObjectMapper
podpaliło lont i aplikacja wybuchła przy starcie. Prawda, że ciekawe?
Jak więc temu zaradzić nie korzystając z adnotacji @Primary
? Zapraszam do dalszej lektury.
Rozwiązanie problemu
Rozwiązanie na jakie wpadliśmy z zespołem jest bardzo trywialne. Po prostu należy stworzyć ObjectMapper
dla konkretnego zewnętrznego serwisu, ale bez jego rejestracji w kontekście Springa, tylko jako instancję danej konfiguracji. Zobaczmy jak to może wyglądać dla przypadku z tego artykułu.
1
2
3
4
5
6
7
private static final ObjectMapper POLISH_DATE_OBJECT_MAPPER = new ObjectMapper()
.registerModule(
new JavaTimeModule()
.addDeserializer(
LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(POLISH_DATE_FORMAT)))
.addSerializer(
LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(POLISH_DATE_FORMAT))));
1
2
3
4
5
6
7
private static final ObjectMapper UNITED_STATES_DATE_OBJECT_MAPPER = new ObjectMapper()
.registerModule(
new JavaTimeModule()
.addDeserializer(
LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(UNITED_STATES_DATE_FORMAT)))
.addSerializer(
LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(UNITED_STATES_DATE_FORMAT))));
Najlepiej takie statyczne pole umieścić w klasie konfiguracyjnej oznaczonej przez adnotację @Configuration
. Wtedy można daną instancją klasy ObjectMapper
dzielić się w wybranym module odpowiedzialnym za komunikację z konkretnym klientem. Przy okazji zostawiliśmy w spokoju głównego ObjectMapper
, który wydaje się być dobrze skonfigurowany pod kątem większości przypadków. Całość zadziałała oczywiście zgodnie z planem.
Podsumowanie
Problem był naprawdę ciekawy. Dzięki niemu można było się więcej dowiedzieć jak działa Spring Boot pod spodem. Jeśli poznamy te mechanizmy to ich zwalczanie, lub współpraca z nimi, będzie o wiele łatwiejsza. Po więcej takich ciekawostek mogę odesłać Was do prezentacji Stéphane Nicoll & Andy Wilkinson przedstawionej na Devoxx. Dużo przydatnej wiedzy na temat tego co się dzieje z naszą aplikacją webową opartą o Spring Boot.
Miło było do Was napisać kolejny wpis po takiej przerwie i mam nadzieję, że wrócę do regularności.