Rabbitmq项目落地实战-SSM篇

背景:公司的老系统是SSM框架编写的,因为需要加入rabbitmq,但网上关于rabbitmq与SSM的整合非常少,所以本文章就是为了记录一下这段时间整合时所遇到的问题。若你的项目是Springboot的,你也可以借鉴我所写的代码。

1. 前言

这篇文章将会解决 Rabbitmq 技术落地时的一系列问题,若有部分问题没有出现,希望大家可以留言,一起解决!

2. 生产者的消息可靠投递

生产者的消息可靠性投递,是为了防止消费者发送消息到mq的这段距离出现网络问题而导致发送失败。如下图所示:
Rabbitmq项目落地实战-SSM篇_第1张图片
对此,rabbitmq出给了三种解决方法:transaction事物机制、confirm模式、return模式。

2.1 transaction事物机制

发送消息前,开启事务(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事务就会回滚(channel.txRollback()),回滚后消息不会发送到mq中,如果发送成功则提交事务(channel.txCommit()),提交后,消息才会发送到mq。这种方式是很灵活的,可以根据不同的业务场景做操作,但缺点就是这些操作会侵入代码。

以上说的是Rabbitmq自带的事物,这种事物是编程式的,但正常来说,我们的业务方法(Service)都是响应式的,即打上@Transactional 注解,这种写法显然没有那么容易与我们的业务方法相结合。我们是希望业务方法执行成功才发送消息,若执行失败了消息就不发送。那有没有一种方法,可以结合业务方法的事物呢?答案是有的,我们把发送消息的操作同样交由Spring去处理就可以了。详见如下代码实现:

public class TransactionalUtil {

    public static void doAfterTransaction(DoTransactionalCompletion doTransactionalCompletion) {
        //判断当前上下文有没有事物激活
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            //如果有的话,就将回调接口注册入事物管理器中。事物执行成功后,回调接口内的方法将会被调用
            TransactionSynchronizationManager.registerSynchronization(doTransactionalCompletion);
        }
    }

    //eg:
    @Transactional
    public void doTx() {
        // start tx

        TransactionalUtil.doAfterTransaction(new DoTransactionalCompletion(() -> {
            // send MQ... RPC...
        }));

        // end tx
    }

}


class DoTransactionalCompletion implements TransactionSynchronization {

    private Runnable runnable;

    public DoTransactionalCompletion(Runnable runnable) {
        this.runnable = runnable;
    }

    /**
     * 回调函数。本地事物执行完之后要做的事情
     *
     * @param status
     */
    @Override
    public void afterCompletion(int status) {
        if (status == TransactionSynchronization.STATUS_COMMITTED) {
            this.runnable.run();
        }
    }
}

参考极海的Spring事物拓展:https://www.bilibili.com/video/BV168411h7PU/

2.2 confirm模式

当消息从生产者发送到mq时,不论发送是否成功都会返回一个confirmCallback,当发送成功时这个confirmCallback为true,当发送失败时这个confirmCallback为false。相当于是生产者每次发送消息,无论成功与否,都会收到一个通知。优势是一次配置,可全局生效。

2.3 return模式

消息从交换机发送到队列时,若投递失败则会返回一个returnCallback。


在本次项目中,我们就选中了confirm模式。特别注意:confirm模式与return模式只能二选一!

配置:


<rabbit:connection-factory id="defaultConnectionFactory"
                               addresses="${rabbitmq.addresses}"
                               username="${rabbitmq.username}"
                               password="${rabbitmq.password}"
                               virtual-host="${rabbitmq.virtualHost}"
                               publisher-confirms="true"/>
                        

<rabbit:template id="defaultRabbitTemplate"  retry-template="defaultRetryTemplate"
                     message-converter="jackson2JsonMessageConverter" connection-factory="defaultConnectionFactory"
                     mandatory="true" confirm-callback="RabbitmqMessageConfirm"/>

<bean id="RabbitmqMessageConfirm" class="com.hand.htms.config.RabbitmqMessageConfirm"/>

