Springboot + redis + AOP+自定义注解来实现页面幂等性校验

Springboot + Redis + AOP+自定义注解来实现页面幂等性校验

什么是幂等性,通俗理解就是同一个URL, 多次发起同一个请求(链接地址+请求参数), 返回相同的结果。
比如:

  1. 订单接口:不能创建多个相同编号的订单;
  2. 支付宝或微信支付接口:相同的一笔订单,支付只能扣款一次,不允许重复扣款;
  3. 表单提交:在网络超时或者是重复点击提交按钮等情况下,只能成功提交一次表单。

今天我们来聊聊大家最关心的“如何解决表单提交幂等”的问题。

  • 表单重复提交问题的产生:
    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]
---如果设置锁成功,则返回11小时过期,时间设置短一些,可以防止死锁),否则返回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+自定义注解来实现页面幂等性校验,前后端代码就已经提供完了,如果你对我感兴趣,欢迎随时关注我,记得点赞,如果你还有任何疑问或者不理解的地方,或者有更好的建议,或大家想了解某方面的知识点,欢迎大家在下方留言,我会第一时间回复大家!

你可能感兴趣的:(中间件,spring,boot,redis,aop,java,分布式)