JSON Toolkit

JSON у Java-клас: генератор дає лише каркас, для продакшену потрібно ще п'ять речей

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

Середній~10 хв читанняОпубліковано 2026-11-04Оновлено 2026-11-04
Інтерфейс інструменту, що перетворює JSON на Java POJO класи

Коротко: згенерований клас компілюється одразу, але не готовий до продакшену. Виміряні значення за замовчуванням нашого генератора: 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. Якщо досі падає, вставте виняток на нашу сторінку пошуку за текстом помилки і пройдіть пункт для вашої мови.