ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot集成RabbitMQ:如何配置JSON消息转换器及踩坑指南

SpringBoot集成RabbitMQ:如何配置JSON消息转换器及踩坑指南

先说一个我最近在几个项目里反复见到的问题:生产端用RabbitTemplate.convertAndSend()发了一个对象,消费端@RabbitListener方法里收到的却是一堆byte[],或者是一个半死不活的LinkedHashMap。很多人第一反应是“网络传输本来就是字节流,手动转一下很正常”,于是开始在每个发送点写objectMapper.writeValueAsString()、在每个消费点写objectMapper.readValue()。等消息多起来,序列化逻辑散得满项目都是,这时候才想起来,Spring AMQP其实早就给了你一套统一收口的方式——消息转换器。

这篇文章要聊的就是SpringBoot集成RabbitMQ时,如何配置JSON格式的消息转换器:为什么默认的SimpleMessageConverter不满足生产需求、Jackson2JsonMessageConverter背后做了什么、最小可用的配置怎么写、以及我实际踩过的几个坑和排查思路。适合正在用SpringBoot做RabbitMQ开发,并且被“消息体乱码”“消费端类型对不上”“LocalDateTime序列化报错”这类问题困扰的同学参考。

1. 默认情况下,一个消息对象是怎么变成字节流的

1.1 消息的body本质上就是byte数组

很多第一次接触RabbitMQ的同学都有一个认知偏差:以为发送的是一个Java对象,Broker上存的就是这个对象。不是的。RabbitMQ的协议层根本不认识Java对象,一条消息的body永远是一串二进制字节,附加的业务信息全部放在消息头(headers)里。你在业务代码里看到的“发一个User对象”,只是开发框架层面给你的便利,真正落到队列里的仍然是字节。

Spring AMQP在这中间加了一层组件,叫MessageConverter。它的职责拆开看就两件事:发送端把Java对象转成Message(由body和MessageProperties两部分组成),消费端再把Message还原回Java对象。RabbitTemplate.convertAndSend()这个方法名里的“convert”指的就是这一层转换。只要你没手动配置过,Spring Boot默认会注入一个SimpleMessageConverter,它做的事情非常朴素:用Java原生的序列化机制,把对象写到字节数组里。

1.2 Java原生序列化带来的三个隐患

默认方案在一开始通常不会出问题,但一旦项目变大,你一定会想换掉它。我归纳了三个最现实的原因。

第一个是跨语言没法用。Java原生序列化格式只有Java自己认识,如果消费端是Python、Go或者其他语言写的服务,拿到这种消息体基本没法直接解析,只能绕道。第二个是结构完全不透明。你打开RabbitMQ管理后台,看到消息内容是一堆二进制乱码,没法直接判断业务数据对不对,排查问题全靠猜。第三个是安全面的考量。Java原生反序列化如果解了不该解的类,可能引发注入风险,这也是很多团队在代码规范里明确禁用原生序列化格式的重要原因。

所以把消息体统一成JSON格式,是生产环境里非常主流的做法。JSON的好处是跨语言、可读性好,管理后台里能直接看到发送的内容,而且各个语言生态都有成熟的解析库,调试和排查都会轻松很多。

1.3 简单说下ContentType对消息的影响

配置了JSON转换器之后,发送出去的消息头里content_type会变成application/json,而默认的SimpleMessageConverter发送Java序列化内容时,content_type是application/x-java-serialized-object。这一个字段就能在管理后台里直观地告诉你,当前队列里的消息到底是什么格式。

所以排查消息转换器问题的时候,别急着看业务代码,先看管理后台里消息的ContentType。如果显示的是application/x-java-serialized-object,那说明你的JSON转换器根本没有生效,问题定位在发送端配置。如果显示的是application/json,那问题大概率出在消费端。

2. 理解MessageConverter在Spring AMQP中的位置

2.1 一条消息从发送到接收要经过哪些转换环节