业务处理类,可以看到,我们消息发送成功后,会记录到日志内;若发送失败了,则会有钉钉通知,这时候就需要人工接入修复了。

public class RabbitmqMessageConfirm implements RabbitTemplate.ConfirmCallback {

    private Logger logger = LoggerFactory.getLogger(RabbitmqMessageConfirm.class);

    @Autowired
    public RabbitTemplate rabbitTemplate;

    @Autowired
    public DingtalkUtils dingtalkUtils;

    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        if (ack) {
            logger.info("生产者消息发送至Rabbitmq成功");
        } else {
            String message = "生产者有一条消息发送至Rabbitmq失败,但数据已保存到报文表,且生产者会重发5次,请后续人工查看报文表并确认数据是否成功重发至Rabbitmq \n\r失败原因:" + cause;
            logger.warn(message);
            dingtalkUtils.sendDingTalkInterfaceExceptionNotifier(new Exception(cause), message);
        }
    }
}

2.4 生产者端的重试机制

发送失败后,我们可以配置重发机制:

    <bean id="defaultRetryTemplate" class="org.springframework.retry.support.RetryTemplate">
        <property name="backOffPolicy">
            <bean class="org.springframework.retry.backoff.ExponentialBackOffPolicy">
                <property name="initialInterval" value="500" />
                <property name="multiplier" value="10.0" />
                <property name="maxInterval" value="10000" />
            bean>
        property>
        
        <property name="retryPolicy">
            <bean class="org.springframework.retry.policy.SimpleRetryPolicy">
                <property name="maxAttempts" value="5"/>
            bean>
        property>
    bean>

3. Rabbitmq(中间件)的消息持久化

Rabbitmq项目落地实战-SSM篇_第2张图片

若想要满足消息在rabbitmq中持久化,只需要满足两个条件即可。第一:交换机和队列是持久化的。第二:消息是可持久化的。

3.1 交换机和队列持久化

我们在声明交换机和队列的时候,默认就是持久化声明的(SSM配置下)
交换机:
Rabbitmq项目落地实战-SSM篇_第3张图片
队列:
Rabbitmq项目落地实战-SSM篇_第4张图片

3.2 消息持久化

若你使用的是 RabbitTemplate 进行进行消息发送,则默认也是消息持久化的,源码观看可点击这个连接:http://www.qb5200.com/article/472439.html

4. 消费者的问题

Rabbitmq项目落地实战-SSM篇_第5张图片

4.1 防止消息丢失

rabbitmq 是一次性将 大量消息 打入消费者的,消费者接受到这些消息之后,是 立马标记为已确认 的。若这个时候,消费者宕机了;那么所有的消息将会丢失。但我们不想失去任何消息。如果一个消费者死了,我们希望把任务交给另一个消费者。
为了解决这个问题,我们需要设置,第一:每次只拿1条消息消费;第二:我们手动进行消息确认,只有消息完成了才进行确认。

4.1.1 服务质量

通过设置服务质量,我们可以限定每次只消费1条消息

channel.basicQos(1);

4.1.2 手动确认

在消费者监听器容器中,设置参数 acknowledge 为 manual 模式即可

    <rabbit:listener-container connection-factory="defaultConnectionFactory" acknowledge="manual" requeue-rejected="false" >
        <rabbit:listener ref="AppOutDoorListener" queue-names="app_outdoor"/>
        <rabbit:listener ref="AppDeadListener" queue-names="app_dead_queue"/>
    rabbit:listener-container>

消息手动确认

channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);	//消息确认
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);	//消息拒收

4.2 防止消息重复消费

重复消费的原因:正常情况下,消费者在消费消息的时候,消费完毕后,会发送一个确认消息给消息队列,消息队列就知道该消息被消费了,就会将该消息从消息队列中删除;但是因为网络传输等等故障,确认信息没有传送到消息队列,导致消息队列不知道自己已经消费过该消息了,再次将消息分发给其他的消费者。

网上防止重复消息的方法有很多如这篇文章写到,可以用redis实现。https://blog.csdn.net/javaxuanshou/article/details/110228784

