JSON у Java-клас: генератор дає лише каркас, для продакшену потрібно ще п'ять речей
Головне: Практичний вимір того, що насправді видає генератор JSON → Java——числа стають Double, вкладені об'єкти — статичними внутрішніми класами, а жодної анотації Jackson не з'являється. Точність грошей, перейменування полів, стирання узагальнень, невідомі поля та дати — п'ять речей, які доведеться додати власноруч, з кодом, який можна скопіювати.

Коротко: згенерований клас компілюється одразу, але не готовий до продакшену. Виміряні значення за замовчуванням нашого генератора: number→Double, вкладені об'єкти→статичні внутрішні класи, жодної анотації Jackson. Точність грошей, перейменування полів, стирання узагальнень, невідомі поля та дати — п'ять речей, які він ніколи не зробить за вас.
Перетворення відповіді API на Java-класи — механічна праця: читайте JSON, копіюйте поля, огортайте їх у гетери та сетери. Ціна механічної праці — невидимий ніж: помилка в одній літері назви властивості не спричиняє помилки компіляції. Поле мовчки приходить null, і ви дізнаєтесь про це з журналу продакшену. Генерація структури класу існує, щоб узгодити назви й типи з першого дня. Але генератор може узгодити лише те, що видно у зразку.
1. Спочатку запустіть генератор: що він насправді дає
Вставте репрезентативну відповідь у нашу сторінку інструменту JSON → 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"
}
Згенерований каркас (типові опції, useLombok вимкнено):
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; }
// ...решта гетерів/сетерів за тим самим зразком
}
Buyer та Item — окремі статичні внутрішні класи. Правила мапування по черзі:
| Форма в JSON | Згенерований тип Java | Примітки |
|---|---|---|
"name": "王五" |
String |
Рядки завжди стають String |
"qty": 2 |
Double |
Усі числа стають Double, див. розділ 2 |
"vip": true |
Boolean |
Обгортка, null дозволено |
"buyer": {...} |
Buyer(статичний внутрішній клас) |
Вкладені об'єкти стають окремим класом |
"items": [...] |
List<Item> |
Тип елемента береться з першого елемента |
order_id |
orderId |
snake_case автоматично перетворюється на camelCase |
Назви класів отримують PascalCase (Root), поля — camelCase (orderId); конфлікти з ключовими словами Java (class, new, int) автоматично вирішуються додаванням суфікса. Три опції: packageName, className та useLombok, яка перемикає між @Data та рукописними гетерами/сетерами. З Lombok увесь блок шаблонного коду стискається до кількох анотацій.
Дві межі цього каркаса варто знати заздалегідь. По-перше, він не генерує жодної анотації Jackson/Gson — ані @JsonProperty, ані @JsonIgnoreProperties, ані @JsonFormat, бо анотації визначають, як виконується десеріалізація, а зразок не розкриває вашу політику виконання. По-друге, зворотного перетворення немає: виведення JSON з Java-класу поза можливостями — той самий односпрямований дизайн, що й шлях JSON → TypeScript.
2. Виправлення номер один: Double — не тип для грошей
Генератор мапує "price": 19.99 у Double. Це компілюється, присвоюється — і все одно неправильний тип. Двійковий рухомий кома не може представити 0.1, а отже, і більшість десяткових дробів:
System.out.println(19.99); // 19.99 — виглядає нормально
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
Окреме друкування 19.99 виглядає чисто, бо Java застосовує найкоротше представлення; помилка вилазить, щойно значення потрапляє в арифметику. Усі три інженерні матеріали сходяться: ціни, суми та ставки мають бути BigDecimal, сконструйованим з рядка — new BigDecimal("19.99"), а не new BigDecimal(19.99), який заносить похибку double прямо всередину.
Механічне виправлення — прохід після генерації: замініть Double на BigDecimal лише для грошових полів. Пов'язана пастка на тому самому корені: цілі числа понад 2^53 (номери замовлень, сніжинки-ID), що йдуть через Double, мовчки втрачають останні цифри — 20-цифрове число, що втрачає три останні, є виміряним, відтворюваним результатом, і механізм той самий, що описано у статті чому JSON втрачає точність великих чисел. Мапуйте такі поля на String або Long і дозвольте цифрам подорожувати як тексту.
| Справжнє значення поля | Генератор дає | Замінити на |
|---|---|---|
| Гроші, ставки | Double |
BigDecimal(конструктор із рядка) |
| Номери замовлень, ID(можуть перевищити 2^53) | Double |
String або Long |
| Кількості, номери сторінок | Double |
Integer / Long |
| Справжні наукові дроби | Double |
залишити |
3. Виправлення номер два: невідповідність назв фіксується @JsonProperty
Перетворення order_id на orderId — доброзичлива здогадка: генератор припускає, що ви хочете camelCase з боку Java. Jackson за замовчуванням зіставляє ключі JSON із назвами полів буквально. Коли вони не збігаються, нічого не ламається — orderId просто залишається null. Два виправлення:
На рівні поля — додайте @JsonProperty, одна анотація фіксує одне мапування:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
Глобально — змініть стратегію найменування ObjectMapper, і покрокові анотації не знадобляться ніде в проєкті:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
Вибір залежить від площі: один фіксований API — використовуйте рівень поля; вся команда спілкується зі службами у snake_case — глобальна стратегія. Один негативний факт заслуговує власного абзацу: помилкова назва ламається як мовчазний null, а не як виняток. Сторінка інструменту jsonformater вважає це саме тією причиною, чому генератори існують: механічне копіювання — саме там пік цих помилок. Тож після додавання анотацій напишіть принаймні один тест-твердження, що ключові поля не null.
4. Виправлення номер три: стирання типів — ніколи не десеріалізуйте в голий List
Генератор дає вам оголошення поля List<Item>, але те, що насправді наповнює цей список, — Jackson. Такий запис на верхньому рівні падає прямо в пастку стирання типів:
// Помилково: елементи в runtime — LinkedHashMap
List<Item> items = mapper.readValue(json, List.class);
// Правильно: TypeReference зберігає узагальнений параметр
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) компілюється й працює, але узагальнення Java існують лише під час компіляції. У runtime Jackson бачить лише List, тож елементи осідають як LinkedHashMap. Помилка не спрацьовує одразу — вона вибухає на першому item.getSku() десь в іншому місці, кидаючи ClassCastException далеко від місця помилки. Три правильні форми за сценарієм:
// 1. Верхній рівень — сам масив
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. Поле об'єкта верхнього рівня
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. Явна побудова JavaType
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
На боці Gson еквівалент — TypeToken. Усі три матеріали виокремлюють це власним пунктом, бо воно ламається пізно і звітує далеко.
5. Виправлення номер чотири: невідомі поля — не дозволяйте еволюції API зламати десеріалізацію
Додавання поля API — найзвичайніша еволюція. Але типова поведінка Jackson — кидати UnrecognizedPropertyException на невідомих полях: постачальник додає одне поле, і ваш сервіс починає помилятися. Вимкніть це на двох рівнях:
// Рівень класу: толерувати невідомі лише в цьому класі
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// Глобально: увесь mapper толерує
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Після вимкнення з'являється дзеркальна проблема: толерантність також приглушує одруківки в назвах полів, відкриваючи режим «мовчазного null» з попереднього розділу на повну. Компроміс: у продакшені тримайте вимкнено, у тестах — увімкнено, щоб одруківки спливали під час тестування. Gson типово ігнорує невідомі поля — тому він здається безтурботним у швидких інтеграціях, а ціна цього комфорту — ви ніколи не дізнаєтесь, що додав постачальник.
6. Виправлення номер п'ять: дати та конструктор без аргументів
Генератор мапує created_at у String, і це насправді найбезпечніший вибір за замовчуванням: у JSON дата — лише рядок, а формат на дроті може бути ISO-8601, Unix-міткою чи власним — зразок не підкаже, який саме вам потрібен. Коли потрібен справжній тип дати, додайте два рядки:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Виводити ISO-8601 під час серіалізації замість масивів міток часу
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
Конструктор без аргументів — інша обов'язкова перевірка: Jackson десеріалізує, створюючи об'єкт через нього, а потім встановлюючи поля по одному. Напишіть конструктор із параметрами — і типового конструктора більше немає: десеріалізація падає миттєво. З Lombok додайте @NoArgsConstructor; з record у Java 17 доручіть усе підтримці record у Jackson 2.12+, яка зіставляє за параметрами конструктора, і вимога зникає повністю.
7. Два продакшен-моменти: повторне використання ObjectMapper і перевірка входу
Генератор дає класи; у runtime залишаються два архітектурні пункти. По-перше, ObjectMapper дорогий у створенні та потокобезпечний — поділіть один екземпляр на весь застосунок. Виклик new ObjectMapper() всередині методу — найпоширеніший антипатерн продуктивності, і екземпляр, який Spring Boot впорскує за замовчуванням, існує саме тому. Стратегія найменування, JavaTimeModule і політика невідомих полів належать тому одному екземпляру, а не розкиданим місцям виклику.
По-друге, успішна десеріалізація каже, що типи збіглися, а не що значення законні. Від'ємна ціна, нульова кількість, деформований email — Jackson байдужий. Валідація входу належить Bean Validation (hibernate-validator) і додається як анотації на згенерований каркас:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
У Spring @Valid перехоплює запит до того, як він дійде бізнес-логіки. Цей рівень генератор ніколи не надасть — зі зразка можна прочитати, як виглядає поле, але не який бізнес-обмеження воно несе.
8. Де закінчується генерація і починається ручна робота
Коли генерувати, а коли писати вручну — лінія прозора:
- Використовуйте генератор: відповіді з багатьма полями, разові інтеграції з третіми сторонами, швидкі прототипи. Він усуває рівно один клас помилок — механічні одруківки — і за секунди віддає каркас, узгоджений за назвами й типами.
- Рукописно або доопрацювання: гроші та довгі цілі потребують зміни типів, стратегія найменування потребує глобального рішення, ключові об'єкти домену — анотацій валідації. Це бізнес-рішення, які зразок не може прийняти.
- Спільна передумова: спершу переконайтеся, що вихідний JSON коректний. Генератор радісно створює класи з пошкодженого вхідного даних і залишає пастку на час десеріалізації; пропустіть через нашу сторінку валідації перед вставленням. Десять секунд у будь-якому разі.
Генератор покриває «від нуля до каркаса». П'ять виправлень із цієї статті покривають «від каркаса до продакшену». Порядок не можна перевернути, і жоден крок не можна пропустити.
FAQ
Чи можна використовувати згенеровані класи у Spring Boot напряму?
Під час компіляції — так. Перед runtime варто зробити три доповнення: грошові поля на BigDecimal, стратегію найменування або @JsonProperty для snake_case API та @JsonIgnoreProperties(ignoreUnknown = true) на випадок еволюції API. Анотації Bean Validation — за вашими бізнес-правилами.
Чому генератор мапує всі числа на Double, а не long чи BigDecimal?
Зразок розрізняє «ціле проти дробового», але не бізнес-сенс. 2 може бути кількістю або ID; 20-цифровий номер замовлення і ¥19.99 у JSON — просто числа. Залишення семантичних рішень користувачеві — розумна межа для прямого генератора; ціна — таблиця замін із розділу 2, яку треба пройти самостійно.
@Data від Lombok чи рукописні гетери/сетери — що обрати?
Якщо команда вже користується Lombok, беріть @Data — найменше шаблонного коду. Якщо хочете уникнути плагіна часу компіляції чи конфліктів з іншими процесорами анотацій, пишіть руками. Jackson байдужий: він дивиться на гетери/сетери або видимість полів.
Вкладені об'єкти стають статичними внутрішніми класами — це має значення?
Для Jackson — ні; статичний внутрішній клас інстанціюється незалежно. Для довгострокової підтримки більшість команд виносить їх як класи верхнього рівня в пакет — для повторного використання й окремого тестування. Структура внутрішніх класів генератора створена для швидкого огляду, а не для живої кодової бази.
Поле List кидає ClassCastException під час десеріалізації — як локалізувати?
Спершу перевірте, чи десеріалізація не йшла через голий List.class: елементи насправді LinkedHashMap, і помилка вибухає при читанні поля. Перейдіть на TypeReference. Якщо досі падає, вставте виняток на нашу сторінку пошуку за текстом помилки і пройдіть пункт для вашої мови.