Всем привет!
Пока ищу работу, продолжаю знакомить Вас с внутренностями и неочевидными моментами работы распределенной микросервисной архитектуры в мире Java Spring.
Сегодня на очереди Apache Kafka, которая является основой подавляющего большинства крупных и не очень приложений.
В свое время поиск решения с проблемой заголовка __TypeId__ изрядно попил у меня крови и я был весьма удивлен, не найдя на Хабре явно описанных решений.
Может для кого-то это и не проблема вовсе)) Но думаю многим данная статья может пригодится)
В этой статье я проведу Вас от причины возникновения этой проблемы до вариантов ее решения.
Поехали)
1. Всё работает или как туториалы создают ложное чувство безопасности
Откроем практически любой гайд по Spring Kafka с JSON-сообщениями. Картинка всегда одна:
-
Есть модуль
common-dtoс классомOrderCreatedEvent. -
Сервис-продюсер подключает этот модуль и отправляет событие через
KafkaTemplateс дефолтнымJsonSerializer. -
Сервис-консюмер подключает тот же модуль, вешает на метод
@KafkaListenerи принимаетOrderCreatedEventв готовом виде.
Вот минимальный код, который работает из коробки:
Продюсер:
private final KafkaTemplate<String, Object> kafkaTemplate;public void sendEvent(OrderCreatedEvent event) { kafkaTemplate.send("orders", event);}
Консюмер:
@KafkaListener(topics = "orders")public void handle(OrderCreatedEvent event) { System.out.println("Received: " + event);}
И всё. Без JsonDeserializer явно, без @Payload, без маппингов. Просто объект туда, объект обратно. Запускается и работает. И большинство туториалов заканчиваются на этом месте, создавая у разработчика иллюзию, что Spring сам всё разруливает и все решит за них.
Но что именно разруливает?
-
JsonSerializerпри отправке автоматически добавляет в заголовокTypeIdс полным именем класса:"com.example.common.OrderCreatedEvent". -
JsonDeserializerпри получении читает этот заголовок и вызываетClass.forName()по нему. -
Поскольку класс есть в classpath консюмера (из
common-dto), десериализация проходит без сучка без задоринки.
Эта гладкая картинка работает ровно до тех пор, пока оба сервиса делят одну общую библиотеку с DTO. Но именно она формирует у команды уверенность: «Мы настроили Kafka, всё просто, давайте делать так везде».
А дальше проект растёт. Сервисов становится больше. Выносить общие DTO в отдельный репозиторий становится неудобно и кто-то решает, что у каждого сервиса будет свой класс с теми же полями, ведь это же просто структура данных.
И тут туториал заканчивается, а проблема только начинается.
2. Почему просто сделать common-dto не бесплатное решение
На этом месте многие из Вас, вероятно, подумали: «Ну и что? Я просто создам общий модуль, подключу его во все сервисы и проблема решена».
Да, решена. Но какой ценой?
Общая библиотека доменных DTO — это классический компромисс, который в краткосрочной перспективе кажется удобным, а в долгосрочной превращается в немаленькую такую проблему, особенно при неудачном планировании.
Вот почему:
Деплой-связанность через чёрный ход.
Kafka выбрана именно затем, чтобы продюсер и консюмер не знали о времени жизни друг друга. Общий JAR этот принцип нарушает. Изменилась структура события (добавилось поле, изменился тип) — нужно поднимать версию common-dto, пересобрать и передеплоить все сервисы-потребители. Даже тем, кому это поле не нужно. Вместо асинхронной эволюции вы получаете синхронный релизный цикл.
Ничейное владение.
Кто принимает PR в common-dto, если класс исторически принадлежит команде-продюсеру, но правку предлагает команда-консюмер? Формально, владелец репозитория. Фактически — никто не хочет брать ответственность.
Это классическое проявление закона Конвея: архитектура системы копирует структуру коммуникаций в команде. Если вы не можете договориться о том, кто владеет контрактом проблема не техническая, а организационная. И общая библиотека только маскирует её.
DTO как «божественный объект».
Со временем в общий класс добавляют поля под нужды каждого нового потребителя. Никто не решается их удалить, вдруг кто-то ещё что использует. Через год вы смотрите на OrderCreatedEvent с 30 полями, половина из которых помечена @Deprecated или @JsonIgnore(condition = ...).
Это не контракт события. Это свалка опциональных полей, о происхождении которой не помнит никто.
Честная оговорка. Общие библиотеки не зло сами по себе. Для стабильных технических артефактов (клиент трейсинга, формат ошибок, общие утилиты) шеринг оправдан и полезен. Проблема именно с доменными событиями и командами. Они меняются вместе с бизнес-логикой чаще всего. И именно здесь подход одна DTO на всех бьёт больнее всего. Но это тема отдельной статьи.
3. Ломаем common-dto. Криминалистика падения
Итак, мы послушали доводов из предыдущей части статьи и убрали общий модуль.
В сервисе-продюсере класс OrderCreatedEvent лежит в пакете com.producer.event. В сервисе-консюмере структурно идентичный класс, но в пакете com.consumer.model. Конфигурация дефолтная. Запускаем консюмер.
Как на самом деле работает сериализация
Большинство разработчиков мысленно представляют путь сообщения как:
POJO → ObjectMapper → JSON → Получатель → ObjectMapper → POJO
В этой модели JSON это платформенно-независимый формат, и получателю достаточно знать структуру полей.
Но реальность Spring Kafka выглядит иначе:
POJO → JsonSerializer → JSON + заголовок __TypeId__ (полное имя класса отправителя) ↓ JsonDeserializer → Class.forName(__TypeId__) → ObjectMapper → POJO
JsonSerializer не просто превращает объект в JSON. Он добавляет в заголовок сообщения полное имя Java-класса отправителя com.producer.event.OrderCreatedEvent. JsonDeserializer при получении берёт это имя и пытается выполнить Class.forName() загрузив этот класс в JVM получателя. Только после этого он отдаёт JSON на растерзание ObjectMapper-у.
Что происходит при запуске консюмера
В сервисе-консюмере нет класса com.producer.event.OrderCreatedEvent. Есть только свой com.consumer.model.OrderCreatedEvent. JsonDeserializer видит заголовок, пытается загрузить класс отправителя и очень неожиданно… падает.
Лог примерно будет выглядит так:
2026-07-31 10:15:32.123 INFO --- [main] o.a.k.clients.consumer.ConsumerConfig : ConsumerConfig values:value.deserializer = class org.springframework.kafka.support.serializer.ErrorHandlingDeserializer...2026-07-31 10:15:32.456 ERROR --- [main] o.s.boot.SpringApplication : Application run failedorg.springframework.context.ApplicationContextException: Failed to start bean 'org.springframework.kafka.config.internalKafkaListenerEndpointRegistry'at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:181)... 20 common frames omittedCaused by: org.apache.kafka.common.KafkaException: Failed to construct kafka consumerat org.apache.kafka.clients.consumer.KafkaConsumer.<init>(KafkaConsumer.java:844)... 16 common frames omittedCaused by: java.lang.IllegalStateException: No type information in headers and no default type providedat org.springframework.kafka.support.serializer.JsonDeserializer.deserialize(JsonDeserializer.java:311)... 19 common frames omitted
Разберём, почему он выглядит именно так.
Почему падение происходит на старте приложения, а не на первом сообщении?
Строка ConsumerConfig values: печатается из конструктора KafkaConsumer. Консьюмер падает в момент своего создания до того, как он успел прочитать хоть один байт из топика. ErrorHandlingDeserializer, обёрнутый вокруг JsonDeserializer, инициализирует целевой десериализатор синхронно. Если тот не может загрузить класс из заголовка наш конструктор бросает исключение. Всё. До чтения первого сообщения дело не доходит.
Почему это KafkaException, а не ClassNotFoundException напрямую?
Class.forName() действительно бросает ClassNotFoundException. Но Kafka-клиент оборачивает любую ошибку конфигурации сериализатора/десериализатора в общее KafkaException("Failed to construct kafka consumer"). На верхнем уровне стека реальная причина не видна, её нужно искать глубже, в Caused by. В некоторых версиях вы увидите прямой ClassNotFoundException, в других IllegalStateException, как здесь. Суть одна: приложение не стартует.
Важный нюанс: В некоторых версиях Spring Kafka стектрейс обрезается тройкой точек ... прямо в логе, и самая важная строка с именем отсутствующего класса оказывается скрыта. Если вы видите KafkaException и дальше ... 19 common frames omitted это не значит, что причина исчезла. Ищите полный стектрейс в файле лога или включайте logging.level.org.apache.kafka=DEBUG.
И напоследок — почему валится весь ApplicationContext, а не только один листенер?
Failed to start bean 'org.springframework.kafka.config.internalKafkaListenerEndpointRegistry' этот бин управляет всеми @KafkaListener-контейнерами одновременно. DefaultLifecycleProcessor не запускает контекст, пока все lifecycle-бины не стартовали успешно. Если один из десяти консьюмеров не может быть сконструирован то не стартует ни один. Приложение падает целиком, даже если проблема локальна.
4. Варианты решения
Итак, мы видим проблему: дефолтный JsonDeserializer требует, чтобы класс из заголовка TypeId присутствовал в classpath получателя.
Вот варианты, что с этим можно сделать:
4.1. Отказаться от TypeId вообще — читать/писать сырой поток байт
Самый радикальный вариант: не подгружать чужие классы через заголовок, а вообще не полагаться на встроенный механизм типизации. Консюмер принимает ConsumerRecord<String, byte[]> (или String) и сам вызывает ObjectMapper.readValue():
@KafkaListener(topics = "orders")public void handle(ConsumerRecord<String, byte[]> record) throws IOException { OrderCreatedEvent event = objectMapper.readValue(record.value(), OrderCreatedEvent.class); // обработка}
Плюсы:
-
Полная независимость от Jackson-заголовков и от того, кто продюсировал сообщение. Подходит для не-Java продюсеров типа Python, Go или что угодно, что пишет валидный JSON.
-
Риск
ClassNotFoundExceptionот чужого заголовка отсутствует в принципе ведь заголовок никто не читает. -
Вся обработка ошибок десериализации находится в одном явном месте, под вашим контролем.
Минусы — и здесь начинаются сюрпризы, о которых молчат туториалы:
Первое: теряется автоматическая диспетчеризация по типу (@KafkaHandler). Если в одном топике несколько типов сообщений, приходится либо пробовать десериализовать по очереди (try-catch на каждый класс), либо скатываться в content-sniffing и искать конкретное поле в JSON-строке:
String json = new String(record.value());if (json.contains("\"transactionId\"")) { TransactionEvent event = mapper.readValue(json, TransactionEvent.class); } else if (json.contains("\"orderId\"")) { OrderCreatedEvent event = mapper.readValue(json, OrderCreatedEvent.class); }
Это классический антипаттерн. Он ломается на первом же случайном совпадении имени поля у разных типов. Добавили в оба события поле id и вся логика определения типа летит к чертям.
Второе (и самое коварное): скрытая ловушка на retry-топиках. Если в проекте есть @RetryableTopic, а листенер вручную читает byte[] или String, механизм ретраев при пересылке в retry/DLT резолвит KafkaTemplate для форварда.
Если в приложении есть другой KafkaTemplate с JsonSerializer (настроенный для иных целей), Spring может пересылать retry именно через него.
Как это происходит? Готовая JSON-строка, которую вы прочитали вручную, повторно сериализуется JsonSerializer как строка и оборачивается в кавычки и экранируется. На втором retry-хопе экранирование накапливается ещё раз:
# Первое сообщение (оригинал){"orderId": 123}# После первого retry через JsonSerializer"{\"orderId\": 123}"# После второго retry"\"{\\\"orderId\\\": 123}\""
Вместо осмысленного JSON получаем кашу из слешей и кавычек. Листенер падает с ошибкой парсинга, и вы идёте разбираться не в бизнес-логике, а в том, какой KafkaTemplate подхватил @RetryableTopic.
Вывод из этого такой: «уйти от магии Spring не значит уйти от сюрпризов поведения).
Источник сюрпризов просто сместился в другое место инфраструктуры, о существовании которого автор ручного парсинга может не подозревать.
Когда этот вариант оправдан:
-
Топик заведомо с одним типом сообщений (диспетчеризация не нужна).
-
Продюсер принципиально не на Java/Spring, и согласовывать
TYPE_MAPPINGSфизически не с кем. -
Вы готовы взять на себя ручное управление десериализацией и осознаёте риски, связанные с ретраями.
4.2. TYPE_MAPPINGS или как остаться в типобезопасном мире Spring
Второй путь это остаться в экосистеме Spring, но явно сказать десериализатору: даже не пытайся загрузить класс отправителя, вот тебе мой локальный класс для этого типа.
TYPE_MAPPINGS это не магическое свойство, а простая настройка в Map<String, Class<?>>, применяемая в обе стороны:
-
На продюсере: класс id при отправке (в заголовок уходит id, а не полное имя).
-
На консюмере: id класс при чтении (десериализатор ищет класс по id из заголовка).
Рабочий конфиг для продюсера (KafkaProducerConfig):
private Map<String, Object> baseProducerConfigs() { Map<String, Object> props = new HashMap<>(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); //иные настройки всем известные... props.put(JsonSerializer.TYPE_MAPPINGS, """ order:com.producer.event.OrderCreatedEvent """); //добавить еще маппинги можно через запятую прям в text-block return props; }
И для консюмера:
@Beanpublic ConsumerFactory<String, Object> consumerFactory() { Map<String, Object> props = new HashMap<>(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); //иные настройки всем известные... props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); props.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class); props.put(JsonDeserializer.TYPE_MAPPINGS, """ order:com.consumer.model.OrderCreatedEvent """); props.put(JsonDeserializer.TRUSTED_PACKAGES, "oleborn.order_service.order.domain"); return new DefaultKafkaConsumerFactory<>(props); }
Ключевой момент: id совпадают на обеих сторонах, а классы разные. Каждый сервис маппит один и тот же идентификатор order на свой собственный класс в своём пакете. Продюсер кладёт в заголовок order, консюмер по этому order находит com.consumer.model.OrderCreatedEvent.
Что сохраняется как бонус этого пути:
-
Типизированные листенеры. Метод
@KafkaListenerпринимает конкретныйOrderCreatedEvent, а неbyte[]. -
Диспетчеризация по
@KafkaHandler, даже если в одном топике несколько типов, можно разложить обработку по разным методам, и Spring сам подставит нужный POJO. -
Никакого ручного парсинга, никакого content-sniffing, никаких сюрпризов с ретраями, весь функционал Spring Kafka остаётся доступен.
Когда этот вариант оправдан:
-
Оба сервиса на Java/Spring, и вы готовы синхронизировать идентификаторы типов (но не классы).
-
В топике несколько типов сообщений, и хочется использовать встроенную диспетчеризацию.
-
Вы не хотите терять интеграцию с
@RetryableTopicи другими механизмами Spring Kafka.
4.3. MessageConverter: заставляем Spring смотреть на сигнатуру метода, а не в заголовок
Еще один вариант решения это не думать о TypeId , а отобрать у Kafka-клиента задачу мапить JSON в объект и передать её на уровень выше, именно туда, где Spring уже видит сигнатуру вашего метода.
Для этого в Spring Kafka существует механизм MessageConverter.
Как это работает:
-
Мы отказываемся от встроенного
JsonDeserializerна уровне настроек самой Kafka. -
Kafka-консюмер читает сообщение в виде сырого массива байт. Никаких попыток сделать
Class.forName()и падений на старте. -
Мы регистрируем в контексте Spring бин
RecordMessageConverter. -
Перед тем как вызвать ваш метод, Spring пропускает сырые данные через этот конвертер. Конвертер смотрит на тип аргумента в методе
@KafkaListener, берет из него целевой класс и спокойно десериализует JSON прямо в него, полностью игнорируя зловредныйTypeId.
Рабочий конфиг консюмера (современный подход):
Важный нюанс: В старых туториалах вы часто встретите
StringJsonMessageConverter. Начиная со Spring Kafka 4.0, этот класс помечен как@Deprecated(forRemoval=true). Кроме того, использовать строковый конвертер это двойная работа для Garbage Collector: сначала Kafka делает из байт строку, а потом Jackson её парсит. Правильный путь это использоватьByteArrayDeserializerиByteArrayJsonMessageConverter.
@Beanpublic ConsumerFactory<String, byte[]> consumerFactory() { Map<String, Object> props = new HashMap<>(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); // ... иные базовые настройки ... // Возвращаемся к сырым байтам на уровне Kafka-клиента props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, ByteArrayDeserializer.class); return new DefaultKafkaConsumerFactory<>(props);}// Тот самый бин, который решает проблему на уровне Spring@Beanpublic RecordMessageConverter converter() { return new ByteArrayJsonMessageConverter();}
А сам листенер остается девственно чистым:
// Никакой магии, просто ваш локальный класс@KafkaListener(topics = "orders")public void handle(com.consumer.model.OrderCreatedEvent event) { System.out.println("Успешно распарсили: " + event.getId());}
Плюсы:
-
Идеальная микросервисная развязка. Консюмеру абсолютно плевать, из какого пакета продюсер отправлял сообщение. Если поля в JSON совпадают с полями вашего локального класса то всё будет работать.
-
Нет проблем с ретраями. В отличие от ручного парсинга, Spring корректно понимает тип сообщения, и пересылка в DLT или retry-топики отрабатывает штатно, без бесконечного экранирования кавычек.
-
Не нужно поддерживать
TYPE_MAPPINGS. Вы не ведете словари соответствийorder:com.consumer..., забывая обновить их при добавлении нового события.
Минусы и подводные камни:
-
Проблема с
@KafkaHandler. Этот подход ломается, если в один топик летят разные типы событий (например,OrderCreatedиOrderDeleted), и вы хотите красиво маршрутизировать их внутри одного класса через@KafkaHandler. Возникает парадокс: чтобы Spring понял, какому методу отдать сообщение, ему нужно знать целевой тип. Но конвертер берет целевой тип… из сигнатуры метода! Фреймворк не сможет разорвать этот круг и упадёт с ошибкой. Для мульти-типовых топиков придется возвращаться кTYPE_MAPPINGS. -
Ловушка со сложными Generic-типами. В редких случаях, когда ваш листенер принимает сложный параметризованный тип (например,
List<MyComplexDto>), рефлексия Spring может спасовать перед стиранием типов (Type Erasure) в Java. Фреймворк неверно определит целевой класс, и на выходе вы получитеClassCastException, когда вместо DTO внутри списка окажется обычнаяLinkedHashMap. Это лечится явным использованием аннотации@Payloadнад параметром метода.
Когда этот вариант оправдан: Это самый каноничный и правильный подход для независимых микросервисов, если вы придерживаетесь правила «один топик — один тип доменного события».
5. Почему вообще TYPE_MAPPINGS работает
А давайте заглянем под капот и поймём, как именно это свойство меняет поведение сериализации и десериализации. Это знание не бесполезно: без него вы не сможете отладить ситуацию, когда маппинг вроде бы настроен, а в заголовок всё равно улетает полное имя класса.
Полная картина: от POJO до байтов и обратно
Вот как выглядит путь сообщения с учётом TYPE_MAPPINGS:
Сериализация (продюсер):POJO → JsonSerializer.serialize() ↓ getTypeMapper().fromJavaType(javaType, headers) ↓ ищет класс в idClassMapping (обратный поиск) ↓ нашёл id? → пишет id в заголовок __TypeId__ не нашёл? → пишет полное имя класса (!!!) ↓ ObjectMapper → JSON → байты в топикДесериализация (консюмер):байты из топика → JsonDeserializer.deserialize() ↓ getTypeMapper().toJavaType(headers) ↓ читает значение из заголовка __TypeId__ ↓ ищет id в idClassMapping (прямой поиск) ↓ нашёл класс? → возвращает JavaType → ObjectMapper → POJO не нашёл? → трактует значение как полное имя → Class.forName()
Ключевой элемент всей этой механики idClassMapping. Это та самая карта, которую вы заполняете через spring.json.type.mapping.
fromJavaType() или как продюсер решает, что писать в заголовок
На стороне продюсера работает метод fromJavaType() из AbstractJavaTypeMapper. Его логика (упрощённо) выглядит так:
// AbstractJavaTypeMapper — упрощённая версия логикиpublic void fromJavaType(JavaType javaType, Headers headers) { Class<?> rawClass = javaType.getRawClass(); // 1. Пытаемся найти id для этого класса в idClassMapping String id = getIdForClass(rawClass); if (id != null) { // 2a. Нашли — пишем id в заголовок headers.add(new RecordHeader("__TypeId__", id.getBytes())); } else { // 2b. Не нашли — пишем полное имя класса (откат) headers.add(new RecordHeader("__TypeId__", rawClass.getName().getBytes())); }}
Обратите внимание на критическую деталь: если класса нет в idClassMapping, метод не бросает исключение, а молчаливо откатывается на полное имя класса. Это поведение именно тот источник половины багов с TYPE_MAPPINGS.
Сама idClassMapping инициализируется из свойства TYPE_MAPPINGS через метод createMappings():
// JsonSerializer.configure() — фрагментif (!this.typeMapperExplicitlySet && getTypeMapper() instanceof AbstractJavaTypeMapper) { ((AbstractJavaTypeMapper) getTypeMapper()).setUseForKey(isKey); // ... ((AbstractJavaTypeMapper) getTypeMapper()).setIdClassMapping( createMappings((String) configs.get(TYPE_MAPPINGS)) );}
toJavaType() или как консюмер решает, в какой класс превращать
На стороне консюмера работает метод toJavaType() из того же AbstractJavaTypeMapper. Его логика зеркальна:
// AbstractJavaTypeMapper — упрощённая версия логикиpublic JavaType toJavaType(Headers headers) { // 1. Достаём значение из заголовка __TypeId__ String typeId = getTypeIdFromHeaders(headers); if (typeId == null) { return null; // нет информации о типе } // 2. Пытаемся найти класс по id в idClassMapping Class<?> targetClass = getClassForId(typeId); if (targetClass != null) { // 3a. Нашли — возвращаем JavaType для этого класса return constructJavaType(targetClass); } else { // 3b. Не нашли — трактуем typeId как полное имя класса // и пытаемся загрузить через Class.forName() return constructJavaType(Class.forName(typeId)); }}
Именно здесь происходит то самое падение, которое мы видели ранее: если в заголовке лежит com.producer.event.OrderCreatedEvent, а в idClassMapping консюмера нет маппинга для id com.producer.event.OrderCreatedEvent, метод пытается выполнить Class.forName("com.producer.event.OrderCreatedEvent") и получает ClassNotFoundException.
DefaultJackson2JavaTypeMapper реализация по умолчанию
Конкретная реализация, которая используется в JsonSerializer и JsonDeserializer по умолчанию DefaultJackson2JavaTypeMapper. Именно в этом классе живёт поле idClassMapping та самая карта, которую вы наполняете через spring.json.type.mapping.
// DefaultJackson2JavaTypeMapper расширяет AbstractJavaTypeMapperpublic class DefaultJackson2JavaTypeMapper extends AbstractJavaTypeMapper { // idClassMapping хранится в родительском AbstractJavaTypeMapper // и заполняется через setIdClassMapping()}
Важный нюанс: TypePrecedence
Есть ещё один фактор, который может повлиять на то, какой тип будет использован для десериализации, TypePrecedence. По умолчанию установлено значение INFERRED: если тип в сигнатуре метода-листенера конкретный (не абстрактный и не интерфейс), он имеет приоритет над заголовком TypeId.
// DefaultJackson2JavaTypeMapperprivate volatile TypePrecedence typePrecedence = TypePrecedence.INFERRED;
Это значит, что если ваш листенер принимает конкретный OrderCreatedEvent, Spring может проигнорировать заголовок и использовать тип из сигнатуры метода. Однако если в сигнатуре Object или интерфейс, приоритет переходит к заголовку, и тогда вступают в силу все описанные выше механизмы.
6. А зачем Spring вообще так сделал?
Прежде чем записать JsonSerializer/JsonDeserializer в разряд плохо спроектированных, стоит честно посмотреть на задачу, которую они решали.
Spring Kafka появлялся в экосистеме, где типичный сценарий выглядел так: один сервис (или монолит) читает и пишет в топик, а классы DTO лежат в общем модуле. В этом мире TypeId не баг, а фича.
Он решает реальную проблему, которая без него вообще не имеет хорошего решения:
Проблема: в одном топике могут лететь сообщения разных типов: OrderCreated, OrderCanceled, PaymentFailed. KafkaTemplate параметризован как KafkaTemplate<String, Object> продюсер кидает туда любые объекты. Консюмер должен понять, в какой конкретный класс превращать JSON.
Без заголовка TypeId: консюмер получает байты JSON и не знает, какую структуру ожидать. Можно было бы вынести тип в отдельное поле самого JSON (как делают в некоторых системах), но тогда ObjectMapper всё равно нужно знать целевой класс заранее в сигнатуре метода, а это не динамично.
С заголовком TypeId: проблема решается элегантно. Продюсер кладёт в заголовок идентификатор типа, консюмер по нему определяет класс. В мире, где класс доступен с обеих сторон, это работает бесшовно.
Решение логичное. Просто оно было спроектировано для мира, где классы общие. Где есть единая кодовая база, общий модуль DTO, и все участники обмена имеют доступ к одним и тем же Java-классам.
Микросервисная развязка пришла позже. Принцип сервисы не знают о внутренних классах друг друга это архитектурное требование, которое сформировалось уже поверх экосистемы Spring. Оно не было изначальным сценарием использования JsonSerializer. Библиотека решала свою локальную задачу и решала её хорошо для того контекста, в котором создавалась.
Так что дело не в том, что Spring Kafka плохо спроектирована. Дело в том, что её дефолты это оптимальное решение для одной архитектурной парадигмы (монолит/общая кодовая база), которое становится ловушкой в другой (независимые микросервисы). И ответственность за выбор между ними лежит не на фреймворке, а на разработчике, который знает, в каком мире он работает.
7. Интересное
Интересно, что в 2020 году сообщество уже просило брать тип объекта из сигнатуры метода. Но мейнтейнер ответил, что десериализатор находится слишком глубоко в стеке и он ничего не знает о методе, который будет вызван. Решение — использовать String-десериализатор вместе с JsonMessageConverter, который уже знает целевой тип. Как-то так…
Кроме того я заметил очепятку в офф Javadoc библиотеки Spring Kafka:
В документации разных версий встречаются противоречивые примеры формата TYPE_MAPPINGS:
Реальный парсер в исходниках (JsonSerializer.createMappings()) жёстко проверяет разделитель:
Assert.isTrue(split.length == 2, "Each comma-delimited mapping entry must have exactly one ':'")
То есть рабочий разделитель это исключительно только двоеточие (:).
Знак равенства (=) в любом маппинге приведёт к ошибке конфигурации. А документация в разных версиях противоречит сама себе в одном и том же классе JsonDeserializer в разных релизах могут быть разные примеры.
8. Итоги
Вернёмся к тому, с чего начали:
Spring Kafka по умолчанию сериализует не только данные, но и знание о Java-классе.
Именно эта формула источник всех проблем, которые мы разобрали. JsonSerializer добавляет в заголовок TypeId полное имя класса отправителя. JsonDeserializer пытается загрузить этот класс через Class.forName(). В мире общей DTO-библиотеки это работает. В мире независимых микросервисов это ломается при старте контейнера, ещё до того, как прочитано первое сообщение.
Что выносим с собой
Перед тем как запустить продюсера и консюмера в разных репозиториях пройдите по этому чек-листу:
1. Проверьте TYPE_MAPPINGS на симметрию.
Он должен быть настроен на обеих сторонах. Продюсер маппит свой класс → id, консюмер — тот же id → свой класс. Не полагайтесь на то, что настроил у себя и хватит. Если маппинг есть только на одной стороне, сработает откат и в заголовок улетит полное имя класса.
2. Тестируйте после любого апгрейда Spring Boot.
Есть реальные кейсы в Issue (не стал включать чтоб совсем не перегружать статью), когда TYPE_MAPPINGS переставал работать при переходе с 3.2.6 на 3.3.0 без единой строчки изменений в коде. Это не единичный баг это признак того, что вся механика конвертации чувствительна к версиям. Напишите тест, который проверяет не только сообщение пришло, но и какое значение лежит в заголовке TypeId.
3. Не доверяйте копипасте из документации.
Даже официальный Javadoc в разных версиях противоречит сам себе: в одном месте пример с =, в другом с :. Реальный парсер в исходниках принимает только двоеточие. Прогоняйте любое изменение через тесты на своей версии библиотеки.
4. Если выбрали ручной парсинг (byte[]/String) то проверьте ретраи.@RetryableTopic может подхватить KafkaTemplate с JsonSerializer для форварда в DLT. Готовая JSON-строка, прочитанная вручную, повторно сериализуется как строка с экранированием и кавычками, которые накапливаются с каждым ретраем.
Материал подготовлен автором telegram-канала о изучении Java.
ссылка на оригинал статьи https://habr.com/ru/articles/1065138/