Jackson

Jackson преобразует JSON и Java-модель. У него три уровня API:

  • Streaming (JsonParser, JsonGenerator) — минимальная память и ручной контроль tokens;
  • Tree model (JsonNode) — изменяемое дерево для динамической структуры;
  • Data binding (ObjectMapper) — преобразование POJO/record по типам и annotations.

Базовый пример

public record OrderDto(long id, String status) {}
 
ObjectMapper mapper = JsonMapper.builder()
        .findAndAddModules()
        .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        .build();
 
String json = mapper.writeValueAsString(new OrderDto(42, "PAID"));
OrderDto order = mapper.readValue(json, OrderDto.class);

ObjectMapper дорог в создании и содержит caches. Обычно на приложение настраиваю один instance и затем переиспользую. После начала параллельного использования не меняю его конфигурацию.

Generics и стирание типов

Так тип элемента теряется:

List<?> values = mapper.readValue(json, List.class);

Нужно передать полный тип:

List<OrderDto> values = mapper.readValue(
        json,
        new TypeReference<List<OrderDto>>() {}
);

Эволюция контракта

При чтении внешнего API неизвестное поле часто допустимо, поэтому client может отключить FAIL_ON_UNKNOWN_PROPERTIES или точечно применить annotation. Но опечатка в собственном строгом config тогда тоже останется незамеченной. Политику выбираю по границе контракта, а не глобально “для удобства”.

Имена JSON-полей — часть внешнего контракта:

public record UserDto(
        @JsonProperty("user_id") long id,
        String name
) {}

Переименование Java field не должно случайно ломать wire format.

Даты, null и отсутствующее поле

Для Java Time подключается соответствующий module. Формат и timezone фиксируются контрактом. В PATCH нужно различать:

  • field отсутствует — не менять;
  • field равен null — очистить;
  • field содержит значение — установить.

Обычный nullable DTO не всегда выражает эти три состояния.

Опасные графы

Двунаправленные ORM-связи могут дать рекурсию parent → children → parent, lazy queries или утечку внутренних полей. В web-контракте использую отдельный DTO/projection, а не сериализую entity.

Полиморфная десериализация с автоматическим выбором класса опасна для недоверенного input. Разрешаю только явный закрытый набор типов и не включаю широкую default typing без необходимости.

Streaming

Для очень большого массива data binding создаёт весь object graph. Streaming читает по одному token/object и ограничивает память, но требует ручной валидации и закрытия parser.

Типичные сбои

  • Создавать новый ObjectMapper на каждый request.
  • Читать List.class и получить LinkedHashMap вместо DTO.
  • Сериализовать Hibernate entity с lazy graph.
  • Путать отсутствующее поле и null в partial update.
  • Не ограничить polymorphic types для внешнего JSON.
  • Принять локальную timezone JVM за часть API-контракта.

Вопросы для собеседования

Когда выбирать streaming API? Для большого/потокового JSON или минимального allocation.

Почему нужен TypeReference? Он сохраняет описание generic type для runtime-десериализации.

Можно ли разделять ObjectMapper между threads? Да, после завершённой конфигурации; перенастраивать используемый instance не стоит.

Почему entity не лучший response DTO? Контракт связывается с persistence model, возможны lazy queries, циклы и раскрытие полей.

Чем отсутствующее поле отличается от null? В partial update первое часто означает “не менять”, второе — “очистить”.

См. REST, Обобщения generics, Анализ производительности ORM систем.