JSON na klasę Java: generator da szkielet, do produkcji brakuje jeszcze pięciu rzeczy
Podsumowanie: Praktyczne spojrzenie na to, co generator JSON-to-Java naprawdę emituje — liczby stają się Double, zagnieżdżone obiekty statycznymi klasami wewnętrznymi, a nie powstaje ani jedna adnotacja Jacksona. Precyzja pieniędzy, zmiana nazw pól, wymazywanie typów, nieznane pola i daty to pięć rzeczy, które musisz uzupełnić sam, każda z kodem gotowym do wklejenia.

W skrócie: wynik generatora od razu się kompiluje, ale nie jest gotowy do produkcji. Zmierzone domyślne ustawienia naszego generatora mapują każdą liczbę JSON na Double, zamieniają zagnieżdżone obiekty w statyczne klasy wewnętrzne i nie emitują żadnej adnotacji Jacksona. Precyzja pieniędzy, zmiana nazw pól, wymazywanie typów, nieznane pola i daty — pięć rzeczy, których nigdy nie zrobi za Ciebie.
Zamiana odpowiedzi API na klasy Java to praca mechaniczna: przeczytaj JSON, przepisz pola, opakuj je w gettery i settery. Koszt pracy mechanicznej to ten niewidzialny nóż — źle przepisana nazwa właściwości nie powoduje błędu kompilacji. Pole po cichu przychodzi jako null, a odkrywasz to w logu produkcyjnym. Generowanie struktury klas istnieje po to, by od pierwszego dnia trzymać nazwy i typy w ryzach. Ale generator może wyrównać tylko to, co jest widoczne w próbce.
1. Najpierw uruchom generator: co Ci faktycznie daje
Wklej reprezentatywną odpowiedź do naszego narzędzia JSON to Java:
{
"order_id": 202611040001,
"buyer": {
"name": "王五",
"vip": true
},
"items": [
{ "sku": "A-1", "price": 19.99, "qty": 2 }
],
"created_at": "2026-11-04T09:30:00+08:00"
}
Wygenerowany szkielet (opcje domyślne, useLombok wyłączone):
public class Root {
private Double orderId;
private Buyer buyer;
private List<Item> items;
private String createdAt;
public Double getOrderId() { return orderId; }
public void setOrderId(Double orderId) { this.orderId = orderId; }
// ...pozostałe gettery/settery idą tym samym schematem
}
Buyer i Item to osobne statyczne klasy wewnętrzne. Reguły mapowania, jedna po drugiej:
| Kształt w JSON | Wygenerowany typ Java | Uwagi |
|---|---|---|
"name": "王五" |
String |
Stringi zawsze stają się String |
"qty": 2 |
Double |
Każda liczba staje się Double, zobacz sekcję 2 |
"vip": true |
Boolean |
Typ opakowujący, null dozwolone |
"buyer": {...} |
Buyer (statyczna klasa wewnętrzna) |
Zagnieżdżone obiekty stają się osobną klasą |
"items": [...] |
List<Item> |
Typ elementu brany z pierwszego elementu |
order_id |
orderId |
snake_case zamieniony na camelCase |
Nazwy klas dostają PascalCase (Root), pola camelCase (orderId), a kolizje ze słowami kluczowymi Java (class, new, int) dostają automatycznie sufiks. Istnieją trzy opcje: packageName, className i useLombok, przełączająca między @Data a ręcznie pisanymi getterami/setterami. Z włączonym Lombokiem cały blok boilerplate'u kurczy się do kilku adnotacji.
Dwie granice tego szkieletu warto znać z wyprzedzeniem. Po pierwsze, nie generuje żadnych adnotacji Jackson/Gson — żadnego @JsonProperty, żadnego @JsonIgnoreProperties, żadnego @JsonFormat, bo adnotacje decydują o tym, jak deserializacja się wykonuje, a przykładowy payload nie może ujawnić Twojej polityki wykonania. Po drugie, nie ma konwersji odwrotnej: wyprowadzanie JSON z klasy Java jest poza zakresem, ten sam jednokierunkowy projekt co ścieżka JSON to TypeScript.
2. Poprawka numer jeden: Double to nie jest typ na pieniądze
Generator mapuje "price": 19.99 na Double. Kompiluje się, przypisuje, a mimo to jest to zły typ. Binarny zmiennoprzecinkowy zapis nie potrafi reprezentować 0.1, co znaczy, że nie potrafi reprezentować większości ułamków dziesiętnych:
System.out.println(19.99); // 19.99 — wygląda dobrze
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
Samo wypisanie 19.99 wygląda czysto, bo Java stosuje upiększanie do najkrótszej reprezentacji; błąd wychodzi w chwili, gdy wartość wchodzi w arytmetykę. Wszystkie trzy teksty inżynierskie na ten temat są zgodne: ceny, kwoty i stawki powinny być BigDecimal, konstruowane ze stringa — new BigDecimal("19.99"), a nie new BigDecimal(19.99), który wnosi błąd double'a prosto do środka.
Mechaniczna poprawka to przejście po generowaniu: podmień Double na BigDecimal tylko w polach pieniężnych. Powiązana pułapka z tym samym korzeniem: liczby całkowite ponad 2^53 (numery zamówień, ID snowflake) prowadzone przez Double po cichu tracą końcowe cyfry — dwudziestocyfrowa liczba tracąca ostatnie trzy cyfry to zmierzony, powtarzalny wynik, a mechanizm jest ten sam co u tego, dlaczego JSON traci precyzję na dużych liczbach całkowitych. Mapuj te pola na String lub Long i pozwól cyfrom podróżować jako tekst.
| Prawdziwe znaczenie pola | Generator daje | Zmień na |
|---|---|---|
| Pieniądze, stawki | Double |
BigDecimal (konstruktor ze stringa) |
| Numery zamówień, ID (mogą przekroczyć 2^53) | Double |
String albo Long |
| Liczby sztuk, numery stron | Double |
Integer / Long |
| Naprawdę naukowe floate | Double |
zostaw |
3. Poprawka numer dwa: niedopasowane nazwy, przypięte przez @JsonProperty
Zamiana order_id na orderId to dobrze intencjonowany zgadywacz: generator zakłada, że po stronie Java chcesz camelCase. Jackson jednak domyślnie porównuje klucze JSON z nazwami pól dosłownie. Gdy się nie zgadzają, nic nie pada — orderId po prostu zostaje nullem. Dwa remedia:
Poziom pola — dodaj @JsonProperty, jedna adnotacja przybija jedno mapowanie:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
Globalnie — podmień naming strategię ObjectMappera i w całym projekcie nie trzeba adnotacji przy każdym polu:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
Wybór zależy od powierzchni: jedno stałe API — poziom pola; cały zespół rozmawiający z serwisami w snake_case — strategia globalna. Jeden negatywny fakt zasługuje tu na osobny akapit — źle napisana nazwa zawodzi jako cichy null, a nie jako wyjątek. Strona narzędzia jsonformater uznaje to właśnie za powód istnienia generatorów: mechaniczne przepisywanie to dokładnie tam, gdzie ta częstotliwość błędów szczytuje. Więc po dodaniu adnotacji napisz przynajmniej jeden test asercji, że kluczowe pola nie są null.
4. Poprawka numer trzy: wymazywanie typów — nigdy nie deserializuj do nagiej List
Generator wręcza Ci deklarację pola List<Item>, ale tym, co naprawdę wypełnia tę listę, jest Jackson. Deserializacja na najwyższym poziomie tak spada prosto w pułapkę wymazywania typów:
// Źle: elementy są w czasie działania LinkedHashMap
List<Item> items = mapper.readValue(json, List.class);
// Dobrze: TypeReference zachowuje parametr generyczny
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) się kompiluje i działa, ale generyki Java istnieją tylko w czasie kompilacji. W czasie działania Jackson widzi tylko List, więc elementy lądują jako LinkedHashMap. Błąd nie wybucha od razu — wybucha przy pierwszym item.getSku() gdzieś tam, rzucając ClassCastException daleko od miejsca, gdzie błąd popełniono. Trzy poprawne formy, wedle scenariusza:
// 1. Najwyższy poziom to tablica
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. Pole obiektu najwyższego poziomu
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. Jawna konstrukcja JavaType
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
Po stronie Gson odpowiednikiem jest TypeToken. Wszystkie trzy przewodniki wymieniają to jako osobną pozycję, bo zawodzi późno i zgłasza daleko.
5. Poprawka numer cztery: nieznane pola — nie pozwól, by ewolucja API popsuła deserializację
To, że API dodaje pole, to najbardziej zwyczajna ewolucja. Domyślne zachowanie Jacksona to jednak rzucanie UnrecognizedPropertyException na nieznane pola — dostawca doda pole, a Twój serwis zaczyna się walić. Wyłącz to na dwóch poziomach:
// Poziom klasy: toleruj nieznane tylko w tej klasie
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// Globalnie: cały mapper je toleruje
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Gdy to wyłączysz, pojawia się lustrzany problem: tolerancja wycisza też źle wpisane nazwy pól, otwierając wrota trybowi awarii „cichy null” z poprzedniej sekcji. Droga środka to produkcja z wyłączoną tolerancją i zestaw testów z nią włączonym, by literówki wychodziły przy testowaniu. Gson domyślnie ignoruje nieznane pola — dlatego sprawdzi się bez wysiłku przy szybkich integracjach, a ceną tej wygody jest to, że nigdy nie wiesz, co dostawca dodał.
6. Poprawka numer pięć: daty i konstruktor bezargumentowy
Generator mapuje created_at na String, co jest zresztą najbezpieczniejszym domyślnym wyborem: w JSON data to po prostu string, a format na przewodzie może być ISO-8601, timestamp Uniksa albo coś własnego — próbka nie może Ci powiedzieć, który chcesz. Gdy potrzebujesz prawdziwego typu datowego, dodaj dwie linie:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Przy serializacji emituj ISO-8601 zamiast tablic timestampów
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
Konstruktor bezargumentowy to druga obowiązkowa kontrola: Jackson deserializuje przez utworzenie obiektu jego pośrednictwem, po czym ustawia pola jedno po drugim. Napiszesz konstruktor z parametrami, a domyślny znika — deserializacja pada od razu. Z Lombokiem dodaj @NoArgsConstructor; przy record w Java 17 oddaj cały problem wsparciu recordów w Jackson 2.12+, które paruje po parametrach konstruktora i usuwa ten wymóg w całości.
7. Dwie produkcyjne sprawy: współdziel ObjectMapper i waliduj wejście
Generator daje Ci klasy; dwa punkty architektoniczne zostają w czasie działania. Po pierwsze, ObjectMapper jest kosztowny w tworzeniu i jest thread-safe — współdziel jedną instancję w całej aplikacji. Wywoływanie new ObjectMapper() wewnątrz metody to najczęstszy antywzorzec wydajnościowy, a instancja wstrzykiwana domyślnie przez Spring Boot istnieje właśnie po to. Naming strategia, JavaTimeModule i polityka nieznanych pól należą do tej jednej instancji, nie rozsiane po miejscach wywołań.
Po drugie, udana deserializacja mówi, że typy się zgadzają, a nie że wartości są legalne. Ujemna cena, zerowa ilość, zdeformowany e-mail — Jacksonowi to obojętne. Walidacja wejścia należy do Bean Validation (hibernate-validator), doklejona jako adnotacje na wygenerowanym szkielecie:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
W Springu @Valid przechwytuje żądanie, zanim dotrze do logiki biznesowej. Ta warstwa jest czymś, czego generator nigdy nie dostarczy — potrafi z próbki odczytać, jak pole wygląda, ale nie jakie wiązanie biznesowe pole niesie.
8. Gdzie kończy się generowanie, a zaczyna pisanie ręczne
Kiedy generować, a kiedy pisać ręcznie, to jasna linia:
- Użyj generatora: odpowiedzi z wieloma polami, jednorazowe integracje zewnętrzne, szybkie prototypy. Eliminuje dokładnie jedną klasę błędów — mechaniczne pomyłki przy przepisywaniu — i w kilka sekund wręcza szkielet z wyrównanymi nazwami i typami.
- Pisz ręcznie lub przerabiaj: pieniądze i długie liczby całkowite wymagają zmiany typu, naming strategia wymaga decyzji globalnej, kluczowe obiekty domenowe wymagają adnotacji walidacyjnych. To są decyzje biznesowe, których przykładowy payload nie podejmie.
- Wspólny warunek wstępny dla obu: najpierw potwierdź, że źródłowy JSON jest poprawny. Generator chętnie wyprodukuje klasy z popsutego payloadu i zostawi pułapkę na deserializację; przepuść go najpierw przez naszą stronę walidacji. Dziesięć sekund, tak czy owak.
Generator pokrywa „od zera do szkieletu”. Pięć poprawek z tego artykułu pokrywa „od szkieletu do produkcji”. Kolejności nie da się odwrócić i nie da się pominąć żadnego kroku.
FAQ
Czy wygenerowane klasy można użyć bezpośrednio w Spring Boot?
Na etapie kompilacji tak. Przed uruchomieniem warto zrobić trzy uzupełnienia: pola pieniężne na BigDecimal, naming strategię lub @JsonProperty dla API w snake_case i @JsonIgnoreProperties(ignoreUnknown = true) na przetrwanie ewolucji API. Adnotacje Bean Validation zgodnie z Twoimi regułami biznesowymi.
Dlaczego generator mapuje każdą liczbę na Double, a nie long lub BigDecimal?
Próbka odróżnia „całkowite vs. dziesiętne”, ale nie znaczenie biznesowe. 2 może być ilością albo ID; dwudziestocyfrowy numer zamówienia i ¥19.99 to w JSON po prostu liczby. Pozostawienie decyzji semantycznych użytkownikowi to rozsądna granica dla generatora forward — kosztem jest to, że tabelę podstawień z sekcji 2 musisz przejść sam.
@Data z Lomboka czy ręcznie pisane gettery/settery — które?
Jeśli zespół już używa Lomboka, weź @Data dla najmniej boilerplate'u. Jeśli wolisz uniknąć wtyczki w czasie kompilacji albo konfliktów z innymi procesorami adnotacji, pisz ręcznie. Jacksonowi wszystko jedno; patrzy na gettery/settery albo widoczność pól.
Zagnieżdżone obiekty stają się statycznymi klasami wewnętrznymi — czy to ma znaczenie?
Dla Jacksona nie; statyczna klasa wewnętrzna może być instancjonowana niezależnie. Do długoterminowego utrzymania większość zespołów wyciąga je jako klasy najwyższego poziomu do pakietu, dla ponownego użycia i izolowanego testowania. Struktura klas wewnętrznych generatora jest zbudowana do szybkiego podglądu, nie do żywej codebase'u.
Pole List rzuca ClassCastException podczas deserializacji — jak to namierzyć?
Najpierw sprawdź, czy deserializacja nie użyła nagiej List.class: elementy są w rzeczywistości LinkedHashMap i błąd wybucha przy odczycie pola. Przełącz się na TypeReference. Jeśli nadal pada, wklej wyjątek na naszą stronę wyszukiwania komunikatów o błędach i idź za wpisem dla Twojego języka.