• 自定义注解+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.代码实现

    依赖

    1.      
    2.        <dependency>
    3.            <groupId>org.springframework.bootgroupId>
    4.            <artifactId>spring-boot-starter-data-redisartifactId>
    5.        dependency>
    6.        
    7.        <dependency>
    8.            <groupId>redis.clientsgroupId>
    9.            <artifactId>jedisartifactId>
    10.            <version>4.2.2version>
    11.        dependency>

    配置

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

    5.1 自定义注解

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

    5.2 编辑切面

    1. package com.kang.redis.aop;
    2. import com.kang.redis.annotation.NonRepeatSubmit;
    3. import com.kang.redis.exception.ConfirmTokenException;
    4. import lombok.extern.slf4j.Slf4j;
    5. import org.apache.commons.lang3.StringUtils;
    6. import org.aspectj.lang.ProceedingJoinPoint;
    7. import org.aspectj.lang.annotation.Around;
    8. import org.aspectj.lang.annotation.Aspect;
    9. import org.aspectj.lang.annotation.Pointcut;
    10. import org.springframework.beans.factory.annotation.Autowired;
    11. import org.springframework.data.redis.core.StringRedisTemplate;
    12. import org.springframework.stereotype.Component;
    13. import org.springframework.web.context.request.RequestContextHolder;
    14. import org.springframework.web.context.request.ServletRequestAttributes;
    15. import javax.servlet.http.HttpServletRequest;
    16. import static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
    17. /**
    18. * @Author Emperor Kang
    19. * @ClassName NonRepeatSubmitAspect
    20. * @Description 利用切面对使用自定义注解的地方防止重复提交
    21. * @Date 2022/8/24 15:44
    22. * @Version 1.0
    23. * @Motto 让营地比你来时更干净
    24. */
    25. @Aspect
    26. @Component
    27. @Slf4j
    28. @SuppressWarnings("all")
    29. public class NonRepeatSubmitAspect {
    30.    @Autowired
    31.    private StringRedisTemplate redisTemplate;
    32.    /**
    33.     * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
    34.     * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
    35.     * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本次采用该方法)
    36.     * 方式二:execution:一般用于指定方法的执行
    37.     */
    38.    @Pointcut("@annotation(nonRepeatSubmit)")
    39.    public void pointCutNonRepeatSubmit(NonRepeatSubmit nonRepeatSubmit){}
    40.    /**
    41.     * 环绕通知,围绕方法执行
    42.     * 两种环绕方式:
    43.     * 方式一:单用 @Around("execution(* com.kang.redis.controller.*.*(..))")可以
    44.     * 方式二:用@Pointcut@Around联合注解也可以(本地采用这个)
    45.     * 防重复提交的两种方式
    46.     * 方式一:加锁 固定时间内不能重复提交
    47.     * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
    48.     * @param joinPoint
    49.     * @param nonRepeatSubmit
    50.     * @return
    51.     */
    52.    @Around("pointCutNonRepeatSubmit(nonRepeatSubmit)")
    53.    public Object around(ProceedingJoinPoint joinPoint,NonRepeatSubmit nonRepeatSubmit){
    54.        try {
    55.            ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    56.            if(servletRequestAttributes == null){
    57.                throw new ConfirmTokenException("AOP拦截:无法获取当前请求request");
    58.           }
    59.            HttpServletRequest request =  servletRequestAttributes.getRequest();
    60.            //一般都是从request中获取当前用户ID
    61.            String userId = "0001";
    62.            //用于记录成功还是失败
    63.            boolean result = false;
    64.            //防重复提交的类型
    65.            String type = nonRepeatSubmit.limitType().name();
    66.            //注解中有默认配置所以不用考虑isBlank的情况
    67.            if(type.equals(NonRepeatSubmit.Type.PARAM.name())){
    68.                //方式一,参数形式防重提交
    69.                log.info("AOP拦截:采用参数形式防重复提交");
    70.           } else {
    71.                //方式二,令牌形式防重提交
    72.                String token = request.getHeader("token");
    73.                if(StringUtils.isBlank(token)){
    74.                    throw new ConfirmTokenException("AOP拦截:token为空,非法请求");
    75.               }
    76.                String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
    77.                /**
    78.                 * 只有第一次提交时才会删除成功
    79.                 * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
    80.                 * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
    81.                 */
    82.                result = redisTemplate.delete(key);
    83.           }
    84.            if(!result){
    85.                log.error("AOP拦截:请求重复提交");
    86.                log.info("AOP拦截:环绕该方法进行通知");
    87.                throw new ConfirmTokenException("AOP拦截:请求重复提交");
    88.           }
    89.            log.info("AOP拦截:方法执行前");
    90.            Object object = joinPoint.proceed();
    91.            log.info("AOP拦截:方法执行后获得结果为:{}",object);
    92.            return object;
    93.       } catch (Throwable e) {
    94.            log.error("AOP拦截:执行出错",e);
    95.            return e.getMessage();
    96.       }
    97.   }
    98. }

    5.3 自定义异常类

    1. package com.kang.redis.exception;
    2. /**
    3. * @Author Emperor Kang
    4. * @ClassName ConfirmToeknException
    5. * @Description 自定义异常
    6. * @Date 2022/8/24 16:22
    7. * @Version 1.0
    8. * @Motto 让营地比你来时更干净
    9. */
    10. public class ConfirmTokenException extends Exception{
    11.    public ConfirmTokenException() {
    12.        super();
    13.   }
    14.    public ConfirmTokenException(String message) {
    15.        super(message);
    16.   }
    17.    public ConfirmTokenException(String message, Throwable cause) {
    18.        super(message, cause);
    19.   }
    20.    public ConfirmTokenException(Throwable cause) {
    21.        super(cause);
    22.   }
    23.    protected ConfirmTokenException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
    24.        super(message, cause, enableSuppression, writableStackTrace);
    25.   }
    26. }

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

    1. package com.kang.redis.config;
    2. import org.springframework.context.annotation.Bean;
    3. import org.springframework.context.annotation.Configuration;
    4. import org.springframework.data.redis.connection.RedisConnectionFactory;
    5. import org.springframework.data.redis.core.RedisTemplate;
    6. import org.springframework.data.redis.serializer.RedisSerializer;
    7. /**
    8. * @Author Emperor Kang
    9. * @ClassName RedisConfig
    10. * @Description redis配置类
    11. * @Date 2022/8/11 11:17
    12. * @Version 1.0
    13. * @Motto 让营地比你来时更干净
    14. */
    15. @Configuration
    16. public class RedisConfig {
    17.    @Bean
    18.    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    19.        RedisTemplate redisTemplate = new RedisTemplate<>();
    20.        redisTemplate.setConnectionFactory(redisConnectionFactory);
    21.        redisTemplate.setKeySerializer(RedisSerializer.string());
    22.        redisTemplate.setValueSerializer(RedisSerializer.string());
    23.        redisTemplate.setHashKeySerializer(RedisSerializer.string());
    24.        redisTemplate.setHashValueSerializer(RedisSerializer.string());
    25.        redisTemplate.afterPropertiesSet();
    26.        return redisTemplate;
    27.   }
    28. }

    5.4 控制器编写

    1. package com.kang.redis.controller;
    2. import cn.hutool.core.lang.UUID;
    3. import com.alibaba.fastjson.JSON;
    4. import com.kang.redis.annotation.NonRepeatSubmit;
    5. import lombok.extern.slf4j.Slf4j;
    6. import org.springframework.beans.factory.annotation.Autowired;
    7. import org.springframework.data.redis.core.StringRedisTemplate;
    8. import org.springframework.web.bind.annotation.GetMapping;
    9. import org.springframework.web.bind.annotation.PostMapping;
    10. import org.springframework.web.bind.annotation.RequestMapping;
    11. import org.springframework.web.bind.annotation.RestController;
    12. import java.util.ArrayList;
    13. import java.util.HashMap;
    14. import java.util.List;
    15. import java.util.Map;
    16. import java.util.concurrent.TimeUnit;
    17. import static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
    18. /**
    19. * @Author Emperor Kang
    20. * @ClassName NonRepeatSubmitController
    21. * @Description 提前获取令牌用于防重复提交
    22. * @Date 2022/8/24 15:16
    23. * @Version 1.0
    24. * @Motto 让营地比你来时更干净
    25. */
    26. @RestController
    27. @RequestMapping("/submit")
    28. @Slf4j
    29. public class NonRepeatSubmitController {
    30.    @Autowired
    31.    private StringRedisTemplate redisTemplate;
    32.    /**
    33.     * 生成token
    34.     * @return
    35.     */
    36.    @GetMapping("token")
    37.    public String getOrderToken(){
    38.        //假设该用户的userId="0001"
    39.        String userId = "0001";
    40.        //用UUID作为token
    41.        String token = UUID.randomUUID().toString().replaceAll("-","");
    42.        //组装存入redis的key
    43.        String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
    44.        log.info("生成的key为:{}",key);
    45.        //令牌的有效时间是30分钟
    46.        redisTemplate.opsForValue().set(key,String.valueOf(Thread.currentThread().getId()),30, TimeUnit.MINUTES);
    47.        return token;
    48.   }
    49.    /**
    50.     * add添加方法
    51.     * @return
    52.     */
    53.    @PostMapping("add")
    54.    @NonRepeatSubmit(limitType = NonRepeatSubmit.Type.TOKEN,lockTime = 10)
    55.    public String getUserInfo(){
    56.        List db = new ArrayList<>();
    57.        Map map = new HashMap<>();
    58.        map.put("name","齐景春");
    59.        map.put("age","10000");
    60.        map.put("message","插入成功");
    61.        db.add(map);
    62.        log.info("该数据插入数据库成功");
    63.        return JSON.toJSONString(db);
    64.   }
    65. }
    66. 6.测试Token的方式

      获取token

       

      第一次添加add

       

      第二次添加

        

      日志变化

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

    67. 相关阅读:
      9月27日复习
      创建第一个 Cypress 应用后使用命令行 npx Cypress open 报错的原因分析
      C#学习笔记--变量类型的转换
      MyBatis
      【初识 Docker | 基础篇】 Docker 搭建仓库
      02.机器学习原理(复习)
      ELK搭建
      助力,NTP网络时间服务器(GPS北斗时钟)助力精准大数据
      MySQL及MySQLworkbench安装教程
      上海亚商投顾:A股缩量调整 AIGC、Web3.0概念抢眼
    68. 原文地址:https://blog.csdn.net/LuckFairyLuckBaby/article/details/126510264