SpringBoot自定义注解 + AOP 防止重复提交

SpringBoot自定义注解 + AOP 防止重复提交

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

  • 开发的项目中可能会出现下面这些情况:
  • 前端下单按钮重复点击导致订单创建多次
  • 网速等原因造成页面卡顿,用户重复刷新提交请求
  • 黑客或恶意用户使用postman等http工具重复恶意提交表单

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

  • 重复提交带来的问题:
  • 会导致表单重复提交,造成数据重复或者错乱
  • 核心接口的请求增加,消耗服务器负载,严重甚至会造成服务器宕机

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

  • 核心接口需要做防重提交,你应该可以想到以下几种方案:
  • 方式一:前端JS控制点击次数,屏蔽点击按钮无法点击 前端可以被绕过,前端有限制,后端也需要有限制
  • 方式二:数据库或者其他存储增加唯一索引约束 需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
  • 方式三:服务端token令牌方式 下单前先获取令牌-存储redis,下单时一并把token提交并检验和删除-lua脚本
  • 其中方式三 是大家采用的最多的,那有没更加优雅的方式呢?
  • 假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。
  • 本文采用自定义注解+AOP的方式,优雅的实现防止重复提交功能。

自定义注解

Java核心知识-自定义注解(先了解下什么是自定义注解)

Annotation(注解)
  • 从JDK 1.5开始, Java增加了对元数据(MetaData)的支持,也就是 Annotation(注解)。 注解其实就是代码里的特殊标记,它用于替代配置文件,常见的很多,有 @Override、@Deprecated等
什么是元注解
  • 元注解是注解的注解,比如当我们需要自定义注解时会需要一些元注解(meta-annotation),如@Target和@Retention
  • 在这里插入图片描述
java内置4种元注解
  • @Target 表示该注解用于什么地方

  • ElementType.CONSTRUCTOR 用在构造器

  • ElementType.FIELD 用于描述域-属性上

  • ElementType.METHOD 用在方法上

  • ElementType.TYPE 用在类或接口上

  • ElementType.PACKAGE 用于描述包

  • 在这里插入图片描述

  • @Retention 表示在什么级别保存该注解信息

  • RetentionPolicy.SOURCE 保留到源码上

  • RetentionPolicy.CLASS 保留到字节码上

  • RetentionPolicy.RUNTIME 保留到虚拟机运行时(最多,可通过反射获取)

  • 在这里插入图片描述

  • @Documented 将此注解包含在 javadoc 中

    • @Inherited 是否允许子类继承父类中的注解
    • @interface 用来声明一个注解,可以通过default来声明参数的默认值
  • 自定义注解时,自动继承了java.lang.annotation.Annotation接口,可以通过反射可以获取自定义注解

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

  • 防重提交方式
  • token令牌方式
  • ip+类+方法方式(方法参数)
  • 利用AOP来实现
  • Aspect Oriented Program 面向切面编程, 在不改变原有逻辑上增加额外的功能
  • AOP思想把功能分两个部分,分离系统中的各种关注点
  • 好处
    • 减少代码侵入,解耦
    • 可以统一处理横切逻辑,方便添加和删除横切逻辑
  • 业务流程:
  • 在这里插入图片描述

代码实战防重提交自定义注解之Token令牌/参数方式