配置JSON转换器之前,我建议先在脑子里建一个完整的链路模型。在Spring AMQP里,一条消息从发送到接收大概会经过这么几个环节。

发送端:业务代码调用RabbitTemplate.convertAndSend(),传入Java对象。RabbitTemplate内部会先检查传入的是不是Message类型,如果不是,就交给持有的MessageConverter,把对象转换成Message(byte[] body, MessageProperties properties),再交给底层Channel发到Broker。

消费端:Broker把消息推给SimpleMessageListenerContainer,再经MessageListenerAdapter调用MessageConverter.fromMessage(),把收到的Message还原成Java对象,最后反射调用你写的@RabbitListener方法。

这里最关键的一点是:Broker完全不关心消息body里是什么格式,转换动作只发生在客户端两侧。这意味着发送端用什么Converter,消费端最好也用对应的Converter,否则就会出现一边写JSON、一边按Java序列化解析的错位。

2.2 MessageConverter接口到底长什么样

Spring AMQP的MessageConverter接口定义很简单,核心方法就三个:

public interface MessageConverter { Message toMessage(Object object, MessageProperties messageProperties) throws MessageConversionException; Object fromMessage(Message message) throws MessageConversionException; default Message createMessage(Object object, MessageProperties messageProperties) { return toMessage(object, messageProperties); } }

toMessage负责把业务对象转成Message,fromMessage负责把Message还原成业务对象。Jackson2JsonMessageConverter就是这个接口的典型实现,区别在于它内部组合了一个ObjectMapper,用JSON规则来完成序列化和反序列化,同时额外做了类型映射,也就是在消息头里写入__TypeId__,让消费端知道该把JSON还原成哪个类。

2.3 SpringBoot为何会自动识别MessageConverter的Bean

很多人第一次看到网上的配置示例,一个@Bean就完事,觉得很玄乎。其实背后的机制是Spring Boot的自动配置。Boot的RabbitAutoConfiguration在创建RabbitTemplate和SimpleRabbitListenerContainerFactory时,会去容器里找MessageConverter类型的Bean。如果存在且唯一,就自动把它设置进去。

这也是为什么你只需要写一个配置类,不用在每个convertAndSend里手动指定converter,不用在每个@RabbitListener上标注转换器。这种隐式装配特别省事,但也埋了一个雷:如果项目里出现了多个MessageConverter类型的Bean,Boot就不会自动帮你选了,必须显式指定,否则运行期行为会非常奇怪。

我在一个项目里就遇到过这种状况:有人为了兼容历史消息写了一个自定义Converter,后来又加了一个JSON的,两个Bean同时在容器里,RabbitTemplate的行为完全不可预测,时好时坏。最后是把不同队列拆到不同的监听器容器工厂,每个工厂指定各自的Converter才恢复正常。这一点我会在第5节里详细展开。

3. 配置JSON消息转换器:最小可用的完整方案

3.1 注册Jackson2JsonMessageConverter

最核心的配置,其实就是一个Bean。我自己的标准写法如下。

package com.example.mq.config; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import com.fasterxml.jackson.databind.ObjectMapper; @Configuration public class JsonMessageConverterConfig { @Bean public MessageConverter jsonMessageConverter(ObjectMapper objectMapper) { return new Jackson2JsonMessageConverter(objectMapper); } }

注意一个细节:方法参数直接注入Spring Boot自动配置好的ObjectMapper,而不是自己new ObjectMapper()。这一点很关键,因为Boot的ObjectMapper默认已经注册了JavaTimeModule,后面的LocalDateTime问题,多半就是从“自己new”开始的。如果你用默认的ObjectMapper,Java 8时间类型序列化时就会报错。

Jackson2JsonMessageConverter内部的实现逻辑并不复杂。它序列化时把对象转成JSON字节,放入Message的body,同时设置ContentType为application/json,并写入__TypeId__属性,记录发送方对象的全限定类名。消费端拿到__TypeId__后,就知道该把JSON还原成哪个类型。