但由于我们的系统是将消息存储在数据库中的,我们可以直接从数据库中找到这条消息,然后判断他的状态即可。若消息之前是消费过的,则不可能为“N”状态。(未消费的状态是 “N”,消费成功是“F”,消费失败是“E”)

                InoutSendxnyBase sendxnyBase = new InoutSendxnyBase();
                sendxnyBase.setId(id);
                sendxnyBase = inoutSendxnyBaseService.selectByPrimaryKey(iRequest, sendxnyBase);
                //如果状态是F或者E,则不执行,防止消息重复消费
                if ("F".equals(sendxnyBase.getStatus()) || "E".equals(sendxnyBase.getStatus())) {
                    channel.basicAck(deliveryTag, false);
                    return;
                }

4.3 消费失败重试机制

在我们系统中,实现的是重试 5 次,每次重试间隔0.5s,若5次均失败了,会直接进入死信队列中。

  1. 队列与交换机的创建
    
    
    <rabbit:queue name="app_dead_queue" auto-declare="true"/>
    
    <rabbit:queue name="app_outdoor" auto-declare="true">
        <rabbit:queue-arguments>
            <entry key="x-dead-letter-exchange" value="app_dead_exchange"/>
            <entry key="x-dead-letter-routing-key" value="app_outdoor_dead_key"/>
        rabbit:queue-arguments>
    rabbit:queue>

    
    
    <rabbit:direct-exchange name="app_dead_exchange">
        <rabbit:bindings>
            <rabbit:binding queue="app_dead_queue" key="app_outdoor_dead_key"/>
        rabbit:bindings>
    rabbit:direct-exchange>
  1. 消费者监听配置
    设置 requeue-rejected 为 false。意思是消息拒收之后不要让其重入队列,这样拒收之后才能使消息进入死信队列。
    <bean id="AppOutDoorListener" class="com.hand.tms.miniapp.listener.AppOutDoorListener"/>
    <bean id="AppDeadListener" class="com.hand.tms.miniapp.listener.AppDeadListener"/>


    
    <rabbit:listener-container connection-factory="defaultConnectionFactory" acknowledge="manual" requeue-rejected="false" >
        <rabbit:listener ref="AppOutDoorListener" queue-names="app_outdoor"/>
        <rabbit:listener ref="AppDeadListener" queue-names="app_dead_queue"/>
    rabbit:listener-container>
  1. 业务队列消费
public class AppOutDoorListener implements ChannelAwareMessageListener {

    private Logger logger = LoggerFactory.getLogger(AppOutDoorListener.class);

    @Autowired
    private IInoutSendxnyBaseService inoutSendxnyBaseService;

    @Autowired
    private IGuangxinService guangxinService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void onMessage(Message message, Channel channel) throws Exception {
        //重试次数
        int retryCount = 0;
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        Long id = null; //消息的id
        IRequest iRequest = new ServiceRequest();
        String errLog = null; //错误日志
        channel.basicQos(1); //一次只接受一条未确认的消息

        while (retryCount < 5) {
            retryCount++;
            try {
                //业务操作START
                String content = new String(message.getBody());
                id = new Long(content);
                logger.info("InoutSendxnyBase的id是{}", id);
                //根据id找 InoutSendxnyBase
                InoutSendxnyBase sendxnyBase = new InoutSendxnyBase();
                sendxnyBase.setId(id);
                sendxnyBase = inoutSendxnyBaseService.selectByPrimaryKey(iRequest, sendxnyBase);
                if (StrUtil.isEmpty(sendxnyBase.getStatus())) {
                    throw new Exception("数据库未找到InoutSendxnyBase");
                }
                //如果状态是F或者E,则不执行,防止消息重复消费
                if ("F".equals(sendxnyBase.getStatus()) || "E".equals(sendxnyBase.getStatus())) {
                    channel.basicAck(deliveryTag, false);
                    return;
                }

                //业务操作
                *****

                //业务操作完成后,报文状态更新到数据库
                sendxnyBase.setStatus("F");
                sendxnyBase.setLastUpdateDate(new Date());
                inoutSendxnyBaseService.updateByPrimaryKeySelective(iRequest, sendxnyBase);
                //业务操作END

                //若没有报错,消息确认
                channel.basicAck(deliveryTag, false);
                return;
            } catch (Exception e) {
                errLog = "id为" + id + "的InoutSendxnyBase,出错的原因是:" + e.toString();
                logger.warn(errLog);
                String errorTip = "第" + retryCount + "次消费失败" +
                        ((retryCount < QueueConstant.MAX_RETRIES) ? "," + QueueConstant.RETRY_INTERVAL + "s后重试" : ",进入死信队列");
                logger.warn(errorTip);
                //间隔时间,重试
                Thread.sleep(0.5 * 1000);
            }
        }

        //重试到达5次,消息拒接,进入死信
        if (retryCount == 5) {
            // 使用redis存储错误信息
            String key = "InoutSendxnyBase:" + id;
            redisTemplate.opsForValue().set(key, errLog, 30, TimeUnit.MINUTES);
            channel.basicNack(deliveryTag, false, false);
        }
    }
}
  1. 死信队列
