目录
一、哪些因素会引起重复提交?
二、重复提交会带来哪些问题?
三、订单的防重复提交你能想到几种方案?
四、自定义注解方式
4.1Java核心知识-自定义注解(先了解下什么是自定义注解)
4.1.1 Annotation(注解)
4.1.2什么是元注解
4.1.3java内置4种元注解
4.2AOP+自定义注解接口防重提交多场景设计
4.3代码实战防重提交自定义注解之Token令牌/参数方式
4.3.1 自定义注解token令牌方式
4.3.2 再看下参数的防重方式
一、哪些因素会引起重复提交?
开发的项目中可能会出现下面这些情况:
前端下单按钮重复点击导致订单创建多次
网速等原因造成页面卡顿,用户重复刷新提交请求
黑客或恶意用户使用postman等http工具重复恶意提交表单
重复提交带来的问题
会导致表单重复提交,造成数据重复或者错乱
核心接口的请求增加,消耗服务器负载,严重甚至会造成服务器宕机
核心接口需要做防重提交,你能想到几种方式:
方式一:前端JS控制点击次数,屏蔽点击按钮无法点击
前端可以被绕过,前端有限制,后端也需要有限制
方式二:数据库或者其他存储增加唯一索引约束
需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
方式三:服务端token令牌方式
下单前先获取令牌-存储redis 下单时一并把token提交并检验和删除-lua脚本
分布式情况下,采用Lua脚本进行 *** 作(保障原子性)
其中方式三 是大家采用的最多的,那有没更加优雅的方式呢?
假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低
本博客采用自定义注解,达到更优雅的目的
从JDK 1.5开始, Java增加了对元数据(metaData)的支持,也就是 Annotation(注解)。
注解其实就是代码里的特殊标记,它用于替代配置文件
常见的很多 @Override、@Deprecated等
元注解是注解的注解,比如当我们需要自定义注解时会需要一些元注解(meta-annotation),如@Target和@Retention
4.1.3java内置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接口
通过反射可以获取自定义注解
防重提交方式
token令牌方式
ip+类+方法方式(方法参数)
利用AOP来实现
Aspect Oriented Program 面向切面编程, 在不改变原有逻辑上增加额外的功能
AOP思想把功能分两个部分,分离系统中的各种关注点
好处
减少代码侵入,解耦
可以统一处理横切逻辑
方便添加和删除横切逻辑
业务流程:
第一步 自定义注解
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的方式
import lombok.extern.slf4j.Slf4j; import net.wnn.annotation.RepeatSubmit; import net.wnn.constant.RedisKey; import net.wnn.enums.BizCodeEnum; import net.wnn.exception.BizException; import net.wnn.interceptor.LoginInterceptor; import net.wnn.util.CommonUtil; 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.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; 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 java.lang.reflect.Method; import java.util.concurrent.TimeUnit; @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/i_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的防止重复提交
对AOP切面等不是很熟悉的,可以看下这篇博客中AOP的详细介绍以及实战举例:
AOP面向切面编程之全局日志打印/统计接口耗时_8年开发工作经验的老王,积极分享工作中遇到的问题~-CSDN博客
4.3.2 再看下参数的防重方式参数式防重复的核心就是IP地址+类+方法+账号的方式,增加到redis中做为key。第一次加锁成功返回true,第二次返回false,通过这种来做到的防重复。
先介绍下Redission:
Redission是一个在Redis的基础上实现的Java驻内存数据网格,支持多样Redis配置支持、丰富连接方式、分布式对象、分布式集合、分布式锁、分布式服务、多种序列化方式、三方框架整合
Redisson底层采用的是Netty 框架
官方文档:https://github.com/redisson/redisson
关于用redis来实现分布式锁的方式redis的api或者redis+lua或者Redission,可以看下这篇博客:
高并发下Redis实现分布式锁的坑你是否踩过_8年开发工作经验的老王,积极分享工作中遇到的问题~-CSDN博客
参数防重复加入Redission上代码环节
第一步 引入依赖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; } }
第四步切面参数防重逻辑:
import lombok.extern.slf4j.Slf4j; import net.wnn.annotation.RepeatSubmit; import net.wnn.constant.RedisKey; import net.wnn.enums.BizCodeEnum; import net.wnn.exception.BizException; import net.wnn.interceptor.LoginInterceptor; import net.wnn.util.CommonUtil; 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.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; 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 java.lang.reflect.Method; import java.util.concurrent.TimeUnit; @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/i_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) { MappageResult = productOrderService.page(orderPageRequest); return JsonData.buildSuccess(pageResult); }
postman请求接口进行验证:
第一次请求后,redis的key中存在的,TTL 5秒
5秒内重复点击接口 因为已经存在的这个key,所以当再次增加key的时候,就会返回flase:
这样就完成了通过AOP 参数的防止重复提交
两种防重提交,应用场景不一样,也可以更多方式进行防重,根据实际业务进行选择即可
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)