3.2 生产端与消费端示例代码

我准备一个最小可跑的示例。先定义一个消息对象。

package com.example.mq.dto; import java.io.Serializable; import java.time.LocalDateTime; public class DemoMessage implements Serializable { private String orderId; private Integer amount; private LocalDateTime createdAt; public DemoMessage() { } public DemoMessage(String orderId, Integer amount, LocalDateTime createdAt) { this.orderId = orderId; this.amount = amount; this.createdAt = createdAt; } public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId = orderId; } public Integer getAmount() { return amount; } public void setAmount(Integer amount) { this.amount = amount; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } }

生产端调用:

import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Component; @Component public class DemoProducer { private final RabbitTemplate rabbitTemplate; public DemoProducer(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; } public void send() { DemoMessage message = new DemoMessage("2025001", 188, LocalDateTime.now()); // 第三个参数是路由Key,示例里直接用队列名 rabbitTemplate.convertAndSend("demo.order.queue", message); } }

消费端接收:

import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import com.example.mq.dto.DemoMessage; @Component public class DemoConsumer { @RabbitListener(queues = "demo.order.queue") public void onMessage(DemoMessage message) { System.out.println("收到消息: " + message.getOrderId()); } }

这三段代码配合第3.1节的配置类,就是一个完整可运行的JSON消息收发闭环。发送时不用写任何序列化逻辑,接收时也不用把参数声明成String再手动parse,@RabbitListener方法的参数直接写成业务对象类型,Spring AMQP会在方法调用前自动完成反序列化。

3.3 消费端DTO的一个隐藏要求

既然用了JSON转换器,消费端的类就必须满足Jackson的反序列化要求,最典型的一点是:必须有默认无参构造方法,字段需要用setter或者字段本身可访问。很多人在写DTO时只写了有参构造,忘了无参构造,结果发送端好好的,消费端一收就报InvalidDefinitionException。

所以消息DTO我刚一开始就建议遵循几个约定:无参构造必须有、字段用包装类型不要用基本类型、业务相关的枚举字段要谨慎。这些约定是为了防止消息在传输过程中因为类型适配问题被打回来。

3.4 为什么不应该退回“发JSON字符串,消费端自行解析”

有些团队会习惯性用convertAndSend(queue, objectMapper.writeValueAsString(obj)),把序列化放在业务代码里做,消费端再用objectMapper.readValue自行解析。这套方案确实可行,但有几个明显缺点。

序列化逻辑散落在业务代码里,每个发送点都要记得写objectMapper,消费端又要重复写readValue,非常容易漏。更麻烦的是,如果发送端写漏了一个字段,或者消费端解析时用了另一个ObjectMapper配置,异常信息会被业务代码的异常包裹,排查起来比直接用消息转换器更费时间。消息转换器本来就是Spring AMQP提供来统一收口这件事的,业务代码里就不该出现“把一个对象转成String再塞进队列”的写法。

4. 生产环境中的几个高频坑和完整排查链路

4.1 消费端收到的是LinkedHashMap而不是目标对象

这是配置了JSON转换器之后最常见的问题。表现是:@RabbitListener方法里的参数类型声明为DemoMessage,但实际拿到的却是LinkedHashMap,或者方法签名直接报类型不匹配。

根因一般有两个。

第一,消费端并没有真正加载到Jackson2JsonMessageConverter。常见的坑是把配置类放在了发送端服务的代码里,但消费端是另一个服务,没放这个配置类,于是消费端走的还是默认的SimpleMessageConverter,自然转换不出DemoMessage。

第二,消息头的__TypeId__丢失或与消费端类名不匹配。Jackson2JsonMessageConverter发送时会写入发送对象的全限定类名,比如com.example.mq.dto.DemoMessage。如果消费端服务的包名不是这个,或者同一个类放在另一个包里,转换器定位不到类,就会按默认逻辑退化成LinkedHashMap。

排查顺序我建议这样:先看管理后台里消息body是否已经是JSON文本,如果还是二进制乱码,说明发送端Converter没生效,问题出在发送端。再点开消息的headers看__TypeId__的值,如果这个值对应不上消费端类路径,就优化两边的类定义,或者统一用type mapper做映射。如果两边确实需要不同的包结构,可以用DefaultJackson2JavaTypeMapper配置信任包和映射关系。不过我的经验是,多数项目最省事的做法是把消息DTO抽到一个公共模块里,发送端和消费端依赖同一个jar,这样类名天然一致,不用处理跨包问题。

4.2 LocalDateTime等时间类型报InvalidDefinitionException

运行时报错长这样:

InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"

这个错误在单个服务里不常见,因为Spring Boot自动配置的ObjectMapper已经注册了JavaTimeModule。但它很容易在写成new Jackson2JsonMessageConverter(new ObjectMapper())时出现——你没用Boot管理的ObjectMapper,新new的那个自然不知道Java 8时间类型怎么序列化。

解决办法很简单,回到第3.1节的写法,把ObjectMapper作为方法参数注入。如果你确实需要自定义ObjectMapper,也可以手动补上模块:

import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);

disable(WRITE_DATES_AS_TIMESTAMPS)是让时间序列化成ISO字符串而不是毫秒时间戳,这样消息在管理后台里更可读,跨服务解析时也更容易对齐。这里还有一个小细节:如果在application.yml里设置了spring.jackson.date-format,它只影响java.util.Date的格式,对LocalDateTime不生效。LocalDateTime想统一格式,得自己在字段上加@JsonFormat或者在ObjectMapper里配置,别在日期格式上重复踩坑。

4.3 correlationId和自定义消息属性的处理

JSON转换器只管body,不管headers。所以如果你在业务里需要给消息设置correlationId,不要指望转换器帮你自动填,它不会。

发送侧通常用CorrelationData配合发布确认机制来追踪:

import org.springframework.amqp.rabbit.connection.CorrelationData; CorrelationData correlationData = new CorrelationData("order-2025-0001"); rabbitTemplate.convertAndSend("demo.order.queue", message, correlationData);

框架会把correlationData.getId()写入MessageProperties中的correlationId字段。消费端可以通过Message参数的getMessageProperties().getCorrelationId()读取。

如果你的自定义header比较多,正确姿势是用MessagePostProcessor在消息进入队列前修改properties,而不是塞进消息对象里:

rabbitTemplate.convertAndSend("demo.order.queue", message, msg -> { msg.getMessageProperties().setHeader("source", "order-service"); return msg; });

这些header在JSON转换时不会丢失,因为它们本来就不在body里。

4.4 手动确认转换器是否真的生效

有一种隐蔽情况:配置类写了,但@RabbitListener是直接用Message类型接收的。MessageListenerAdapter看到参数是Message类型时,就不会调用fromMessage,你拿到的仍然是原始Message,body里是JSON字符串的byte数组。这不是配置失败,而是框架设计如此。

判断Converter是否生效,最简单的一个方法是在管理后台看消息体。只要body显示的是{"orderId":"2025001",...}这样可读的JSON文本,说明发送端配置生效了。消费端配置是否生效,则靠@RabbitListener里是否能正常收到对象类型来判断,或者临时把参数改成Map看类型来验证。

4.5 转换失败与消费重试的“连锁反应”

还有一个容易被忽略的点:消费端如果反序列化一直报错,@RabbitListener的方法会抛异常,默认情况下这条消息会被不断投递回来重试。重试次数超过配置后,消息要么进入死信队列,要么被丢弃,取决于队列有没有配置死信路由。

所以配置了JSON转换器后,建议给监听器配置合理的重试策略和死信队列。别等消息堆积了才想到这个问题。这类问题不属于消息转换器本身,但往往是“转换突然失败”之后,运维层面最先炸出来的坑。

5. 进阶场景:双Converter并存时的取舍

5.1 一套服务里混合JSON与原生序列化

有些团队会遇到一个两难场景:大部分业务消息已经切到JSON,但历史遗留队列还在用Java原生序列化,不能停。如果把全局MessageConverter配置成JSON,旧队列消费端全乱套,于是有人直接把配置类删掉,退回默认的SimpleMessageConverter。这样确实能保住旧队列,但新队列的JSON化了也没了。

正确的做法是拆监听器容器工厂。Spring AMQP允许你在一个服务里声明多个SimpleRabbitListenerContainerFactory,每个工厂用各自的MessageConverter,然后用@RabbitListener(queues = "xxx", containerFactory = "xxxFactory")指定。

示例:

import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import com.fasterxml.jackson.databind.ObjectMapper; @Configuration public class MultiConverterConfig { @Bean("jsonListenerContainerFactory") public SimpleRabbitListenerContainerFactory jsonListenerContainerFactory( ConnectionFactory connectionFactory, ObjectMapper objectMapper) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(new Jackson2JsonMessageConverter(objectMapper)); return factory; } @Bean("legacyListenerContainerFactory") public SimpleRabbitListenerContainerFactory legacyListenerContainerFactory( ConnectionFactory connectionFactory) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); // 不设置MessageConverter,回退到默认SimpleMessageConverter return factory; } }