public class AppDeadListener implements ChannelAwareMessageListener {

    private Logger logger = LoggerFactory.getLogger(AppDeadListener.class);

    @Autowired
    private IInoutSendxnyBaseService inoutSendxnyBaseService;

    @Autowired
    private DingtalkUtils dingtalkUtils;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void onMessage(Message message, Channel channel) throws Exception {
        try {
            channel.basicQos(1); //一次只接受一条未确认的消息
            String content = new String(message.getBody());
            Long id = new Long(content);
            logger.info("进入死信队列的InoutSendxnyBase的id是{}", id);
            IRequest iRequest = new ServiceRequest();

            //找到id对应的 InoutSendxnyBase 报文
            InoutSendxnyBase sendxnyBase = new InoutSendxnyBase();
            sendxnyBase.setId(id);
            sendxnyBase = inoutSendxnyBaseService.selectByPrimaryKey(iRequest, sendxnyBase);

            //报文请求错误,状态、错误信息保存到数据库中
            String key = "InoutSendxnyBase:" + id;
            String errLog = (String) redisTemplate.opsForValue().get(key);
            sendxnyBase.setStatus("E");
            sendxnyBase.setAttribute1(errLog);
            sendxnyBase.setLastUpdateDate(new Date());
            inoutSendxnyBaseService.updateByPrimaryKeySelective(iRequest, sendxnyBase);

            dingtalkUtils.sendDingTalkInterfaceExceptionNotifier(new Exception(errLog), "业务队列出现异常,已进入死信队列");

            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            logger.warn("死信队列出现异常,异常信息是:{}", e.getMessage());
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }
    }
}

5 代码

配置文件:


