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 (номера заказов, snowflake-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. Такая запись на верхнем уровне падает прямо в ловушку стирания типов:
// Ошибочно: элементы в рантайме — 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 существуют только во время компиляции. В рантайме 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 и валидация входа
Генератор даёт классы; в рантайме остаются два архитектурных пункта. Во-первых, 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 напрямую?
Во время компиляции — да. Перед рантаймом стоит сделать три дополнения: денежные поля на 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 — нет; статический внутренний класс инстанцируется независимо. Для долгосрочной поддержки большинство команд выносит их как классы верхнего уровня в пакет — для повторного использования и отдельного тестирования. Структура внутренних классов генератора создана для быстрого осмотра, а не для живой кодовой базы.
Поле List кидает ClassCastException при десериализации — как локализовать?
Сначала проверьте, не идёт ли десериализация через голый List.class: элементы на самом деле LinkedHashMap, и ошибка взрывается при чтении поля. Перейдите на TypeReference. Если всё ещё падает, вставьте исключение на нашу страницу поиска по тексту ошибки и пройдите пункт для вашего языка.