使用上的区别仅仅是注解参数不同:

@RabbitListener(queues = "demo.order.queue", containerFactory = "jsonListenerContainerFactory") public void onJson(DemoMessage message) { ... } @RabbitListener(queues = "demo.legacy.queue", containerFactory = "legacyListenerContainerFactory") public void onLegacy(org.springframework.amqp.core.Message message) { ... }

这样旧队列继续走Java序列化,新队列走JSON,两边互不干扰。注意,当容器里存在多个MessageConverter类型的Bean时,Boot自动配置不会随便挑一个,它需要你在工厂里显式设置。这也是我前面说的“多个Converter Bean会破坏隐式装配”的标准解法。

5.2 发送端的多Converter场景

如果发送端也需要在不同的队列里发不同格式的消息,不能依赖Boot的自动选择,就需要在调用时手动指定。RabbitTemplate本身持有一个MessageConverter属性,你可以通过setMessageConverter()临时代换,但要注意这会改变RabbitTemplate的全局状态,在并发场景下极不安全。你这边刚换成JSON,另一个线程发送的可能就跟着变了。

更稳妥的办法是维护多个RabbitTemplate,每个Template持有自己的Converter,再按业务队列选用对应的Template。代码稍微多一点点,但并发行为完全不依赖上下文,不会出现“一个线程改了Converter、另一个线程发送时被影响”的情况。

