javaspringspring-bootrabbitmqspring-amqp

@RabbitListener with containerFactory configured with Jackson2JsonMessageConverter using a class mapper doesn't convert to mapped object


In the Consumer app, I have the following configuration:

@Configuration
public class RabbitMqConfiguration {

  ...

  @Bean
  public SimpleRabbitListenerContainerFactory rabbitMqListenerContainerFactory(
      SimpleRabbitListenerContainerFactoryConfigurer configurer) {
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setMessageConverter(jsonConverterWithClassMapping());
    configurer.configure(factory, new CachingConnectionFactory());

    return factory;
  }

  @Bean
  public MessageConverter jsonConverterWithClassMapping() {
    Jackson2JsonMessageConverter jsonConverter = new Jackson2JsonMessageConverter();
    jsonConverter.setClassMapper(classMapper());
    return jsonConverter;
  }

  @Bean
  public DefaultClassMapper classMapper() {
    DefaultClassMapper classMapper = new DefaultClassMapper();
    Map<String, Class<?>> idClassMapping = new HashMap<>();
    idClassMapping.put("email.verification", EmailVerificationDto.class);
    idClassMapping.put("webhook.verification", WebhookVerificationDto.class);
    classMapper.setIdClassMapping(idClassMapping);
    return classMapper;
  }
}

The consumer service:

@Service
@Slf4j
public class RabbitMqConsumer {

  @RabbitListener(
      queues = "notification.verification",
      containerFactory = "rabbitMqListenerContainerFactory")
  public void notificationVerification(Object message) {
    log.info(String.format("Got notification method verification type: %s", message));
  }
}

The producer app sends to the same queue (notification.verification) EmailVerificationDto or WebhookVerificationDto which are converted to JSON in the Message body. I would expect the consumer method (RabbitMqConsumer:notificationVerification) to receive the Object message being either EmailVerificationDto or WebhookVerificationDto, which means the message param to be deserialized into some of those 2 objects (EmailVerificationDto or WebhookVerificationDto) since the rabbitMqListenerContainerFactory is configured with Jackson2JsonMessageConverter with the classMapper, but Object is always a org.springframework.amqp.core.Message object, so conversion from Message.body is not done into the objects

I'm able to get the conversion into those objects by using this configuration:

@Bean
    public SimpleMessageListenerContainer listenerContainer() {
        SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();
        container.setConnectionFactory(connectionFactory());
        container.setQueueNames(MAPPED_QUEUE);
        container.setMessageListener(messageListener());
        return container;
    }

    @Bean
    MessageListenerAdapter messageListener() {
        MessageListenerAdapter messageListener = new MessageListenerAdapter(new BasicHandler());
        messageListener.setMessageConverter(jsonConverterWithClassMapping());
        return messageListener;
    }
Where BasicHandler:
    public class BasicHandler {
      public void handleMessage(Object object) {
          // object here is either EmailVerificationDto or WebhookVerificationDto
          System.out.println("Got a " + object);
      }
    }

In this case, the handleMessage method always receives as the param Object object either EmailVerificationDto or WebhookVerificationDto. But I'd like to use the @RabbitListener tag together with the container factory.

And this is my question: Is there any way to get the conversion into those 2 objects by using the rabbitMqListenerContainerFactory as I explained above? There are other ways to make this work like reading a "TypeId" from the Message headers to figure out the data that are sent and make the conversion using Jackson objectMapper but I would like to make this work since I am already using the Jackson2JsonMessageConverter.

As extra info, the Producer app uses the Jackson2JsonMessageConverter in the RabbitTemplate message converter and adds the "TypeId" header to the message before sending:

Configuration:

  @Bean
  public RabbitTemplate rabbitTemplate() {
    RabbitTemplate template = new RabbitTemplate(connectionFactory());
    template.setMessageConverter(new Jackson2JsonMessageConverter());
    //    template.setMandatory(true);
    return template;
  }

Producer:

  message
          .getMessageProperties()
          .setHeader("__TypeId__", typeIdHeader);
  rabbitTemplate.send(notificationMethodVerificationFailedQueue.getActualName(), message);

Anyway, the producer and consumer work properly and I've checked that "TypeId" and the Message body contain the correct serialized JSON object, the only issue in the conversion in the consumer.

Any ideas?


Solution

  • Looks like you would like to rely on the __TypeId__ header instead of inferred logic by default. So, take a look into this option of that Jackson2JsonMessageConverter:

    /**
     * Set the precedence for evaluating type information in message properties.
     * When using {@code @RabbitListener} at the method level, the framework attempts
     * to determine the target type for payload conversion from the method signature.
     * If so, this type is provided in the
     * {@link MessageProperties#getInferredArgumentType() inferredArgumentType}
     * message property.
     * <p> By default, if the type is concrete (not abstract, not an interface), this will
     * be used ahead of type information provided in the {@code __TypeId__} and
     * associated headers provided by the sender.
     * <p> If you wish to force the use of the  {@code __TypeId__} and associated headers
     * (such as when the actual type is a subclass of the method argument type),
     * set the precedence to {@link Jackson2JavaTypeMapper.TypePrecedence#TYPE_ID}.
     * @param typePrecedence the precedence.
     * @see DefaultJackson2JavaTypeMapper#setTypePrecedence(Jackson2JavaTypeMapper.TypePrecedence)
     */
    public void setTypePrecedence(Jackson2JavaTypeMapper.TypePrecedence typePrecedence) {
    

    More info in docs: https://docs.spring.io/spring-amqp/reference/amqp/message-converters.html#Jackson2JsonMessageConverter-from-message

    UPDATE

    Thank you for the sample!

    So ran it in debug mode and I indeed see that MessagingMessageListenerAdapter is getting correct conversion after its toMessagingMessage(amqpMessage). But then the handling goes to the InvocableHandlerMethod where its getMethodArgumentValues(message, providedArgs) resolves your Object parameter into a first AMQP Message instance in those providedArgs:

    return this.handlerAdapter.invoke(message, amqpMessage, channel, amqpMessage.getMessageProperties());
    

    We probably need to look into fixing that MessagingMessageListenerAdapter somehow to skip mapping from Message to the Object, but really trying to follow whatever we claim in the docs: https://docs.spring.io/spring-amqp/reference/amqp/receiving-messages/async-annotation-driven/enable-signature.html

    A non-annotated element that is not one of the supported types (that is, Message, MessageProperties, Message<?> and Channel) is matched with the payload.

    Which is not true after our investigation.

    As a workaround I suggestion to use org.springframework.messaging.Message as an argument instead:

    public void listenForJsonMappedMessages(Message<?> message) {
        Object mappedObject = message.getPayload();