<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:rabbit="http://www.springframework.org/schema/rabbit"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/context
                           http://www.springframework.org/schema/context/spring-context-4.2.xsd
                           http://www.springframework.org/schema/rabbit
                           http://www.springframework.org/schema/rabbit/spring-rabbit-1.7.xsd">

    <context:property-placeholder location="classpath:config.properties"/>
    
    <rabbit:connection-factory id="defaultConnectionFactory"
                               addresses="${rabbitmq.addresses}"
                               username="${rabbitmq.username}"
                               password="${rabbitmq.password}"
                               virtual-host="${rabbitmq.virtualHost}"
                               publisher-confirms="true"/>

    
    <rabbit:template id="defaultRabbitTemplate"  retry-template="defaultRetryTemplate"
                     message-converter="jackson2JsonMessageConverter" connection-factory="defaultConnectionFactory"
                     mandatory="true" confirm-callback="RabbitmqMessageConfirm"/>

    <rabbit:admin id="defaultRabbitAdmin" connection-factory="defaultConnectionFactory"/>

    <rabbit:direct-exchange name="defaultDirectExchange" id="defaultDirectExchange"/>
    <rabbit:topic-exchange name="defaultTopicExchange"  id="defaultTopicExchange"/>


    <bean id="defaultRetryTemplate" class="org.springframework.retry.support.RetryTemplate">
        <property name="backOffPolicy">
            <bean class="org.springframework.retry.backoff.ExponentialBackOffPolicy">
                <property name="initialInterval" value="500" />
                <property name="multiplier" value="10.0" />
                <property name="maxInterval" value="10000" />
            bean>
        property>
        <property name="retryPolicy">
            <bean class="org.springframework.retry.policy.SimpleRetryPolicy">
                <property name="maxAttempts" value="5"/>
            bean>
        property>
    bean>


    <bean class="com.hand.hap.message.impl.rabbitmq.ListenerContainerFactory"/>

    <bean id="jackson2JsonMessageConverter"  class="org.springframework.amqp.support.converter.Jackson2JsonMessageConverter">
        <property name="jsonObjectMapper" ref="objectMapper"/>
    bean>

    <bean id="RabbitmqMessageConfirm" class="com.hand.htms.config.RabbitmqMessageConfirm"/>
    


    
    
    
    <rabbit:queue name="app_dead_queue" auto-declare="true"/>
    
    <rabbit:queue name="app_outdoor" auto-declare="true">
        <rabbit:queue-arguments>
            <entry key="x-dead-letter-exchange" value="app_dead_exchange"/>
            <entry key="x-dead-letter-routing-key" value="app_outdoor_dead_key"/>
        rabbit:queue-arguments>
    rabbit:queue>

    
    
    <rabbit:direct-exchange name="app_dead_exchange">
        <rabbit:bindings>
            <rabbit:binding queue="app_dead_queue" key="app_outdoor_dead_key"/>
        rabbit:bindings>
    rabbit:direct-exchange>

    
    <bean id="AppOutDoorListener" class="com.hand.tms.miniapp.listener.AppOutDoorListener"/>
    <bean id="AppDeadListener" class="com.hand.tms.miniapp.listener.AppDeadListener"/>


    
    <rabbit:listener-container connection-factory="defaultConnectionFactory" acknowledge="manual" requeue-rejected="false" >
        <rabbit:listener ref="AppOutDoorListener" queue-names="app_outdoor"/>
        <rabbit:listener ref="AppDeadListener" queue-names="app_dead_queue"/>
    rabbit:listener-container>
    
beans>

生产者:我这里异步延时1s再发送到mq,为了防止事物还没提交,而消费者消费过快导致消息失败

		poolTaskExecutor.execute(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //放在此处发送消息,是为了防止回滚而导致将脏数据存入消息队列
            for (InoutSendxnyBase inoutSendxnyBase : inoutSendxnyBases) {
                logger.info("在异步线程池中发送消息至rabbitmq,InoutSendxnyBase的id是{}", inoutSendxnyBase.getId());
                //取插入数据后的id 放入消息队列中,延迟1s
                rabbitTemplate.convertAndSend(QueueConstant.appOutDoorQueue,inoutSendxnyBase.getId());
            }
        });

消费者:

public class AppOutDoorListener implements ChannelAwareMessageListener {

    private Logger logger = LoggerFactory.getLogger(AppOutDoorListener.class);

    @Autowired
    private IInoutSendxnyBaseService inoutSendxnyBaseService;

