Spring Boot 4.1.1 会自动配置一个 JmsClient Bean,它就是拿自动配置的 JmsTemplate 调了一次 JmsClient.create(jmsTemplate),两者共用同一个消息转换器、同一套 QoS 和 spring.jms.template.* 配置。问题也出在这里:Boot 默认不设 spring.jms.template.receive-timeout,JmsTemplate 的接收超时是 0,用 JmsClient 从空队列 receive() 时线程会一直挂着,测试里 3 秒后还没返回。自己用 JmsClient.create(connectionFactory) 新建的客户端则完全不读这些配置,也拿不到你声明的 JSON 转换器。
JmsClient 从哪个版本开始有
JmsClient 是 Spring Framework 7.0 加的,对应 spring-framework gh-32501,里程碑 7.0.0-M7,源码里 JmsClient 和 DefaultJmsClient 都标着 @since 7.0。官方文档 Using Spring JMS 的原话是 "JmsClient is a new API variant in Spring Framework 7.0, following the design of JdbcClient and co. JmsClient builds on JmsTemplate"。Boot 这边的自动配置是 spring-boot gh-46293 Add support for JmsClient,跟着 4.0.0-M1 进来,所以 Boot 4.0 起就有,不是 4.1 的新东西。
有人提过 PR,想再自动配置一个 JmsClient.Builder,好让 spring.jms.template.* 能带到自己构建的客户端上(gh-49853),2026 年 4 月被标成 declined 关掉了。维护者的意思是 JmsClient 和 JdbcClient 一样,Boot 直接给成品,不像 RestClient 那样给 Builder。
这次的环境是 Spring Boot 4.1.1、Spring Framework 7.0.9、JDK 21.0.12.1,Boot 管理的 Artemis 客户端是 artemis-jakarta-client 2.53.0。Artemis 已经从 ActiveMQ 独立出来,Maven 坐标换成了 org.apache.artemis,Docker 镜像也从 apache/activemq-artemis(停在 2.44.0)换到了 apache/artemis,服务端我用了和客户端同版本的 2.53.0:
$ docker run -d --name jmsclient-artemis -p 61616:61616 -e ARTEMIS_USER=demo -e ARTEMIS_PASSWORD=demo apache/artemis:2.53.0-alpine
2026-10-22 00:13:24,793 INFO [org.apache.activemq.artemis.core.server] AMQ221007: Server is now active
2026-10-22 00:13:24,793 INFO [org.apache.activemq.artemis.core.server] AMQ221001: Apache Artemis Message Broker version 2.53.0 [0.0.0.0, nodeID=656797c9-cdad-11f1-91b6-0242ac110002]
项目只有 spring-boot-starter-artemis 和 spring-boot-starter-jackson 两个依赖,application.properties 里配的是 spring.artemis.mode=native、spring.artemis.broker-url=tcp://localhost:61616 和用户名密码。
Boot 配出来的 JmsClient 就是包了一层 JmsTemplate
Boot 4.1.1 的 spring-boot-jms 模块里,JmsClient 的配置只有几行(JmsClientConfigurations.java):
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(JmsClient.class)
static class JmsClientConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(JmsTemplate.class)
JmsClient jmsClient(JmsTemplate jmsTemplate) {
return JmsClient.create(jmsTemplate);
}
}
加 --debug 启动,条件报告里能看到三层依赖:ConnectionFactory 唯一才有 JmsTemplate,JmsTemplate 唯一才有 JmsClient。
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar auto --debug
JmsClientConfigurations.JmsClientConfiguration#jmsClient matched:
- @ConditionalOnSingleCandidate (types: org.springframework.jms.core.JmsTemplate; SearchStrategy: all) found a single bean 'jmsTemplate'; @ConditionalOnMissingBean (types: org.springframework.jms.core.JmsClient; SearchStrategy: all) did not find any beans (OnBeanCondition)
JmsClientConfigurations.JmsTemplateConfiguration#jmsTemplate matched:
- @ConditionalOnSingleCandidate (types: jakarta.jms.ConnectionFactory; SearchStrategy: all) found a single bean 'jmsConnectionFactory'; @ConditionalOnMissingBean (types: org.springframework.jms.core.JmsOperations; SearchStrategy: all) did not find any beans (OnBeanCondition)
启动后我按类型列了一下 Bean,再用反射看 DefaultJmsClient 里那个 jmsTemplate 字段是不是容器里的同一个对象:
[auto] beans of JmsClient: jmsClient
[auto] beans of JmsTemplate: jmsTemplate
[auto] beans of JmsMessagingTemplate: jmsMessagingTemplate
[auto] beans of MessageConverter:
[auto] jmsClient class: org.springframework.jms.core.DefaultJmsClient
[auto] jmsClient wraps the jmsTemplate bean: true
[jmsTemplate bean] messageConverter=SimpleMessageConverter explicitQosEnabled=false deliveryMode=2 priority=4 timeToLive=0 receiveTimeout=0
是同一个。JmsTemplate、JmsMessagingTemplate、JmsClient 三个 Bean 同时存在,注入哪个都行。最后一行也说明了 Boot 的默认值:SimpleMessageConverter,不开显式 QoS,持久化投递,优先级 4,不过期,接收超时 0。
常用操作
JmsClient 的入口只有 destination(...),拿到一个 OperationSpec 后再选 send、receive 或 sendAndReceive。receive 系列返回的都是 Optional。下面这几段对应的输出在后面:
this.jmsClient.destination("demo.basic").send("hello jms client");
this.jmsClient.destination("demo.basic").receive(String.class);
this.jmsClient.destination("demo.headers").send("with headers",
Map.of("tenant", "acme", "retry", 3, "jms_priority", 9, "jms_type", "greeting"));
this.jmsClient.destination("demo.headers").receive();
this.jmsClient.destination("demo.selector").receive("tenant = 'b'", String.class);
this.jmsClient.destination("demo.echo").sendAndReceive("ping", String.class);
this.jmsClient.destination("demo.empty1").withReceiveTimeout(500).receive(String.class);
this.jmsClient.destination("demo.empty2").receive(String.class);
demo.echo 上挂了一个 @JmsListener,把收到的字符串转成大写返回。每一步都放到单独的线程里执行,主线程最多等 3 秒,超过就记一行 still blocked。打印 Message 时我去掉了 id、timestamp、jms_messageId、jms_timestamp 这几个每次都会变的头。
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar basics
[send String] -> String sent (48 ms)
[receive String] -> Optional[String hello jms client] (12 ms)
[send with headers] -> String sent (4 ms)
[receive Message] -> Optional[Message payload=String with headers headers={JMSXDeliveryCount=1, jms_deliveryMode=2, jms_destination=ActiveMQQueue[demo.headers], jms_expiration=0, jms_priority=4, jms_redelivered=false, jms_type=greeting, retry=3, tenant=acme}] (5 ms)
[send tenant=a] -> String sent (4 ms)
[send tenant=b] -> String sent (1 ms)
[receive selector tenant='b'] -> Optional[String for b] (12 ms)
[sendAndReceive] -> Optional[String PING] (28 ms)
[receive empty withReceiveTimeout(500)] -> Optional.empty (507 ms)
[receive empty with defaults] -> still blocked after 3000 ms (3000 ms)
发送、接收、选择器、请求响应都正常,带头发送和空队列接收这两步的结果另外说。
自己塞的 jms_priority 被悄悄丢掉了
带头发送时我在 Map 里放了 jms_priority=9 和 jms_type=greeting,收到的消息 jms_type 在,优先级却还是 4,没有任何报错或日志。
这和 10 月 20 日那篇 Spring AMQP 的 setDelayLong 对 RabbitMQ 4.4 的 quorum queue 不生效 里的情况类似:客户端设了,消息上却没有,也不报错。原因在 SimpleJmsHeaderMapper。出站方向它只认三个 jms_ 前缀的头:jms_correlationId、jms_replyTo、jms_type,其余以 jms_ 开头的一律跳过;不带前缀的头才会被写成 JMS 属性,所以 tenant 和 retry 都过去了。优先级、投递模式、过期时间在 JMS 里是由生产者在发送时决定的,要改只能走 withPriority、withDeliveryPersistent、withTimeToLive。
顺带一提,Spring 消息自带的 timestamp 头是 Long,属于 JMS 支持的属性类型,也会被原样写成一个名为 timestamp 的 JMS 属性,后面 JSON 那节的原始消息里能看到它;id 头是 UUID,不在支持的类型里,不会写过去。
不设超时的 receive 会一直等
withReceiveTimeout(500) 的那次在 507 毫秒后返回了 Optional.empty。不带超时的那次到 3000 毫秒还没回来。
JmsTemplate 的接收超时默认是 0,表示无限等待,Boot 的 JmsProperties.Template 里 receiveTimeout 也是 null,不设就不会去改它。JmsClient 底下用的就是这个 JmsTemplate,Optional 这个返回类型很容易让人以为“没消息就返回空”,实际要等超时到了才会是空。监听容器那边的 spring.jms.listener.receive-timeout 默认是 1 秒,但那是 @JmsListener 的配置,和 JmsClient 无关。
QoS 和 spring.jms.template.* 是共享的
单次调用上的 withPriority 之类的设置是生效的:
[QoS default send] -> String sent (9 ms)
[QoS default receive] -> Optional[Message payload=String default qos headers={JMSXDeliveryCount=1, jms_deliveryMode=2, jms_destination=ActiveMQQueue[demo.qos], jms_expiration=0, jms_priority=4, jms_redelivered=false}] (5 ms)
[QoS custom send] -> String sent (0 ms)
[QoS custom receive] -> Optional[Message payload=String custom qos headers={JMSXDeliveryCount=1, jms_deliveryMode=1, jms_destination=ActiveMQQueue[demo.qos], jms_expiration=1792628299376, jms_priority=7, jms_redelivered=false}] (4 ms)
withPriority(7).withTimeToLive(60000).withDeliveryPersistent(false) 发出去的消息,收到时优先级是 7,投递模式变成 1(非持久),过期时间也有了值。
这些 withXxx 方法不会改动 Boot 的 JmsTemplate。DefaultJmsClient 在第一次遇到这类设置时,用 Spring Framework 7.0 新加的拷贝构造器 new JmsTemplate(original) 复制出一个模板,连接工厂、转换器、目标解析器、超时、QoS 全部照抄,再在副本上改。设置优先级、TTL、持久化这三个时还会顺手把副本的 explicitQosEnabled 打开,JmsTemplate 只有在这个开关打开时才会把自己的 QoS 值交给生产者。
再把 spring.jms.template.* 配上,对比 Boot 的 JmsClient 和手动 JmsClient.create(connectionFactory) 建的客户端:
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar props --spring.jms.template.priority=8 --spring.jms.template.time-to-live=30s --spring.jms.template.delivery-mode=non-persistent --spring.jms.template.receive-timeout=700ms
[jmsTemplate bean] messageConverter=SimpleMessageConverter explicitQosEnabled=true deliveryMode=1 priority=8 timeToLive=30000 receiveTimeout=700
[boot client send] -> Optional[Message payload=String boot client headers={JMSXDeliveryCount=1, jms_deliveryMode=1, jms_destination=ActiveMQQueue[demo.props], jms_expiration=1792628274423, jms_priority=8, jms_redelivered=false}] (48 ms)
[JmsClient.create(connectionFactory) send] -> Optional[Message payload=String plain client headers={JMSXDeliveryCount=1, jms_deliveryMode=2, jms_destination=ActiveMQQueue[demo.props], jms_expiration=0, jms_priority=4, jms_redelivered=false}] (6 ms)
[boot client withPriority(2) send] -> Optional[Message payload=String boot client priority 2 headers={JMSXDeliveryCount=1, jms_deliveryMode=1, jms_destination=ActiveMQQueue[demo.props], jms_expiration=1792628274443, jms_priority=2, jms_redelivered=false}] (4 ms)
[jmsTemplate bean after withPriority(2)] messageConverter=SimpleMessageConverter explicitQosEnabled=true deliveryMode=1 priority=8 timeToLive=30000 receiveTimeout=700
[jmsTemplate send] -> Optional[Message payload=String jms template headers={JMSXDeliveryCount=1, jms_deliveryMode=1, jms_destination=ActiveMQQueue[demo.props], jms_expiration=1792628274448, jms_priority=8, jms_redelivered=false}] (4 ms)
[boot client receive empty] -> Optional.empty (708 ms)
[JmsClient.create(connectionFactory) receive empty] -> still blocked after 3000 ms (3000 ms)
只要设了 delivery-mode、priority、time-to-live 中的任意一个,Boot 就会把 explicitQosEnabled 设成 true,模板上的值是优先级 8、非持久、TTL 30000、接收超时 700。Boot 的 JmsClient 发出的消息完全按这套来,单次 withPriority(2) 只改了优先级,非持久和过期时间还在,发完以后 Bean 上的优先级仍然是 8。接收也一样,空队列 708 毫秒后返回空。
手动 JmsClient.create(connectionFactory) 的那一路,发出去的是优先级 4、持久化、不过期,接收空队列又挂住了。它内部是 new JmsTemplate(connectionFactory),和 Boot 的配置没有任何关系。
和 JmsTemplate 混用
两个 API 发出的消息互相能读,这在共用一个模板时是理所当然的:
[template send, client receive] -> Optional[String from JmsTemplate] (7 ms)
[client send, template receive] -> String from JmsClient (5 ms)
容易踩的是 Bean 数量。有的项目会为不同业务各声明一个 JmsTemplate,这时 JmsClient 和 JmsMessagingTemplate 都不会被自动配置,@ConditionalOnSingleCandidate 找到了两个候选:
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar beans --app.two-templates=true --debug
JmsClientConfigurations.JmsClientConfiguration#jmsClient:
Did not match:
- @ConditionalOnSingleCandidate (types: org.springframework.jms.core.JmsTemplate; SearchStrategy: all) found multiple beans 'auditJmsTemplate', 'ordersJmsTemplate' (OnBeanCondition)
JmsClientConfigurations.JmsTemplateConfiguration#jmsTemplate:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.jms.core.JmsOperations; SearchStrategy: all) found beans of type 'org.springframework.jms.core.JmsOperations' ordersJmsTemplate, auditJmsTemplate (OnBeanCondition)
JmsClientConfigurations.MessagingTemplateConfiguration#jmsMessagingTemplate:
Did not match:
- @ConditionalOnSingleCandidate (types: org.springframework.jms.core.JmsTemplate; SearchStrategy: all) found multiple beans 'auditJmsTemplate', 'ordersJmsTemplate' (OnBeanCondition)
[beans] beans of JmsClient:
[beans] beans of JmsTemplate: ordersJmsTemplate, auditJmsTemplate
[beans] beans of JmsMessagingTemplate:
给 ordersJmsTemplate 加上 @Primary 再跑一次,JmsClient 回来了,包的是这个主模板:
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar beans --app.two-templates-primary=true --debug
JmsClientConfigurations.JmsClientConfiguration#jmsClient matched:
- @ConditionalOnSingleCandidate (types: org.springframework.jms.core.JmsTemplate; SearchStrategy: all) found a single primary bean 'ordersJmsTemplate' from beans 'auditJmsTemplate', 'ordersJmsTemplate'; @ConditionalOnMissingBean (types: org.springframework.jms.core.JmsClient; SearchStrategy: all) did not find any beans (OnBeanCondition)
[beans] beans of JmsClient: jmsClient
[beans] beans of JmsTemplate: ordersJmsTemplate, auditJmsTemplate
[beans] beans of JmsMessagingTemplate: jmsMessagingTemplate
[beans] jmsClient wraps: ordersJmsTemplate
这时 Boot 自己的 JmsTemplate 已经因为 @ConditionalOnMissingBean(JmsOperations.class) 退出了,JmsClient 包的是应用自己 new 出来的模板。从 JmsClientConfigurations 的源码看,spring.jms.template.* 和转换器 Bean 只在 Boot 创建 jmsTemplate 时映射上去,所以都不会作用到 ordersJmsTemplate。
消息转换器有两层
不配转换器时,Boot 的 JmsTemplate 用的是 SimpleMessageConverter,只认字符串、字节数组、Map 和 Serializable。发一个普通的 record 会直接失败:
[send record without JSON converter] -> FAILED org.springframework.messaging.converter.MessageConversionException: Could not convert 'GenericMessage [payload=Order[id=A-1, amount=42], headers={id=1517a36e-217e-6cf7-2141-faac62861af3, timestamp=1792628239395}]': Cannot convert object of type [com.example.jmsclient.Order] to JMS message. Supported message payloads are: String, byte array, Map<String,?>, Serializable object. <- org.springframework.jms.support.converter.MessageConversionException: Cannot convert object of type [com.example.jmsclient.Order] to JMS message. Supported message payloads are: String, byte array, Map<String,?>, Serializable object. (3 ms)
外层是 org.springframework.messaging.converter.MessageConversionException,里层是 org.springframework.jms.support.converter.MessageConversionException,这两个同名不同包的异常正好对应 JmsClient 的两层转换。外层是 spring-messaging 的 MessageConverter,负责把任意 payload 变成 Message<?>;里层是 JMS 的 MessageConverter,负责把 Message<?> 的 payload 变成 TextMessage、BytesMessage 这些 JMS 消息。
Boot 文档写明,声明一个 JMS 的 MessageConverter Bean 就会被关联到自动配置的 JmsTemplate 上(Spring Boot Reference: JMS)。我加了一个 Jackson 3 的转换器:
@Bean
MessageConverter jacksonJmsMessageConverter() {
JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter();
converter.setTargetType(MessageType.TEXT);
converter.setTypeIdPropertyName("_type");
return converter;
}
这里的 MessageConverter 和 JacksonJsonMessageConverter 都是 org.springframework.jms.support.converter 包下的。JacksonJsonMessageConverter 是 7.0 新加的 Jackson 3 版本,原来的 MappingJackson2MessageConverter 在 7.0 被标成了 @Deprecated(since = "7.0", forRemoval = true)。然后对比三种客户端:
JmsClient plain = JmsClient.create(this.connectionFactory);
JmsClient messagingJson = JmsClient.builder(this.connectionFactory)
.messageConverter(new JacksonJsonMessageConverter())
.build();
第二个 JacksonJsonMessageConverter 是 org.springframework.messaging.converter 包下的,JmsClient.Builder.messageConverter(...) 只接受 spring-messaging 的转换器,传 JMS 的那个编译不过。结果:
$ java -jar target/jmsclient-demo-0.0.1-SNAPSHOT.jar json --app.json=true
[jmsTemplate bean] messageConverter=JacksonJsonMessageConverter explicitQosEnabled=false deliveryMode=2 priority=4 timeToLive=0 receiveTimeout=0
[boot client send Order] -> String sent (70 ms)
[raw JMS message] -> String TextMessage text={"id":"A-1","amount":42} properties={JMSXDeliveryCount=1, _type=com.example.jmsclient.Order, timestamp=1792628241645} JMSPriority=4 JMSDeliveryMode=2 JMSExpiration=0 (10 ms)
[boot client round trip] -> Optional[Order Order[id=A-2, amount=7]] (29 ms)
[JmsClient.create(connectionFactory) send Order] -> FAILED org.springframework.messaging.converter.MessageConversionException: Could not convert 'GenericMessage [payload=Order[id=A-3, amount=1], headers={id=d6552754-1362-3eb9-1380-cc9efa3ba262, timestamp=1792628241752}]': Cannot convert object of type [com.example.jmsclient.Order] to JMS message. Supported message payloads are: String, byte array, Map<String,?>, Serializable object. <- org.springframework.jms.support.converter.MessageConversionException: Cannot convert object of type [com.example.jmsclient.Order] to JMS message. Supported message payloads are: String, byte array, Map<String,?>, Serializable object. (2 ms)
[builder messaging JSON converter send Order] -> String sent (6 ms)
[raw JMS message] -> String BytesMessage bytes={"id":"A-4","amount":5} properties={JMSXDeliveryCount=1, timestamp=1792628241758} JMSPriority=4 JMSDeliveryMode=2 JMSExpiration=0 (5 ms)
[builder messaging JSON converter round trip] -> Optional[Order Order[id=A-5, amount=6]] (9 ms)
[boot client receives builder message] -> FAILED org.springframework.messaging.converter.MessageConversionException: Could not convert 'ActiveMQMessage[ID:f3296b32-cdad-11f1-8ada-4adfdcfafe79]:PERSISTENT/ClientMessageImpl[messageID=539, durable=true, address=demo.json3,userID=f3296b32-cdad-11f1-8ada-4adfdcfafe79, properties=TypedProperties[__AMQ_CID=f315bc1b-cdad-11f1-8ada-4adfdcfafe79, timestamp=1792628241779, _AMQ_ROUTING_TYPE=1]]' <- org.springframework.jms.support.converter.MessageConversionException: Could not find type id property [_type] on message [ID:f3296b32-cdad-11f1-8ada-4adfdcfafe79] from destination [ActiveMQQueue[demo.json3]] (11 ms)
Boot 的 JmsClient 自动用上了 JSON 转换器,原始消息是 TextMessage,带 _type 属性,往返能还原成 Order。手动 create 的客户端还是 SimpleMessageConverter,同样的 record 发不出去。
用 Builder 配 spring-messaging JSON 转换器的那个客户端能发能收,但它发出的是 BytesMessage,没有 _type 属性。原因是外层转换器先把 Order 序列化成了 byte[],里层默认的 SimpleMessageConverter 看到字节数组就生成 BytesMessage。用 Boot 的 JmsClient 去收这条消息,JMS 层的 Jackson 转换器找不到类型属性,直接报 Could not find type id property [_type]。同一个应用里两种客户端混用时,就会出现这种一边发得出去、另一边收不回来的情况。
Spring Framework 文档的 Sending a Message 一节给的示例是 JmsClient.create(connectionFactory),在 Boot 应用里照抄这一行,拿到的就是前面那个不读 spring.jms.template.*、也用不上 JSON 转换器 Bean、接收默认无限等待的客户端。
一起交流
分享你的思考,让讨论更进一步。