大家好。今天我们来看看SpringBoot中如何通过自定义注解+ AOP 来防止重复提交。
哪些因素会引起重复提交?
开发的项目中可能会出现下面这些情况:
- 前端下单按钮重复点击导致订单创建多次
- 网速等原因造成页面卡顿,用户重复刷新提交请求
- 黑客或恶意用户使用postman等http工具重复恶意提交表单
重复提交会带来哪些问题?
重复提交带来的问题
- 会导致表单重复提交,造成数据重复或者错乱
- 核心接口的请求增加,会消耗服务器负载,严重甚至会造成服务器宕机
订单的防重复提交你能想到几种方案?
核心接口需要做繁重提交,你应该可以想到以下几种方案:
方式一:前端JS控制点击次数,屏蔽点击按钮无法点击 前端可以被绕过,前端有限制,后端也需要有限制
方式二:数据库或者其他存储增加唯一索引约束 需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
方式三:服务端token令牌方式 下单前先获取令牌-存储 redis ,下单时一并把token提交并检验和删除- Lua 脚本
其中方式三 是大家采用得最多的,那有没更加优雅的方式呢?
假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。
本文采用自定义注解+AOP的方式,优雅的实现防止重复提交功能。
自定义注解
java 核心知识-自定义注解(先了解下什么是自定义注解)
annotation (注解)
从 JDK 1.5开始, Java增加了对元数据( Meta Data)的支持,也就是 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.*; | |
/** | |
* 自定义防重提交 | |
*/ | |
//可以用在方法上 | |
//保留到 虚拟机 运行时,可通过反射获取 | |
public RepeatSubmit { | |
/** | |
* 防重提交,支持两种,一个是方法参数,一个是令牌 | |
*/ enum Type { PARAM, TOKEN } | |
/** | |
* 默认防重提交,是方法参数 | |
* @return | |
*/ Type limitType() default Type.PARAM; | |
/** | |
* 加锁过期时间,默认是秒 | |
* @return | |
*/ long lockTime() default 5; | |
} |
第二步 引入redis
#-------redis连接配置------- | |
spring.redis.client-type=jedis | |
spring.redis.host=.79.xxx.xxx | |
spring.redis.password= | |
spring. Redis .port=6379 | |
spring.redis.jedis.pool.max-active= | |
spring.redis.jedis.pool.max-idle= | |
spring.redis.jedis.pool.min-idle= | |
spring.redis.jedis.pool.max-wait= |
第三步 提前获取令牌用于防重提交
private StringRedisTemplate redisTemplate; | |
/** | |
* 提交订单令牌的缓存key | |
*/public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s"; | |
/** | |
* 下单前获取令牌用于防重提交 | |
* @return | |
*/ | |
public Json Data getOrderToken(){ | |
//获取登录账户 | |
long accountNo = LoginInterceptor.threadLocal.get().getAccountNo(); | |
//随机获取位的数字+字母作为token | |
String token = CommonUtil.getStringNumRandom(); | |
//key的组成 | |
String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY,accountNo,token); | |
//令牌有效时间是分钟 | |
redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()),,TimeUnit.MINUTES); | |
return JsonData.buildSuccess(token); | |
} | |
/** | |
* 获取随机长度的串 | |
* | |
* @param length | |
* @return | |
*/private static final String ALL_CHAR_NUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; | |
public static String getStringNum random (int length) { | |
//生成随机数字和字母, | |
Random random = new Random(); | |
StringBuilder saltString = new StringBuilder(length); | |
for (int i =; i <= length; ++i) { | |
saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length()))); | |
} | |
return saltString.toString(); | |
} |
第四步 定义切面类-开发解析器
根据type区分是使用token方式 还是参数方式
先看下token的方式
/** | |
* 定义一个切面类 | |
**/ | |
public class RepeatSubmitAspect { | |
private StringRedisTemplate redisTemplate; | |
/** | |
* 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法 | |
* 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点 | |
* 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这) | |
* 方式二: execution :一般用于指定方法的执行 | |
*/ | |
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,删除成功则是第一次提交 | |
*/ | |
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable { | |
HttpServlet Request 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 Biz Exception (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,通过这种来做到的防重复。
先介绍下Redission: Redission是一个在Redis的基础上实现的Java驻内存数据网格,支持多样Redis配置支持、丰富连接方式、分布式对象、分布式集合、分布式锁、分布式服务、多种 序列化 方式、三方框架整合。 Redisson 底层采用的是 Netty 框架 官方文档:
第一步 引入依赖pom.xml:
<dependency> | |
<groupId>org.redisson</groupId> | |
<artifactId>redisson</artifactId> | |
<version>.10.1</version> | |
</dependency> |
第二步 增加配置:
#-------redis连接配置------- | |
spring.redis.client-type=jedis | |
spring.redis.host=.79.xxx.xxx | |
spring.redis.password= | |
spring.redis.port= | |
spring.redis.jedis.pool.max-active= | |
spring.redis.jedis.pool.max-idle= | |
spring.redis.jedis.pool.min-idle= | |
spring.redis.jedis.pool.max-wait= |
第三步 获取 redisson Client:
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; | |
public class RedissionConfiguration { | |
private String redisHost; | |
private String redisPort; | |
private String redisPwd; | |
/** | |
* 配置分布式锁的redisson | |
* @return | |
*/ | |
public RedissonClient redissonClient(){ | |
Config config = new Config(); | |
//单机方式 | |
config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort); | |
//集群 | |
//config.use Cluster Servers().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() // 集群状态扫描间隔时间,单位是毫秒 | |
.addNodeAddress("redis:// 127.0.0.1 :7000") | |
.addNodeAddress("redis://.0.0.1:7002"); | |
RedissonClient redisson = Redisson.create(config); | |
return redisson; | |
}*/ | |
} |
第四步切面参数防重逻辑:
/** | |
* 定义一个切面类 | |
**/ | |
public class RepeatSubmitAspect { | |
private StringRedisTemplate redisTemplate; | |
private RedissonClient redissonClient; | |
/** | |
* 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法 | |
* 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点 | |
* 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这) | |
* 方式二:execution:一般用于指定方法的执行 | |
*/ | |
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,删除成功则是第一次提交 | |
*/ | |
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.MD(String.format("%s-%s-%s-%s",ipAddr,className,method,accountNo)); | |
//加锁 | |
// 这种也可以 本博客也介绍下redisson的使用 | |
// res = redisTemplate.opsForValue().setIfAbsent(key, "", lockTime, TimeUnit.SECONDS); | |
RLock lock = redissonClient.getLock(key); | |
// 尝试加锁,最多等待秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义] | |
res = lock.tryLock(,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 | |
*/ ("page") | |
RepeatSubmit.Type.PARAM) | (limitType =|
public JsonData page() { ProductOrderPageRequest orderPageRequest | |
Map<String, Object> pageResult = productOrderService.page(orderPageRequest); | |
return JsonData.buildSuccess(pageResult); | |
} |
postman请求接口进行验证:
第一次请求后,redis的key中存在的,TTL 5秒
5秒内重复点击接口 因为已经存在的这个key,所以当再次增加key的时候,就会返回flase:
这样就完成了通过AOP 参数的防止重复提交
两种防重提交,应用场景不一样,也可以更多方式进行防重,根据实际业务进行选择即可