自定义注解token令牌方式
  • 第一步 自定义注解

  • import java.lang.annotation.*;
    /**
     * 自定义防重提交
     */
    @Documented
    @Target(ElementType.METHOD)//可以用在方法上
    @Retention(RetentionPolicy.RUNTIME)//保留到虚拟机运行时,可通过反射获取
    public @interface RepeatSubmit {
        /**
         * 防重提交,支持两种,一个是方法参数,一个是令牌
         */
        enum Type { PARAM, TOKEN }
        /**
         * 默认防重提交,是方法参数
         * @return
         */
        Type limitType() default Type.PARAM;
        /**
         * 加锁过期时间,默认是5秒
         * @return
         */
        long lockTime() default 5;
    }
    =
    
  • 第二步 引入redis

  • #-------redis连接配置-------
    spring.redis.client-type=jedis
    spring.redis.host=120.79.xxx.xxx
    spring.redis.password=123456
    spring.redis.port=6379
    spring.redis.jedis.pool.max-active=100
    spring.redis.jedis.pool.max-idle=100
    spring.redis.jedis.pool.min-idle=100
    spring.redis.jedis.pool.max-wait=60000
    
  • 第三步 下单前获取令牌用于防重提交

  • @Autowired
    private StringRedisTemplate redisTemplate;
    /**
     * 提交订单令牌的缓存key
     */
    public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s";
    
    /**
      * 下单前获取令牌用于防重提交
      * @return
      */
    @GetMapping("token")
    public JsonData getOrderToken(){
      //获取登录账户
      long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
      //随机获取32位的数字+字母作为token
      String token = CommonUtil.getStringNumRandom(32);
      //key的组成
      String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY,accountNo,token);
      //令牌有效时间是30分钟
      redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()),30,TimeUnit.MINUTES);
    
      return JsonData.buildSuccess(token);
    }
    
    /**
     * 获取随机长度的串
     *
     * @param length
     * @return
     */
    private static final String ALL_CHAR_NUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    
    public static String getStringNumRandom(int length) {
      //生成随机数字和字母,
      Random random = new Random();
      StringBuilder saltString = new StringBuilder(length);
      for (int i = 1; i <= length; ++i) {
    
        saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length())));
      }
      return saltString.toString();
    }
    
  • 第四步 定义切面类-开发解析器

  • 根据type区分是使用token方式 还是参数方式

  • 先看下token的方式

  • /**
     * 定义一个切面类
     **/
    @Aspect
    @Component
    @Slf4j
    public class RepeatSubmitAspect {
        @Autowired
        private StringRedisTemplate redisTemplate;
     
        /**
         * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
         * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
         * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
         * 方式二:execution:一般用于指定方法的执行
         */
        @Pointcut("@annotation(repeatSubmit)")
        public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
     
        }
     
        /**
         * 环绕通知, 围绕着方法执行
         * @param joinPoint
         * @param repeatSubmit
         * @return
         * @throws Throwable
         * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
         * <p>
         * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
         * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
         * <p>
         * <p>
         * 两种方式
         * 方式一:加锁 固定时间内不能重复提交
         * <p>
         * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
         */
        @Around("pointCutNoRepeatSubmit(repeatSubmit)")
        public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
            //用于记录成功或者失败
            boolean res = false;
            //防重提交类型
            String type = repeatSubmit.limitType().name();
            if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
                //方式一,参数形式防重提交
               } else {
                //方式二,令牌形式防重提交
                String requestToken = request.getHeader("request-token");
                if (StringUtils.isBlank(requestToken)) {
                    throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
                }
                String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
                /**
                 * 提交表单的token key
                 * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
                 * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
                 */
                res = redisTemplate.delete(key);
            }
            if (!res) {
                log.error("请求重复提交");
                log.info("环绕通知中");
                return null;
            }
            log.info("环绕通知执行前");
            Object obj = joinPoint.proceed();
            log.info("环绕通知执行后");
            return obj;
        }
    }
    
  • 验证结果

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 第一次请求后,执行正常查询筛选逻辑

  • 在这里插入图片描述

  • 再次请求同一个接口:

  • 在这里插入图片描述

  • 这样就完成了通过AOP token的防止重复提交

