自定义注解+AOP解决重复提交的问题

1.哪些因素会引起重复提交?

  1. 前端下单按钮重复点击导致订单创建多次

  2. 网速等原因造成页面卡顿,用户重复刷新提交请求

  3. 黑客或恶意用户使用postman等http工具重复恶意提交表单

2.重复提交会带来哪些问题?

  1. 会导致表单重复提交,造成数据重复或者错乱

  2. 核心接口的请求增加,会消耗服务器负载,严重甚至会造成服务器宕机

3.订单的防重复提交你能想到几种方案?

方式一:前端JS控制点击次数,屏蔽点击按钮无法点击

前端可以被绕过,前端有限制,后端也需要有限制

方式二:数据库或者其他存储增加唯一索引约束

需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机

方式三:服务端token令牌方式下单前先获取令牌-存储redis,下单时一并把token提交并检验和删除-lua脚本

分布式情况下,采用Lua脚本进行操作(保障原子性)

其中方式三 是大家采用得最多的,那有没更加优雅的方式呢?

假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。

4.自定义注解+AOP

4.1 AOP+自定义注解接口防重提交多场景设计

防重提交方式

  • token令牌方式

  • ip+类+方法方式(方法参数)

利用AOP来实现

  • Aspect Oriented Program 面向切面编程, 在不改变原有逻辑上增加额外的功能

  • AOP思想把功能分两个部分,分离系统中的各种关注点

优点

  • 减少代码侵入,解耦

  • 可以统一处理横切逻辑,方便添加和删除横切逻辑

4.2 流程

5.代码实现

依赖

      <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
​
        <!--jedis-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>4.2.2</version>
        </dependency>

配置

server:
  port: 9004
spring:
  redis:
    database: 0
    host: localhost      # redis服务器地址
    port: 6379           # redis端口
    password:
    jedis:
      pool:
        max-active: 50   # 连接池最大连接数(使用负值表示没有限制)
        min-idle: 0      # 连接池中最小空闲连接
        max-wait: -1ms   # 连接池最大阻塞等待时间(使用负值表示没有限制),因为配置了timeout,会以timeout为准
        max-idle: 50     # 连接池中的最大空闲连接
    timeout: 1200        # 连接超时时间(单位:毫秒)

5.1 自定义注解

package com.kang.redis.annotation;
​
import java.lang.annotation.*;
​
/**
 * @Author Emperor Kang
 * @ClassName NonRepeatSubmit
 * @Description 防止重复提交注解
 * @Date 2022/8/24 15:03
 * @Version 1.0
 * @Motto 让营地比你来时更干净
 */
@Documented
@Target(ElementType.METHOD) //应用在方法上
@Retention(RetentionPolicy.RUNTIME) //保留到虚拟机运行时,可通过反射获取
public @interface NonRepeatSubmit {
    /**
     * 支持两种防重复提交方式:
     *  1.方法参数
     *  2.令牌
     */
    enum Type {PARAM,TOKEN}
​
    /**
     * 默认防重复提交,是方法参数
     * @return
     */
    Type limitType() default Type.PARAM;
​
    /**
     * 加锁过期时间,默认是5s
     * @return
     */
    long lockTime() default 5;
}

5.2 编辑切面

package com.kang.redis.aop;
​
import com.kang.redis.annotation.NonRepeatSubmit;
import com.kang.redis.exception.ConfirmTokenException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
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 static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
​
/**
 * @Author Emperor Kang
 * @ClassName NonRepeatSubmitAspect
 * @Description 利用切面对使用自定义注解的地方防止重复提交
 * @Date 2022/8/24 15:44
 * @Version 1.0
 * @Motto 让营地比你来时更干净
 */