    @Autowired
    private IGuangxinService guangxinService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void onMessage(Message message, Channel channel) throws Exception {
        //重试次数
        int retryCount = 0;
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        Long id = null; //消息的id
        IRequest iRequest = new ServiceRequest();
        String errLog = null; //错误日志
        channel.basicQos(1); //一次只接受一条未确认的消息

        while (retryCount < 5) {
            retryCount++;
            try {
                //业务操作START
                String content = new String(message.getBody());
                id = new Long(content);
                logger.info("InoutSendxnyBase的id是{}", id);
                //根据id找 InoutSendxnyBase
                InoutSendxnyBase sendxnyBase = new InoutSendxnyBase();
                sendxnyBase.setId(id);
                sendxnyBase = inoutSendxnyBaseService.selectByPrimaryKey(iRequest, sendxnyBase);
                if (StrUtil.isEmpty(sendxnyBase.getStatus())) {
                    throw new Exception("数据库未找到InoutSendxnyBase");
                }
                //如果状态是F或者E,则不执行,防止消息重复消费
                if ("F".equals(sendxnyBase.getStatus()) || "E".equals(sendxnyBase.getStatus())) {
                    channel.basicAck(deliveryTag, false);
                    return;
                }

                //业务操作
                *****

                //业务操作成功后,报文状态更新到数据库
                sendxnyBase.setStatus("F");
                sendxnyBase.setLastUpdateDate(new Date());
                inoutSendxnyBaseService.updateByPrimaryKeySelective(iRequest, sendxnyBase);
                //业务操作END

                //若没有报错,消息确认
                channel.basicAck(deliveryTag, false);
                return;
            } catch (Exception e) {
                errLog = "id为" + id + "的InoutSendxnyBase,出错的原因是:" + e.toString();
                logger.warn(errLog);
                String errorTip = "第" + retryCount + "次消费失败" +
                        ((retryCount < QueueConstant.MAX_RETRIES) ? "," + QueueConstant.RETRY_INTERVAL + "s后重试" : ",进入死信队列");
                logger.warn(errorTip);
                //间隔时间,重试
                Thread.sleep((0.5 * 1000);
            }
        }

        //重试到达5次,消息拒接,进入死信
        if (retryCount == 5) {
            // 使用redis存储错误信息
            String key = "InoutSendxnyBase:" + id;
            redisTemplate.opsForValue().set(key, errLog, 30, TimeUnit.MINUTES);
            channel.basicNack(deliveryTag, false, false);
        }
    }
}

死信队列:

public class AppDeadListener implements ChannelAwareMessageListener {

    private Logger logger = LoggerFactory.getLogger(AppDeadListener.class);

    @Autowired
    private IInoutSendxnyBaseService inoutSendxnyBaseService;

    @Autowired
    private DingtalkUtils dingtalkUtils;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void onMessage(Message message, Channel channel) throws Exception {
        try {
            channel.basicQos(1); //一次只接受一条未确认的消息
            String content = new String(message.getBody());
            Long id = new Long(content);
            logger.info("进入死信队列的InoutSendxnyBase的id是{}", id);
            IRequest iRequest = new ServiceRequest();

            //找到id对应的 InoutSendxnyBase 报文
            InoutSendxnyBase sendxnyBase = new InoutSendxnyBase();
            sendxnyBase.setId(id);
            sendxnyBase = inoutSendxnyBaseService.selectByPrimaryKey(iRequest, sendxnyBase);

            //报文请求错误,状态、错误信息保存到数据库中
            String key = "InoutSendxnyBase:" + id;
            String errLog = (String) redisTemplate.opsForValue().get(key);
            sendxnyBase.setStatus("E");
            sendxnyBase.setAttribute1(errLog);
            sendxnyBase.setLastUpdateDate(new Date());
            inoutSendxnyBaseService.updateByPrimaryKeySelective(iRequest, sendxnyBase);

            dingtalkUtils.sendDingTalkInterfaceExceptionNotifier(new Exception(errLog), "业务队列出现异常,已进入死信队列");

            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            logger.warn("死信队列出现异常,异常信息是:{}", e.getMessage());
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }
    }
}

6 参考

  1. Rabbitmq 与 .NET 实操落地:https://www.bilibili.com/video/BV1by4y1t7S1/?spm_id_from=333.337.search-card.all.click&vd_source=44d1426ea434f76dfe83b4aa384fb2fa
  2. RabbitMQ 常见问题: https://www.cnblogs.com/taojietaoge/p/16895578.html
  3. RabbitMQ 常见问题与解决措施:https://blog.csdn.net/javaxuanshou/article/details/110228784
  4. Docker搭建RabbitMQ集群:https://blog.csdn.net/zhuocailing3390/article/details/122510135

你可能感兴趣的:(java-rabbitmq,rabbitmq,java)