再看下参数的防重方式
  • 参数式防重复的核心就是IP地址+类+方法+账号的方式,增加到redis中做为key。第一次加锁成功返回true,第二次返回false,通过这种来做到的防重复。

  • 第一步 引入依赖pom.xml:

  • <dependency>
          <groupId>org.redisson</groupId>
          <artifactId>redisson</artifactId>
          <version>3.10.1</version>
    </dependency>
    
  • 第二步 增加配置:

  • #-------redis连接配置-------
    spring.redis.client-type=jedis
    spring.redis.host=120.79.xxx.xxx
    spring.redis.password=123456
    spring.redis.port=6379
    spring.redis.jedis.pool.max-active=100
    spring.redis.jedis.pool.max-idle=100
    spring.redis.jedis.pool.min-idle=100
    spring.redis.jedis.pool.max-wait=60000
    
  • 第三步 获取redissonClient:

  •  
    import org.redisson.Redisson;
    import org.redisson.api.RedissonClient;
    import org.redisson.config.Config;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
     
    @Configuration
    public class RedissionConfiguration {
        @Value("${spring.redis.host}")
        private String redisHost;
        @Value("${spring.redis.port}")
        private String redisPort;
        @Value("${spring.redis.password}")
        private String redisPwd;
        /**
         * 配置分布式锁的redisson
         * @return
         */
        @Bean
        public RedissonClient redissonClient(){
            Config config = new Config();
            //单机方式
            config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);
            //集群
            //config.useClusterServers().addNodeAddress("redis://192.31.21.1:6379","redis://192.31.21.2:6379")
            RedissonClient redissonClient = Redisson.create(config);
            return redissonClient;
        }
     
        /**
         * 集群模式
         * 备注:可以用"rediss://"来启用SSL连接
         */
        /*@Bean
        public RedissonClient redissonClusterClient() {
            Config config = new Config();
            config.useClusterServers().setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
                  .addNodeAddress("redis://127.0.0.1:7000")
                  .addNodeAddress("redis://127.0.0.1:7002");
            RedissonClient redisson = Redisson.create(config);
            return redisson;
        }*/
     
    }
    
  • 第四步切面参数防重逻辑:

  • /**
     * 定义一个切面类
     **/
    @Aspect
    @Component
    @Slf4j
    public class RepeatSubmitAspect {
        @Autowired
        private StringRedisTemplate redisTemplate;
        @Autowired
        private RedissonClient redissonClient;
        /**
         * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
         * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
         * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
         * 方式二:execution:一般用于指定方法的执行
         */
        @Pointcut("@annotation(repeatSubmit)")
        public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
     
        }
     
        /**
         * 环绕通知, 围绕着方法执行
         * @param joinPoint
         * @param repeatSubmit
         * @return
         * @throws Throwable
         * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
         * <p>
         * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
         * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
         * <p>
         * <p>
         * 两种方式
         * 方式一:加锁 固定时间内不能重复提交
         * <p>
         * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
         */
        @Around("pointCutNoRepeatSubmit(repeatSubmit)")
        public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
            //用于记录成功或者失败
            boolean res = false;
            //防重提交类型
            String type = repeatSubmit.limitType().name();
            if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
                //方式一,参数形式防重提交
                long lockTime = repeatSubmit.lockTime();
                String ipAddr = CommonUtil.getIpAddr(request);
                MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
                Method method = methodSignature.getMethod();
                String className = method.getDeclaringClass().getName();
                String key = "order-server:repeat_submit:"+CommonUtil.MD5(String.format("%s-%s-%s-%s",ipAddr,className,method,accountNo));
                //加锁
                // 这种也可以 本博客也介绍下redisson的使用
                // res  = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
                RLock lock = redissonClient.getLock(key);
                // 尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义]
                res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
            } else {
                //方式二,令牌形式防重提交
                String requestToken = request.getHeader("request-token");
                if (StringUtils.isBlank(requestToken)) {
                    throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
                }
                String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
                /**
                 * 提交表单的token key
                 * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
                 * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
                 */
                res = redisTemplate.delete(key);
            }
            if (!res) {
                log.error("请求重复提交");
                log.info("环绕通知中");
                return null;
            }
            log.info("环绕通知执行前");
            Object obj = joinPoint.proceed();
            log.info("环绕通知执行后");
            return obj;
        }
    }
    
  • 其中lock.tryLock解释下:

  • // 尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义] res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);

  • tryLock只有在调用时空闲的情况下,才会获得该锁。如果锁可用,则获取该锁,并立即返回值为true;如果锁不可用,那么这个方法将立即返回值为false。

  • 典型的用法:

  • 在这里插入图片描述

  • 这种用法可以保证在获得了锁的情况下解锁,在没有获得锁的情况下不尝试解锁。

  • 第五步 使用

  • 依然是在分页这块做个验证 看起来比较清晰

  • type改成RepeatSubmit.Type.PARAM

  • /**
    * 分页接口
    *
    * @return
    */
    @PostMapping("page")
    @RepeatSubmit(limitType = RepeatSubmit.Type.PARAM)
    public JsonData page(@RequestBody ProductOrderPageRequest orderPageRequest) {
    Map<String, Object> pageResult = productOrderService.page(orderPageRequest);
    return JsonData.buildSuccess(pageResult);
    }
    
  • postman请求接口进行验证:

  • 在这里插入图片描述

  • 第一次请求后,redis的key中存在的,TTL 5秒

  • 在这里插入图片描述

  • 5秒内重复点击接口 因为已经存在的这个key,所以当再次增加key的时候,就会返回flase:

  • 这样就完成了通过AOP 参数的防止重复提交

  • 两种防重提交,应用场景不一样,也可以更多方式进行防重,根据实际业务进行选择即可~

好的,关于SpringBoot自定义注解AOP的问题,我可以为您提供一些基本的介绍和示例代码。 首先,AOP(Aspect-Oriented Programming)是一种编程范式,它可以在不修改业务逻辑代码的情况下,对应用程序进行横切关注点的切面处理。而Spring AOP作为Spring框架的一部分,提供了一种基于代理模式的AOP实现。 在使用Spring AOP的过程中,自定义注解可以作为切点表达式的一部分,通过对注解的解析,实现对被注解的方法或类的切面处理。下面是一个简单的示例代码,演示如何通过自定义注解实现对方法的AOP处理: 首先,定义一个自定义注解: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MyAnnotation { String value() default ""; } ``` 然后,在需要被拦截的方法上添加该注解: ```java @Service public class MyService { @MyAnnotation("myAnnotation") public void doSomething() { System.out.println("do something..."); } } ``` 接下来,使用AspectJ的@Aspect注解定义一个切面类,并在该类中定义一个切点,用于匹配被@MyAnnotation注解的方法: ```java @Aspect @Component public class MyAspect { @Pointcut("@annotation(com.example.demo.annotation.MyAnnotation)") public void myAnnotationPointcut() {} @Before("myAnnotationPointcut()") public void beforeMyAnnotation() { System.out.println("before myAnnotation..."); } } ``` 最后,启动SpringBoot应用程序,调用MyService的doSomething方法,就可以看到输出结果: ```java before myAnnotation... do something... ``` 以上就是一个简单的SpringBoot自定义注解AOP的示例。通过使用自定义注解,可以更加方便地实现对应用程序的切面处理。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IT枫斗者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值