前言
发送邮件功能,借鉴
刚果商城
,根据文档及项目代码实现。整理总结便有了此文,文章有不对的点,请联系博主指出,请多多点赞收藏,您的支持是我最大的动力~
发送邮件功能主要借助 mail、freemarker以及rocketmq
实现。
刚果商城是个分布式项目,近看发送消息模块即可。
标准的DDD
分层架构。
RocketMQ部署
方便起见,使用docker部署环境
RocketMQ 4.5.1 安装部署
安装 NameServer
docker run -d -p 9876:9876 --name rmqnamesrv foxiswho/rocketmq:server-4.5.1
安装 Brocker
1)新建配置目录
mkdir -p /mydata/rocketmq/conf
2)新建配置文件 broker.conf
brokerClusterName = DefaultCluster
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
# 此处为本地ip, 如果部署服务器, 需要填写服务器外网ip
brokerIP1 = xx.xx.xx.xx
3)创建容器
docker run -d \
-p 10911:10911 \
-p 10909:10909 \
--name rmqbroker \
--link rmqnamesrv:namesrv \
-v /mydata/rocketmq/conf/broker.conf:/etc/rocketmq/broker.conf \
-e "NAMESRV_ADDR=namesrv:9876" \
-e "JAVA_OPTS=-Duser.home=/opt" \
-e "JAVA_OPT_EXT=-server -Xms512m -Xmx512m" \
foxiswho/rocketmq:broker-4.5.1
安装 rocketmq 控制台
docker pull pangliang/rocketmq-console-ng
docker run -d \
--link rmqnamesrv:namesrv \
-e "JAVA_OPTS=-Drocketmq.config.namesrvAddr=namesrv:9876 -Drocketmq.config.isVIPChannel=false" \
--name rmqconsole \
-p 8088:8080 \
-t pangliang/rocketmq-console-ng
运行成功,稍等几秒启动时间,浏览器输入 ip:8088
查看。
记得放行上述所有端口,最终结果如下:
RocketMQ安装成功~
引入主要依赖
<!-- 发送邮件主要依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 模板引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- 消息队列 实现解耦 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>
配置文件
主要看application.yaml 和 application-dev.yaml
application.yaml
server:
port: 8001
spring:
profiles:
active: dev
application:
name: message-service
stream:
bindings:
# 主要是如下两个通道的配置 (消费者通道)
mailSend:
consumer:
concurrency: 4
max-attempts: 1
content-type: application/json
destination: message-center_topic
group: message-center_mail-send_cg
# 生产者通道
messageOutput:
content-type: application/json
destination: message-center_topic
group: message-center_general-send_pg
rocketmq:
bindings:
mailSend:
consumer:
delay-level-when-next-consume: -1
tags: common_message-center_mail-send_tag
# ...
application-dev.yaml
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
stream:
rocketmq:
binder:
name-server: 127.0.0.1:9876 # rocketmq服务
mail:
default-encoding: UTF-8
host: smtp.163.com
password: xxx
port: 25
protocol: smtp
username: xxx@163.com
重要的是mail中参数(用的是网易邮箱),username
:网易邮箱账号,password
:登录 SMTP server 的密码
登录 SMTP server 密码
password获取步骤如下:
一、登录网页版邮箱(https://email.163.com/),进入邮箱首页。
二、点击上方设置,选择POP/SMTP/IMAP选项。
三、在客户端协议界面,选择开启对应的协议,IMAP或者POP3分别为不同的收信协议,选择只开启需要的收信协议,比如IMAP,推荐使用IMAP
协议来收发邮件,它可以和网页版完全同步。
四、点击开启,继续开启,手机扫码发送短信后,得到的一串密码即为登录密码
真正代码实现
interfaces层
用户接口层,入参为CQRS风格
,参数都在application层
发送邮件入参:
@Data
@ApiModel("邮箱发送")
public class MailSendCommand {
@ApiModelProperty(value = "标题", example = "刚果商城邮箱验证码提醒")
@NotBlank(message = "邮箱标题不能为空")
private String title;
@Email
@ApiModelProperty(value = "发送者", example = "congomall@163.com")
@NotBlank(message = "邮箱发送者不能为空")
private String sender;
@Email
@ApiModelProperty(value = "接收者", example = "7798432@163.com", notes = "实际发送时更改为自己邮箱")
@NotBlank(message = "邮箱接收者不能为空")
private String receiver;
@Email
@ApiModelProperty("抄送者")
private String cc;
@ApiModelProperty(value = "消息参数")
private List<String> paramList;
// 与数据库对应
@ApiModelProperty(value = "模板ID", example = "userRegisterVerification")
@NotBlank(message = "邮箱模板ID不能为空")
private String templateId;
}
application层
直接调用到application层Service实现类方法,该层封装好参数直接调用基础层中消息生产者。
domain层
领域层里面主要是一些常量、实体类,接口以及仓储接口具体实现在基础层。
infrastructure层 ☆
消息通道配置
source -> sink
public interface MessageSource {
String OUTPUT = "messageOutput";
@Output(MessageSource.OUTPUT)
MessageChannel messageOutput();
}
public interface MessageSink {
String MAIL_SEND = "mailSend";
@Input(MessageSink.MAIL_SEND)
SubscribableChannel mailSend();
}
常量与配置文件中通道名称保持一致
消息生产者
@Slf4j
@Component
@AllArgsConstructor
public class MessageSendProduce {
// 属性名与配置文件中通道名保持一致
private final MessageChannel messageOutput;
/**
* 邮箱消息发送
*/
public void mailMessageSend(MailMessageSendEvent mailMessageSendEvent) {
String keys = UUID.randomUUID().toString();
Message<?> message = MessageBuilder
.withPayload(JSON.toJSONString(mailMessageSendEvent))
.setHeader(MessageConst.PROPERTY_KEYS, keys)
.setHeader(MessageConst.PROPERTY_TAGS, MessageRocketMQConstants.MESSAGE_MAIL_SEND_TAG)
.build();
long startTime = SystemClock.now();
boolean sendResult = false;
try {
// 发送消息给mq
sendResult = messageOutput.send(message, 2000L);
} finally {
log.info("邮箱消息发送,发送状态: {}, Keys: {}, 执行时间: {} ms, 消息内容: {}", sendResult, keys, SystemClock.now() - startTime, JSON.toJSONString(mailMessageSendEvent));
}
}
}
消息消费者
@Slf4j
@Component
@RequiredArgsConstructor
public class MailMessageSendConsume {
private final MessageSendFacade messageSendFacade;
// 幂等性注解,还没研究
@Idempotent(
uniqueKeyPrefix = "mail_message_send:",
key = "#event.messageSendId+'_'+#event.hashCode()",
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.MQ,
keyTimeout = 600L
)
@StreamListener(MessageSink.MAIL_SEND)
public void mailMessageSend(@Payload MailMessageSendEvent event, @Headers Map headers) {
long startTime = System.currentTimeMillis();
try {
MessageSend messageSend = BeanUtil.toBean(event, MessageSend.class);
// 【外观模式】: 抽象消息发送、消息存储以及失败回调业务方等逻辑
messageSendFacade.mailMessageSend(messageSend);
} finally {
log.info("Keys: {}, Msg id: {}, Execute time: {} ms, Message: {}", headers.get("rocketmq_KEYS"), headers.get("rocketmq_MESSAGE_ID"), System.currentTimeMillis() - startTime,
JSON.toJSONString(event));
}
}
}
【外观模式】
直接与外观类交互,外观类封装了做某件事的所有操作,无需与一个个子操作一一交互,
降低了复杂性,提高了可维护性
。
以消息发送为例,将发送邮箱以及消息存储和失败回调业务封装为一个方法降低调用处理复杂度。
发送邮箱核心实现类
☆
@Slf4j
@Component
@AllArgsConstructor
public class MailMessageProduceImpl implements ApplicationListener<ApplicationInitializingEvent>, MailMessageProduce {
private final MailTemplateMapper mailTemplateMapper;
private final JavaMailSender javaMailSender;
private final Configuration configuration;
@SneakyThrows
@Override
public boolean send(MessageSend messageSend) {
try {
// 根据模板id查询模板 模板id:userRegisterVerification
MailTemplateDO mailTemplateDO = mailTemplateMapper.selectOne(
Wrappers.lambdaQuery(MailTemplateDO.class)
.eq(MailTemplateDO::getTemplateId, messageSend.getTemplateId()));
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
helper.setFrom(messageSend.getSender());
helper.setSubject(messageSend.getTitle());
if (StrUtil.isNotBlank(messageSend.getCc())) {
helper.setCc(messageSend.getCc().split(","));
}
if (StrUtil.isNotBlank(messageSend.getReceiver())) {
helper.setTo(messageSend.getReceiver().split(","));
}
Map<String, Object> model = Maps.newHashMap();
// 模板参数名称与下面freemarker模板中参数一一对应
String[] templateParams = mailTemplateDO.getTemplateParam().split(",");
if (ArrayUtil.isNotEmpty(templateParams)) {
for (int i = 0; i < templateParams.length; i++) {
model.put(templateParams[i], messageSend.getParamList().get(i));
}
}
// 模板id就是模板名
String templateKey = messageSend.getTemplateId() + ".ftl";
// 从单例对象容器获取模板
Template template = Singleton.get(templateKey, () -> {
try {
return configuration.getTemplate(templateKey);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
String html = FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
helper.setText(html, true);
// freemarker填充参数,发送邮箱
javaMailSender.send(mimeMessage);
} catch (Throwable ex) {
log.error("邮件发送失败,Request: {}", JSONUtil.toJsonStr(messageSend), ex);
return false;
}
return true;
}
/**
* 初始化邮箱模板 【率先先将所有模板初始化到单例对象容器中】
*/
@SneakyThrows
@Override
public void onApplicationEvent(ApplicationInitializingEvent event) {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(ResourceUtils.CLASSPATH_URL_PREFIX + "templates/*.ftl");
for (Resource resource : resources) {
String templateName = resource.getFilename();
Singleton.put(templateName, configuration.getTemplate(templateName));
}
}
}
模板具体内容:
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<div>
<p style="caret-color: rgb(86, 90, 92); color: rgb(86, 90, 92); font-family: Helvetica, 微软雅黑, 宋体; text-size-adjust: auto; font-size: 20px;">
亲爱的用户:</p>
<p style="caret-color: rgb(86, 90, 92); color: rgb(86, 90, 92); font-family: Helvetica, 微软雅黑, 宋体; font-size: 18px; text-size-adjust: auto;">
您好!感谢您的使用,您本次的验证码为:<span class="Apple-converted-space"> </span></p><b
style="font-family: Helvetica, 微软雅黑, 宋体; text-size-adjust: auto; font-size: 32px; color: rgb(45, 123, 255);">${validCode}</b>
<p style="caret-color: rgb(86, 90, 92); color: rgb(86, 90, 92); font-family: Helvetica, 微软雅黑, 宋体; text-size-adjust: auto; font-size: 20px;">
安全提示:</p>
<p style="caret-color: rgb(86, 90, 92); color: rgb(86, 90, 92); font-family: Helvetica, 微软雅黑, 宋体; font-size: 18px; text-size-adjust: auto;">
为保障您的帐户安全,请在 5 分钟内完成验证,否则验证码将自动失效。<span class="Apple-converted-space"> </span></p>
</div>
<div>
<includetail><!--<![endif]--></includetail>
</div>
</body>
</html>
最终实现效果
测试结果如下:
收件为QQ邮箱:
收件为谷歌邮箱:
经我测试发现,
配置的是网易邮箱,发送者就只能是网易邮箱,接收者可以是任意邮箱
。