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 (номера заказов, 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. Если всё ещё падает, вставьте исключение на нашу страницу поиска по тексту ошибки и пройдите пункт для вашего языка.