@Aspect
@Component
@Slf4j
@SuppressWarnings("all")
public class NonRepeatSubmitAspect {
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    /**
     * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
     * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
     * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本次采用该方法)
     * 方式二:execution:一般用于指定方法的执行
     */
    @Pointcut("@annotation(nonRepeatSubmit)")
    public void pointCutNonRepeatSubmit(NonRepeatSubmit nonRepeatSubmit){}
​
    /**
     * 环绕通知,围绕方法执行
     * 两种环绕方式:
     * 方式一:单用 @Around("execution(* com.kang.redis.controller.*.*(..))")可以
     * 方式二:用@Pointcut和@Around联合注解也可以(本地采用这个)
     * 防重复提交的两种方式
     * 方式一:加锁 固定时间内不能重复提交
     * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
     * @param joinPoint
     * @param nonRepeatSubmit
     * @return
     */
    @Around("pointCutNonRepeatSubmit(nonRepeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint,NonRepeatSubmit nonRepeatSubmit){
        try {
            ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if(servletRequestAttributes == null){
                throw new ConfirmTokenException("AOP拦截:无法获取当前请求request");
            }
            HttpServletRequest request =  servletRequestAttributes.getRequest();
            //一般都是从request中获取当前用户ID
            String userId = "0001";
            //用于记录成功还是失败
            boolean result = false;
            //防重复提交的类型
            String type = nonRepeatSubmit.limitType().name();
            //注解中有默认配置所以不用考虑isBlank的情况
            if(type.equals(NonRepeatSubmit.Type.PARAM.name())){
                //方式一,参数形式防重提交
                log.info("AOP拦截:采用参数形式防重复提交");
            } else {
                //方式二,令牌形式防重提交
                String token = request.getHeader("token");
                if(StringUtils.isBlank(token)){
                    throw new ConfirmTokenException("AOP拦截:token为空,非法请求");
                }
                String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
                /**
                 * 只有第一次提交时才会删除成功
                 * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
                 * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
                 */
                result = redisTemplate.delete(key);
            }
            if(!result){
                log.error("AOP拦截:请求重复提交");
                log.info("AOP拦截:环绕该方法进行通知");
                throw new ConfirmTokenException("AOP拦截:请求重复提交");
            }
            log.info("AOP拦截:方法执行前");
            Object object = joinPoint.proceed();
            log.info("AOP拦截:方法执行后获得结果为:{}",object);
            return object;
        } catch (Throwable e) {
            log.error("AOP拦截:执行出错",e);
            return e.getMessage();
        }
    }
}

5.3 自定义异常类

package com.kang.redis.exception;
​
/**
 * @Author Emperor Kang
 * @ClassName ConfirmToeknException
 * @Description 自定义异常
 * @Date 2022/8/24 16:22
 * @Version 1.0
 * @Motto 让营地比你来时更干净
 */
public class ConfirmTokenException extends Exception{
    public ConfirmTokenException() {
        super();
    }
​
    public ConfirmTokenException(String message) {
        super(message);
    }
​
    public ConfirmTokenException(String message, Throwable cause) {
        super(message, cause);
    }
​
    public ConfirmTokenException(Throwable cause) {
        super(cause);
    }
​
    protected ConfirmTokenException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

5.4 redis的一些配置类(StringRedisTemplate用不到)

package com.kang.redis.config;
​
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
​
/**
 * @Author Emperor Kang
 * @ClassName RedisConfig
 * @Description redis配置类
 * @Date 2022/8/11 11:17
 * @Version 1.0
 * @Motto 让营地比你来时更干净
 */
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setValueSerializer(RedisSerializer.string());
​
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(RedisSerializer.string());
​
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

5.4 控制器编写

package com.kang.redis.controller;
​
import cn.hutool.core.lang.UUID;
import com.alibaba.fastjson.JSON;
import com.kang.redis.annotation.NonRepeatSubmit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
​
import static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
​
/**
 * @Author Emperor Kang
 * @ClassName NonRepeatSubmitController
 * @Description 提前获取令牌用于防重复提交
 * @Date 2022/8/24 15:16
 * @Version 1.0
 * @Motto 让营地比你来时更干净
 */
@RestController
@RequestMapping("/submit")
@Slf4j
public class NonRepeatSubmitController {
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    /**
     * 生成token
     * @return
     */
    @GetMapping("token")
    public String getOrderToken(){
        //假设该用户的userId="0001"
        String userId = "0001";
        //用UUID作为token
        String token = UUID.randomUUID().toString().replaceAll("-","");
        //组装存入redis的key
        String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
        log.info("生成的key为:{}",key);
        //令牌的有效时间是30分钟
        redisTemplate.opsForValue().set(key,String.valueOf(Thread.currentThread().getId()),30, TimeUnit.MINUTES);
        return token;
    }
​
    /**
     * add添加方法
     * @return
     */
    @PostMapping("add")
    @NonRepeatSubmit(limitType = NonRepeatSubmit.Type.TOKEN,lockTime = 10)
    public String getUserInfo(){
        List<Object> db = new ArrayList<>();
        Map<String,String> map = new HashMap<>();
        map.put("name","齐景春");
        map.put("age","10000");
        map.put("message","插入成功");
        db.add(map);
        log.info("该数据插入数据库成功");
        return JSON.toJSONString(db);
    }
​
}

6.测试Token的方式

获取token

 

第一次添加add

 

第二次添加

  

日志变化

 其他部分后续再补充,token的方式至此结束

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340