- 作者:老汪软件技巧
- 发表时间:2024-09-02 04:02
- 浏览量:
一、简介
在实现邮箱验证码功能时,虽然表面上看只是发送一封带有验证码的邮件,但实际上背后涉及的细节远不止于此。本文将详细探讨如何利用Spring Email、Freemarker和Redis来构建一个高效的邮箱验证码系统。我们将深入分析验证码的时效管理,探讨为什么使用Redis的ZSet集合来存储验证码是更优的选择。同时,介绍如何通过异步操作来优化用户体验,确保邮件发送和验证码存储能够高效、可靠地完成,以及如何对接口进行限流。最后,我们将展示一个完整的实现示例,帮助你全面掌握这一关键功能的开发。
二、验证码时效管理
有些平台在发送验证码时,会提示验证码在几分钟内有效,但当我因为网络问题多次收到几封邮件时,往往只有最后一封邮件的验证码是有效的,这波属实是搞不懂了。
在讨论验证码时效管理时,传统的 Key-Value 存储方式确实容易引发一些问题。因为当一个邮箱多次请求验证码时,新生成的验证码会覆盖旧的验证码,从而导致前面收到的验证码变得无效。这违背了“验证码在几分钟内有效”的承诺,给用户带来了不好的体验。
为了优化用户体验,我们可以考虑使用集合(Set 或 ZSet)来存储验证码信息,从而解决这个问题。以下为不同存储方式的介绍:
Key-Value 存储方式
在最简单的 Key-Value 方式中,每个邮箱只与一个验证码对应。当新的验证码生成时,旧的验证码会被覆盖:
KeyValue过期时间
code
60s
这种方式的问题在于,旧的验证码会被覆盖,用户只能使用最新的验证码,尽管之前收到的验证码可能也在有效期内。
使用Set集合存储方式
我们可以使用 Set 集合存储多次请求生成的验证码:
KeySet过期时间
{code1, code2}
60s
这种方式虽然可以存储多个验证码,但它并不能记录每个验证码的生成时间。因此,过期管理变得复杂:所有验证码只能一起过期,无法单独控制每个验证码的有效期。
使用ZSet进行存储
ZSet(有序集合)提供了一种更灵活的方式来管理验证码。我们可以将验证码和它的生成时间一起存储到 ZSet 中:
KeyZSet过期时间
[{code1, timestamp1}, {code2, timestamp2}]
60s
通过 ZSet 的方式,我们不仅可以存储多个验证码,还可以根据时间戳对它们进行排序,并精准控制每个验证码的有效期。当用户请求验证时,我们可以判断哪个验证码在有效期内,从而避免无效验证码的困扰。
方案选择
最终选择使用Redis中Zset进行存储,说说区别:
三、异步操作优化异步操作的两个关键点发送验证码作为异步操作:发送验证码通常涉及网络请求或外部服务调用,这类操作可能会有一定的延迟。如果同步执行,可能会阻塞主线程,影响用户体验。因此,将发送验证码的操作异步化,可以提高系统的响应速度和整体性能。存储验证码到 Redis 作为后续操作:只有在验证码成功发送之后,才需要将验证码存储到 Redis 中。这样可以避免在发送验证码失败时,仍然保存无效的验证码数据。这种操作顺序的合理性,确保了数据的一致性和操作的准确性。为什么使用CompletableFuture
在没有引入消息队列(MQ)的情况下,CompletableFuture提供了一个简洁而强大的异步编程模型。它允许我们定义一系列的异步操作,并且可以灵活地指定操作之间的依赖关系。以下是代码示例:
CompletableFuture.runAsync(() -> {
// 发送验证码
}).thenRunAsync(() -> { // 当发送验证码错误的时候,不会执行存储验证码到redis
// 存储验证码到redis
});
优点总结四、发送邮箱验证码限流
对于邮箱验证码如何限流,可以参考这两篇限流文章,可以对发送验证码进行60s内防重复提交,每小时、每天发送验证码针对ip限制发送次数
Redis如何多规则限流和防重复提交?
Redis如何多规则限流和Redis防重复提交 | 重构篇
五、为什么使用 Freemarker 模版引擎生成邮件内容
在项目中使用 Freemarker 模版引擎生成邮件内容而不是直接使用常量,主要有以下几个原因:
统一管理与维护:通过 Freemarker 模版引擎,可以将所有邮件内容的结构和样式集中在模板文件中进行管理。这样,当邮件的样式或内容需要调整时,只需修改模板文件即可,无需在代码中逐一修改常量。
动态内容生成:Freemarker 模板引擎支持将动态数据插入到模板中,生成个性化的邮件内容。对于欢迎通知、日志预警、邮箱验证码等场景,可以轻松定制邮件内容,提高灵活性和可维护性。
样式调整方便:模板引擎允许我们灵活地控制邮件的布局和样式。当需要统一调整邮件样式时,只需修改模板,不必在代码中逐一调整常量定义的内容。
六、开始实战
正式编码会自定义线程池,以及一系列工具类,对于这些辅助操作,请移步到源码查看
1. 引入依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-mailartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-freemarkerartifactId>
dependency>
2. 在配置文件中配置您的邮箱信息
我使用的QQ邮箱,需要在设置中获取到这个授权码
spring:
## 邮箱配置
mail:
## 配置邮件服务器的地址
host: smtp.qq.com
## 配置邮件服务器的端口(465或587)
port: 465
## 配置邮箱账号
username: ${company.email}
## 配置邮箱授权码
password: XXX
# 配置默认编码
default-encoding: UTF-8
properties:
mail:
smtp:
socketFactory:
## SSL 连接配置
class: javax.net.ssl.SSLSocketFactory
## 是否开启 debug,这样方便开发者查看邮件发送日志
debug: false
3. 编写 Freemarker 文件
后续采用html发送邮箱验证码,所以 ftl 文件采用html的形式编写
html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱验证码title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f7f7f7;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
text-align: center;
}
p {
color: #666;
font-size: 16px;
line-height: 1.6;
}
.highlight {
color: #007bff;
font-weight: bold;
}
.code-container {
background-color: #f0f0f0;
padding: 10px;
text-align: center;
border-radius: 5px;
margin-top: 10px;
}
.code {
font-size: 24px;
font-weight: bold;
margin: 0;
padding: 5px; /* 添加内边距增加文字间距 */
}
.company-link {
color: #007bff;
text-decoration: none;
}
.footer {
background: linear-gradient(to right, #007bff, #00bfff);
height: 10px;
border-radius: 5px;
margin-top: 20px;
}
style>
head>
<body>
<div class="container">
<h1>尊敬的用户,h1>
<p>您收到来自 <a href="${companyWebsite! 'baidu.com'}" class="company-link">${companyName! '默认公司名'}a>
的邮件,用于验证您的邮箱地址。p>
<div class="code-container">
<p class="code">${verifyCode}p>
div>
<p>请使用此验证码完成验证过程 ( 有效时间 ${validTime} 分钟 )。p>
<p>如果您没有请求此验证码,请忽略此邮件。p>
<p>感谢您的配合,p>
<p><a href="${companyWebsite! 'baidu.com'}" class="company-link">${companyName! '默认公司名'}a>p>
<div class="footer">div>
div>
body>
html>
4. 发送邮箱验证码 & 校验邮箱验证码
代码注释描述的很详细,这里我简单介绍一下
代码中使用了 Freemarker 模板引擎来生成包含验证码的邮件内容。通过构建SendCodeFtlDto对象,将公司名称、公司官网、验证码等信息传递给 Freemarker 模板email_code.ftl,生成完整的 HTML 邮件内容。这种方式使得邮件内容的维护更加灵活,模板可以根据需求进行修改,而不需要调整代码。
在校验邮箱验证码时,代码使用了滚动时间窗口的方式,从 Redis 中获取指定时间范围内(例如三分钟)的验证码,并进行验证。如果验证码有效,系统会立即删除该验证码,防止其被重复使用。这种滚动验证机制提高了验证码校验的灵活性和安全性,确保验证码只能在指定时间段内使用。
为了提高性能和响应速度,代码采用了CompletableFuture实现异步操作。首先异步发送验证码邮件,接着在邮件发送成功后异步将验证码存储到 Redis 中。CompletableFuture的使用使得两个操作可以非阻塞地执行,同时保证了操作的顺序性——只有在邮件发送成功后才会存储验证码,从而提高了系统的可靠性。
/**
* 发送邮箱服务实现类
*
* @author YiFei
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements IEmailService {
// 防止报错
@Resource
private final JavaMailSender javaMailSender;
private final CompanyConfiguration companyConfiguration;
private final TaskExecutor ioIntensiveExecutor;
private final RedisUtil redisUtil;
private final Configuration configuration;
/**
* 使用邮箱发送验证码
*
* @param email 需要送达的邮箱
*/
@Override
public void sendEmailCode(String email) {
// TODO 数据库存储到 email_table 里面
// 1. 生成验证码
String code = RandomUtil.randomString(MailConstants.EMAIL_CODE_NUM);
// 2. 构建 sendCodeFtlDto 对象 (用于生成ftl的信息)
SendCodeFtlDto sendCodeFtlDto = SendCodeFtlDto.builder()
.companyName(companyConfiguration.getName()) // 公司名
.companyWebsite(companyConfiguration.getWebsite()) // 公司官网
.validTime(MailConstants.EMAIL_CODE_TIME_OUT) // 有效时长
.verifyCode(code).build();
// 3. 通过 CompletableFuture 发送邮箱验证码
CompletableFuture.runAsync(() -> {
try (StringWriter stringWriter = new StringWriter()) {
// 4. 构建 email_code.ftl 模板
Template template = configuration.getTemplate(FreemarkerConstants.EMAIL_SEND_CODE_FTL_PATH);
template.process(sendCodeFtlDto, stringWriter);
// 5. 发送 email_code.html 文档
sendEmailWithHtmlContent(email,
SystemConstants.EMAIL_CODE_TEMPLATE_SUBJECT.formatted(companyConfiguration.getName()),
stringWriter.toString()
);
} catch (Exception e) {
// TODO 数据库存储错误信息到 email_table 里面
log.error("记录错误日志,案例说应该持久化到数据库", e);
throw new RuntimeException(e);
}
}, ioIntensiveExecutor).thenRunAsync(() -> {
// 6. 将验证码存储到 redis ( 默认转大写 )
redisUtil.addCacheZSetValue(RedisKeyConstants.EMAIL_CODE_CACHE_PREFIX + email
, code.toUpperCase(), Instant.now().toEpochMilli(), sendCodeFtlDto.getValidTime(), TimeUnit.MINUTES);
}, ioIntensiveExecutor);
}
/**
* 校验邮箱验证码
*
* @param email 邮箱
* @param emailCode 验证码
* @return 是否校验成功
*/
@Override
public boolean checkEmailCode(String email, String emailCode) {
// 1. 获取当前时间
Instant currentTime = Instant.now();
// 2. 减去对应分钟数
Instant adjustedTime = currentTime.minus(Duration.ofMinutes(MailConstants.EMAIL_CODE_TIME_OUT));
// 3. 获取缓存中在3分钟之类的值
String redisEmailCodeKey = RedisKeyConstants.EMAIL_CODE_CACHE_PREFIX + email;
Set cacheZSetByScore = redisUtil.getCacheZSetByScore(
redisEmailCodeKey, // redis 中存储key
adjustedTime.toEpochMilli(), // 开始时间 (三分钟前)
currentTime.toEpochMilli(), // 结束时间 (当前时间)
0, -1
);
// 4. 校验是否存在该值 ( 默认转大写 )
boolean result = cacheZSetByScore.contains(emailCode.toUpperCase());
if (result) {
// 5. 验证成功 删除 redis 缓存数据防止二次使用
redisUtil.deleteObject(redisEmailCodeKey);
}
return result;
}
/**
* 发送包含 HTML 内容的邮件
*
* @param sendToEmail 收件人邮箱地址
* @param subject 邮件主题
* @param html HTML 格式的邮件内容
* @throws MessagingException 发送邮件过程中可能抛出的异常
*/
private void sendEmailWithHtmlContent(String sendToEmail, String subject, String html) throws MessagingException {
// 创建 MimeMessage 对象
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
// 使用 MimeMessageHelper 对象设置邮件的发送者、接收者、主题和内容
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true);
mimeMessageHelper.setFrom(companyConfiguration.getName() + '<' + companyConfiguration.getEmail() + ">");
mimeMessageHelper.setTo(sendToEmail);
mimeMessageHelper.setSubject(subject);
mimeMessageHelper.setText(html, true);
// 发送邮件
javaMailSender.send(mimeMessage);
}
}
七、演示
直接使用服务器部署好的项目进行演示 ( 注:项目有很多有趣的功能,通过邮箱验证码注册后即可体验 )
八、源码
源码地址 | 在线演示 | 觉得不错可以给个start
前端源码位置 : 登录页源码
后端源码位置 : 邮箱服务源码
注意事项 :