一、前言

我们之前通过SpringBoot整合mail可以很方便的实现邮件的发送,但是这类服务通常不稳定,当出现网络抖动的时候,会导致邮件自动推送失败。现在我们学习SpringBoot整合RabbitMQ实现邮件的完美投递,以便实现邮件 100% 被投递成功。

内容涵盖了 RabbitMQ 很多知识点,如:

  • 生产者和消费者模型
  • 消息发送机制
  • 消费确认机制
  • 消息的重新投递
  • 消息消费失败的处理方案

二、项目准备

1.安装RabbitMQ服务

安装 RabbitMQ 服务,这一步比较简单,可以访问下面的官方地址,下载软件包并依次按照步骤进行安装即可。

https://rabbitmq.org.cn/docs/download

安装成功之后,登陆 RabbitMQ 控制台,可以看到类似于如下界面。

SpringBoot整合RabbitMQ使用邮件异步发送_RabbitMQ

2.创建交换器

点击“Exchanges”菜单,进入“交换器”管理界面。

SpringBoot整合RabbitMQ使用邮件异步发送_邮件发送_02

进入之后,点击最下方“Add a new exchange”按钮,创建一个类型为topic,名称叫mail.exchange的交换器,并提交。

SpringBoot整合RabbitMQ使用邮件异步发送_SpringBoot_03

3.创建消息队列

接着,点击“Queues”菜单,进入消息队列管理界面。

SpringBoot整合RabbitMQ使用邮件异步发送_异步发送_04

同样的,点击最下方“Add a new queue”按钮,创建一个名称叫mq.mail.ack的消息队列,并提交。

SpringBoot整合RabbitMQ使用邮件异步发送_SpringBoot_05

保存之后,在列表中可以看到刚刚创建的消息队列,然后点击进入详情。

在详情中,将当前消息队列与上文创建的交换器进行绑定,便于后续通过交换器来发送消息到队列,操作如下。

SpringBoot整合RabbitMQ使用邮件异步发送_SpringBoot_06

对于topic类型的交换器,通常不直接与消息队列进行交互,而是通过一个路由键,将消息路由到目标消息队列,这样设计的目的是让消息投递更加灵活。路由键,可以简单理解为类似于路由器,对数据进行路由分发处理。

4.配置邮箱发送服务器

为了实现邮件自动发送功能,我们还需要准备一个邮箱发送服务器,以 QQ 邮箱为例,登陆进去之后,在设置里面开启 POP3/SMTP 服务,并获取授权码记录下来。

SpringBoot整合RabbitMQ使用邮件异步发送_SpringBoot_07

SpringBoot整合RabbitMQ使用邮件异步发送_RabbitMQ_08

三、项目实践

1.引入依赖

<!--mail 支持-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!--amqp 支持-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2.添加相关配置

application.properties中添加 rabbitmq、邮箱相关配置,示例如下:

# 配置邮件发送主机地址
spring.mail.host=smtp.qq.com
# 配置邮件发送服务端口号
spring.mail.port=25
# 配置邮件发送服务协议
spring.mail.protocol=smtp
# 配置邮件发送者用户名或者账户
spring.mail.username=xxxx
# 配置邮件发送者密码或者授权码
spring.mail.password=xxxx
# 配置邮件默认编码
spring.mail.default-encoding=UTF-8
# 配置smtp相关属性
spring.mail.properties.mail.smtp.auth=false
spring.mail.properties.mail.smtp.ssl.enable=false
spring.mail.properties.mail.smtp.ssl.required=false

#rabbitmq配置
spring.rabbitmq.host=127.0.0.1
spring.rabbitmq.port=5672
spring.rabbitmq.virtual-host=qx
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
#开启confirms回调 P -> Exchange
spring.rabbitmq.publisher-confirms=true
# 开启returnedMessage回调 Exchange -> Queue
spring.rabbitmq.publisher-returns=true
# 设置手动确认(ack) Queue -> C
spring.rabbitmq.listener.simple.acknowledge-mode=manual
spring.rabbitmq.listener.simple.prefetch=100

其中,spring.mail.usernamespring.mail.password指的就是上文中创建的邮箱账号和授权码,将其配置进去即可。

3.编写RabbitMQ配置类

package com.example.dataproject.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author qx
 * @date 2024/9/11
 * @des 监听消息的发送情况配置
 */
@Configuration
@Slf4j
public class RabbitConfig {
    @Autowired
    private CachingConnectionFactory connectionFactory;

    @Bean
    public RabbitTemplate rabbitTemplate() {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        //设置消息转换器为json格式
        rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter());

        //消息是否成功发送到Exchange
        rabbitTemplate.setConfirmCallback((correlationData, b, s) -> {
            if (b) {
                log.info("消息发送到Exchange成功:{}", correlationData);
            } else {
                log.error("消息发送到Exchange失败,{},cause:{}", correlationData, s);
            }
        });
        //触发setReturnCallback回调必须设置mandatory=true,否则Exchange没有找到Queue就会丢弃掉消息,而不会触发回调
        rabbitTemplate.setMandatory(true);

