什么是幂等性,通俗理解就是同一个URL, 多次发起同一个请求(链接地址+请求参数), 返回相同的结果。
比如:
今天我们来聊聊大家最关心的“如何解决表单提交幂等”的问题。
表单重复提交问题的产生:
1)、用户连续重复点击提交按钮;
2)、在客户端请求服务端的过程中(表单提交的过程中),网络出现延迟或超时等情况;
3)、表单提交之后,用户疯狂点击浏览器的“刷新”按钮;
4)、用户提交表单后,点击浏览器的“后退”按钮回退到表单页面后进行再次提交;
5)、系统遭遇到恶意攻击行为。
6)、未完待续…
解决手段
1)、前端处理手段:
前台页面使用JS进行控制,定义全局变量进行标识控制,如果提交成功,将变量设置为true,否则设置为false,具体前台代码实现如下:(js代码要写在HTML标签的尾部,具体原因,我会专门开一个专题进行讲解,欢迎关注!)
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<!DOCTYPE HTML>
<html>
<head>
<title>Form表单幂等</title>
</head>
<body>
<form action="/serSubmit/postFormSubmit" onsubmit="return postSubmit()" method="post">
用户名:<input type="text" name="userName">
密 码:<input type="password" name="userPwd">
<input type="submit" value="提交" >
</form>
</body>
<script type="text/javascript">
//表单是否已经提交的标识,默认为false
var isCommint = false;
function postSubmit(){
if(isCommint==false){
//提交表单后,将标识设置为true
isCommint = true;
//返回true让表单正常提交到后台
return true;
}else{
//返回false,不允许表单重复提交
return false;
}
}
</script>
</html>
2)、后端处理手段:
MySQL方法:
将某个字段设置成“唯一索引”(适用范围:insert数据)。
MySQL某个字段设置成唯一索引避免重复数据,也能达到幂等效果。比如:“用户名”这个字段在数据库里设置成唯一索引,然后在代码里监控MySQL行为,如果行为有异常,则说明存在重复添加用户名,则可在代码里做下一步处理。
非MySQL方法
redis分布式锁+AOP+自定义注解方式(适用范围:在具有提交行为操作的方法上)。
思路:后端生成token(用户名+UUID.randomUUID().toString()生成)并保存到redis中,前端获取后端的token并隐藏token值,如果用户提交表单,这使用AOP拦截获取前端提交表单中的token值(获取之前要获取redis分布式锁“用户名唯一性作为锁的名称”),判断获取的token值是否在redis中存在,如果存在,则请求通过放行,同时删除在redis中保存的token值,删除分布式锁;反之,如果token不存在,则说明存在重复提交做下一步处理即可。
redis知识点:使用“set key value ex 秒 nx”语法,如果设置成功返回OK,否则返回nil;
redis分布式锁作用:同一时间只允许一个请求获取锁进行业务处理,该作用通常也是用来处理高并发的手段之一;
自定义注解作用:标注在方法上,监控哪些方法是要进行幂等性处理的;
AOP作用:采用环绕通知,监听哪些方法上标有注解,然后对该方法进行幂等性处理。
首先采用Springboot对redis封装好的方法,RedisTemplaet类来实现,要实现分布式锁,一定要采用redis官方提供的lua脚本进行实现,因为“set key value ex 秒 nx”语句它不是原子性的,包括“setnx”这个命令也不是原子性的(因为是先判断是否存在值之后,才会执行set命令,相当于是两个命令的组合),所以如果不采用lua脚本来实现的话,会存在并发的问题。redis官方介绍了如果使用lua脚本里执行redis命令,则其它命令必须等待lua脚本执行完之后,才行执行其它的命令,这样就能够保证lua里面的命令,它是原子性的。
lua获取锁脚本代码如下:
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by wj.
--- DateTime: 2020/5/9 上午10:53
---获取锁
local keys=KEYS[1]
local argv=ARGV[1]
---如果设置锁成功,则返回1(1小时过期,时间设置短一些,可以防止死锁),否则返回0
---如果设置成功,则类型是table类型,设置失败是boolean类型
local setlock =redis.call('set',keys,argv,'ex',3600,'nx')
if setlock==false then
return 0
elseif type(setlock)=='table' and setlock['ok']=='OK' then
return 1
end
lua删除锁脚本代码如下:
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by wj.
--- DateTime: 2020/5/9 上午10:54
---删除锁
---
local keys=KEYS[1]
---返回的是一个number类型
local delredislock=redis.call('del',keys)
return delredislock
编写好lua脚本之后,我们继续编写redisTemplate工具类(该工具类作用:设置锁,删除锁);
分布式加锁解锁工具类代码如下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.ScriptSource;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 分布式锁
* **/
@Component
public class CommonRedisHelper {
//锁名称
private static final String LOCK_PREFIX = "redis_lock";
@Autowired
private RedisTemplate redisTemplate;
public boolean setRedisLock(String key,String value){
String lock = LOCK_PREFIX + key;
String lockValue=LOCK_PREFIX + value;
// 加载脚本文件,脚本文件放在resources目录下
ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("lua/setredislock.lua"));
DefaultRedisScript defaultRedisScript = new DefaultRedisScript();
defaultRedisScript.setScriptSource(scriptSource);
// 设置脚本返回类型
defaultRedisScript.setResultType(String.class);
String result = redisTemplate.execute(defaultRedisScript,Collections.singletonList(lock),lockValue).toString();
return "1".equals(result)?true:false;
}
public boolean delRedisLock(String key){
List list =new ArrayList<>();
String lock = LOCK_PREFIX + key;
list.add(lock);
// 加载脚本文件
ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("lua/delredislock.lua"));
DefaultRedisScript defaultRedisScript = new DefaultRedisScript();
defaultRedisScript.setScriptSource(scriptSource);
// 设置脚本返回类型
defaultRedisScript.setResultType(String.class);
String result = redisTemplate.execute(defaultRedisScript, list).toString();
return "1".equals(result)?true:false;
}
}
分布式锁写好后,我们继续编写自定义注解;
自定义注解代码如下:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author wj
* 注解只在方法上生效
* 验证提交重复性
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface VerificationToken {
boolean value() default false;
}
编写好自定义注解之后,我们接下来编写AOP拦截获取前端token值,并进行教研;
AOP代码如下:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Aspect
@Component
public class AopVerification {
private static Logger logger= LoggerFactory.getLogger(AopVerification.class);
@Autowired
private RedisUtils redisUtils;
@Autowired
private CommonRedisHelper commonRedisHelper;
@Pointcut(value="@annotation(XXXX.VerificationToken)")
public void VerfivtionAop(){}
@Around("VerfivtionAop()")
public Object BeforeVerfivtionAop(ProceedingJoinPoint joinPoint) throws Throwable {
try{
User user = UserUtils.getUser();
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpServletResponse response = servletRequestAttributes.getResponse();
String token= request.getParameter("token");
if (!"".equals(token)&&token!=null){
//获取锁
while (commonRedisHelper.setRedisLock(user.getLoginName(),user.getLoginName())){
try{
//注:redisUtils是操作redis工具类的代码
//判断key是否存在
if(redisUtils.isHasKey(token)){
//说明Redis中有值存在,不存在重复提交
//删除key
if (redisUtils.deleteStringKey(token)){
logger.info("正常提交数据");
}
//继续执行被拦截的方法,放行
return joinPoint.proceed();
}else {
logger.info("重复提交数据");
//返回到不允许重复提交页面
return null;
}
}catch (Exception e){}
finally {
//释放锁
commonRedisHelper.delRedisLock(user.getLoginName());
}
}
logger.info("获取不到锁");
//返回到不允许重复提交页面
return null;
}else {
logger.info("重复提交数据");
//返回到不允许重复提交页面
return null;
}
} catch (Throwable throwable) {
throwable.printStackTrace();
logger.info("提交遇到了故障,请稍后再试!");
//返回到不允许重复提交页面
return null;
}
}
}
还有一个生产token的方法,代码如下;
生成token代码给前端:
/**
*返回前端页面
*/
@RequestMapping(value = "form")
public String form()
{
//注:UserUtils工具类是获取登录用户的相关信息,不方便提供代码。
User user=UserUtils.getUser();
String token= UUID.randomUUID().toString();
try {
redisUtils.setStringValue(token,token,86400);
}catch (Exception e){
logger.info("添加Redis有误");
}
model.addAttribute("token", token);
return "XXX/submitForm";
}
使用自定义注解监控表单提交代码:
/**
*表单提交
*/
@VerificationToken()
@RequestMapping(value = "save")
public void save() {}
至此,后端的代码就已经写完了,还差一个前端代码如下:
前端页面获取隐藏的token值
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<!DOCTYPE HTML>
<html>
<head>
<title>Form表单幂等</title>
</head>
<body>
<form action="/XXX/submitForm">
<input type="hidden" name="token" value="${token}">
用户名:<input type="text" name="userName">
密 码:<input type="password" name="userPwd">
<input type="submit" value="提交" >
</form>
</body>
<script type="text/javascript">
//表单是否已经提交的标识,默认为false
var isCommint = false;
function postSubmit(){
if(isCommint==false){
//提交表单后,将标识设置为true
isCommint = true;
//返回true让表单正常提交到后台
return true;
}else{
//返回false,不允许表单重复提交
return false;
}
}
</script>
</html>
到这里,整个Springboot + redis + AOP+自定义注解来实现页面幂等性校验,前后端代码就已经提供完了,如果你对我感兴趣,欢迎随时关注我,记得点赞,如果你还有任何疑问或者不理解的地方,或者有更好的建议,或大家想了解某方面的知识点,欢迎大家在下方留言,我会第一时间回复大家!