栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

SpringBoot自定义注解 + AOP 防止重复提交(建议收藏)

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

SpringBoot自定义注解 + AOP 防止重复提交(建议收藏)

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

开发的项目中可能会出现下面这些情况:

  1. 前端下单按钮重复点击导致订单创建多次     
  2. 网速等原因造成页面卡顿,用户重复刷新提交请求
  3. 黑客或恶意用户使用postman等http工具重复恶意提交表单
重复提交会带来哪些问题?

重复提交带来的问题

  1. 会导致表单重复提交,造成数据重复或者错乱
  2. 核心接口的请求增加,消耗服务器负载,严重甚至会造成服务器宕机
订单的防重复提交你能想到几种方案?

核心接口需要做防重提交,你应该可以想到以下几种方案:

方式一:前端JS控制点击次数,屏蔽点击按钮无法点击 前端可以被绕过,前端有限制,后端也需要有限制

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

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

分布式情况下,采用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 }
    
    Type limitType() default Type.PARAM;
    
    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;

public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s";


@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);
}


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("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
 
    }
 
    
    @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);
            
            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:


      org.redisson
      redisson
      3.10.1

第二步 增加配置:

#-------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;
    
    @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;
    }
 
    
    
 
}

第四步切面参数防重逻辑:

@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RedissonClient redissonClient;
    
    @Pointcut("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
 
    }
 
    
    @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);
            
            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

@PostMapping("page")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM)
public JsonData page(@RequestBody ProductOrderPageRequest orderPageRequest) {
Map pageResult = productOrderService.page(orderPageRequest);
return JsonData.buildSuccess(pageResult);
}
复制代码

postman请求接口进行验证:

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

 

 

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

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

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

暑期编程PK赛 得CSDN机械键盘等精美礼品!
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/1016906.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号