        //消息是否从Exchange路由到Queue,注意:这是一个失败回调,只有消息从Exchange路由到Queue失败才会回调这个方法
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            log.error("消息从Exchange路由到Queue失败:exchange:{},route:{},replyCode:{},replyText:{},message{}", exchange, routingKey, replyCode, replyText, message);
        });

        return rabbitTemplate;
    }
}

4.编写生产者服务

在 Spring Boot 中,我们可以利用RabbitTemplate工具,将数据通过交换器发送到目标消息队列,示例如下。

先创建一个实体类

package com.example.dataproject.entity;

import lombok.Getter;
import lombok.Setter;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
@Getter
@Setter
public class Mail {

    /**
     * 目标邮箱地址
     */
    private String to;

    /**
     * 标题不能为空
     */
    private String title;

    /**
     * 正文不能为空
     */
    private String content;

    /**
     * 消息id
     */
    private String msgId;
}

接下来创建一个工具类根据实体生成对应Message

package com.example.dataproject.utils;

import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.amqp.core.MessageProperties;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
public class MessageHelper {
    /**
     * 将对象序列化成消息数据
     *
     * @param obj
     * @return
     */
    public static Message objToMsg(Object obj) {
        if (null == obj) {
            return null;
        }
        if (obj instanceof String) {

        }

        Message message = MessageBuilder.withBody(JsonUtil.objToStr(obj).getBytes()).build();
        // 设置消息持久化
        message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
        // 设置消息为json格式
        message.getMessageProperties().setContentType(MessageProperties.CONTENT_TYPE_JSON);
        return message;
    }

    /**
     * 将消息数据反序列化成对象
     *
     * @param message
     * @param clazz
     * @param <T>
     * @return
     */
    public static <T> T msgToObj(Message message, Class<T> clazz) {
        if (null == message || null == clazz) {
            return null;
        }

        String json = new String(message.getBody());
        return JsonUtil.strToObj(json, clazz);
    }
}

创建生产者服务类

package com.example.dataproject.service;

import com.example.dataproject.entity.Mail;
import com.example.dataproject.utils.MessageHelper;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageBuilder;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.UUID;

/**
 * @author qx
 * @date 2024/9/11
 * @des 生产者服务
 */
@Service
public class ProduceService {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public boolean sendData(Mail mail) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        mail.setMsgId(uuid);
        CorrelationData correlationData = new CorrelationData(uuid);
        rabbitTemplate.convertAndSend("mail.exchange", "route.mail.ack", MessageHelper.objToMsg(mail), correlationData);
        return true;
    }

}

5.编写消费者服务

package com.example.dataproject.service;

import com.example.dataproject.entity.Mail;
import com.example.dataproject.utils.MessageHelper;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * @author qx
 * @date 2024/9/11
 * @des 消费者
 */
@Component
@Slf4j
public class ConsumerService {

    @Autowired
    private SendMailService sendMailService;

    @RabbitListener(queues = {"mq.mail.ack"})
    public void consume(Message message, Channel channel) throws IOException {
        log.info("收到消息:{}", message);
        Mail mail = MessageHelper.msgToObj(message, Mail.class);
        boolean falg = sendMailService.send(mail);
        //手动确认模式
        long tag = message.getMessageProperties().getDeliveryTag();
        if (falg) {
            //发送成功 删除消息
            channel.basicAck(tag, falg);
        } else {
            //发送失败 重试返回消息队列
            channel.basicNack(tag, falg, true);
        }
    }

}

6.编写邮件发送服务

package com.example.dataproject.service;

import com.example.dataproject.entity.Mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

/**
 * @author qx
 * @date 2024/9/11
 * @des 邮件发送服务层
 */
@Service
@Slf4j
public class SendMailService {

    @Value("${spring.mail.username}")
    private String from;


    @Autowired
    private JavaMailSender mailSender;

    public boolean send(Mail mail) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(from);
        message.setTo(mail.getTo());
        message.setSubject(mail.getTitle());
        message.setText(mail.getContent());

        try {
            mailSender.send(message);
            log.info("邮件发送成功");
            return true;
        } catch (Exception e) {
            log.error("邮件发送失败:from:{},to:{},desc:{}", from, mail.getTo(), e.getMessage());
            return false;
        }
    }
}

7.编写 controller 接口

接着,编写一个 controller 接口,将邮件发送服务暴露出去,示例如下:

package com.example.dataproject.controller;

import com.example.dataproject.entity.Mail;
import com.example.dataproject.service.ProduceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author qx
 * @date 2024/9/11
 * @des
 */
@RestController
public class MailController {

    @Autowired
    private ProduceService produceService;


    @PostMapping("/send")
    public String sendMail(Mail mail) {
        boolean flag = produceService.sendData(mail);
        return flag ? "success" : "fail";
    }
}

8.服务测试

启动服务进行测试

SpringBoot整合RabbitMQ使用邮件异步发送_异步发送_09

点击发送后我们查看控制台的日志,发送邮件已经成功发送了,并且发送成功之后消息被删除,不会重新进行发送。

SpringBoot整合RabbitMQ使用邮件异步发送_SpringBoot_10

我们查看收件人的邮箱也接收到了邮件。

SpringBoot整合RabbitMQ使用邮件异步发送_RabbitMQ_11

可以清楚的看到,邮件发送成功!当大批量的发送邮件,也不用担心,因为整个邮件的发送都是异步的,不会阻塞主流程的运行。