5.3 我实际处理这类问题的固定顺序

凡是涉及消息转换器配置异常,我从来不会先改代码。第一步永远是打开管理后台,看真实的消息体是什么格式。第二步是确认发送端和消费端加载到的配置类是不是同一个。第二步没问题后,才去看类路径、ObjectMapper配置、Listener容器工厂是否匹配。

把这三步走完,绝大多数问题都能定位到具体的那一端。剩下的一小部分,往往是消息在发送前已经被人手动setMessage了——比如调用了rabbitTemplate.send()而不是convertAndSend(),或者传入了Spring AMQP的Message对象。这种情况消息转换器根本没有机会介入,自然也就不会按JSON处理。遇到时仔细看一下调用点就能发现。

最后说一个我个人的经验总结:消息转换器配置不是多复杂的机制,核心就是记住一句话——发送端和消费端的Converter必须对齐,业务代码里不要把序列化和反序列化的职责背到自己身上。只要这个原则不丢,剩下的配置细节,最多也就是把__TypeId__的包路径、LocalDateTime的模块、多Converter的容器工厂分开这三件事理顺就可以了。如果你现在正被“消费端拿到一堆乱码”或者“参数类型总是LinkedHashMap”折磨,按第4节的排查顺序走一遍,多半比反复试错要快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进