📝个人主页:哈__
期待您的关注
目录
🌼前言
🔒单机环境下防止接口重复提交
📕导入依赖
📂项目结构
🚀创建自定义注解
✈创建AOP切面
🚗创建Conotroller
💻分布式环境下防止接口重复提交
📕导入依赖
📂项目结构
🚀创建自定义注解
🚲创建key的生成工具类
🔨创建Redis工具类
🚗创建AOP切面类
🛵创建Controller
🌼前言
在Web应用开发过程中,接口重复提交问题一直是一个需要重点关注和解决的难题。无论是由于用户误操作、网络延迟导致的重复点击,还是由于恶意攻击者利用自动化工具进行接口轰炸,都可能对系统造成严重的负担,甚至导致数据不一致、服务不可用等严重后果。特别是在SpringBoot这样的现代化Java框架中,我们更需要一套行之有效的策略来防止接口重复提交。
本文将从SpringBoot应用的角度出发,探讨在单机环境和分布式环境下如何有效防止接口重复提交。单机环境虽然相对简单,但基本的防护策略同样适用于分布式环境的部署。
接下来,我们将首先分析接口重复提交的原因和危害,然后详细介绍在SpringBoot应用中可以采取的防护策略,包括前端控制、后端校验、使用令牌机制(如Token)、利用数据库的唯一约束等。对于分布式环境,我们还将探讨如何使用分布式锁、Redis等中间件来确保数据的一致性和防止接口被重复调用。
在深入解析各种防护策略的同时,我们也将结合实际案例,展示如何在SpringBoot项目中具体实现这些策略,并给出一些优化建议,以帮助读者在实际开发中更好地应用这些技术。希望通过本文的介绍,读者能够掌握在SpringBoot应用中防止接口重复提交的有效方法,为Web应用的稳定性和安全性提供坚实的保障。
🔒单机环境下防止接口重复提交
在这种单机的应用场景下,我并没有使用redis进行处理,而是使用了本地缓存机制。在用户对接口进行访问的时候,我们获取接口的一些参数信息,并且根据这些参数生成一个唯一的ID存储到缓存中,下一次在发送请求的时候,先判断这个缓存中是否有对应的ID,若有则阻拦,若没有那么就放行。
📕导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>21.0</version>
</dependency>
📂项目结构
🚀创建自定义注解
我们也说过了,要根据接口的一些信息来生成一个ID,在单机环境下,我定义了一个注解,这个注解里边保存着一个key作为ID,同时,在把这个注解加到接口上,那么这个接口就以这个key作为ID,在访问接口的时候,存储的也是这个ID值。
@Target(ElementType.METHOD) @Retention(value = RetentionPolicy.RUNTIME) @Documented @Inherited public @interface LockCommit { String key() default ""; }
✈创建AOP切面
为了方便之后的接口限流,同时也想把这件事情做一个模块化处理,我使用的是AOP切面,这样做可以减少代码耦合,方便维护。
看过我之前文章的朋友应该都知道我喜欢使用注解来实现AOP了,这里定义了一个pointCut(),切入点表达式是注解类型。如果你还不会AOP的话,可以来看一看我的这篇文章。【Spring】Spring中AOP的简介和基本使用,SpringBoot使用AOP-CSDN博客
此外使用了一个Cache本地缓存用于存储我们接口的ID,同时设置缓存的最大容量和内容的过期时间,在这里我设置的是5秒钟,5秒钟过后ID就会过期,这个接口就可以继续访问。
主要的就是这个环绕通知了,我先获取了调用的接口,也就是具体的方法,之后获取加在这个方法上的注解LockCommit,也就是我们上边自定义的注解。之后拿到注解内的key作为ID传入缓存中。存入之前先判断是否有这个ID,如果有就报错,没有就加入到缓存中,这个逻辑不难。
@Aspect @Component public class LockAspect { public static final Cache<String,Object> CACHES = CacheBuilder.newBuilder() .maximumSize(50) .expireAfterWrite(5, TimeUnit.SECONDS) .build(); @Pointcut("@annotation(com.example.day_04_repeat_commit.annotation.LockCommit)&&execution(* com.example.day_04_repeat_commit.controller.*.*(..))") public void pointCut(){} @Around("pointCut()") public Object Lock(ProceedingJoinPoint joinPoint){ MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); LockCommit lockCommit = method.getAnnotation(LockCommit.class); String key = lockCommit.key(); if(key!=null &&!"".equals(key)){ if(CACHES.getIfPresent(key)!=null){ throw new RuntimeException("请勿重复提交"); } CACHES.put(key,key); } Object object = null; try { object = joinPoint.proceed(); } catch (Throwable e) { e.printStackTrace(); } return object; } }
🚗创建Conotroller
可以看到我在接口上加上了key是stu,对接口访问后,stu就作为ID保存到CACHE中。这里需要多加注意,如果是多个人访问这个接口,那么都会出现防止重复提交的问题,所以这个key的值并不能仅仅设置的这么简单。可以加入一些用户ID,参数的值,IP等信息作为key的构建参数。这里我仅仅是为了演示。
@RestController @RequestMapping("/student") public class StudentController { @RequestMapping("/get-student") @LockCommit(key = "stu") public String getStudent(){ return "张三"; } }
如果你不想要后台报错,而是把错误的提示信息传到前端的话,那么你就可以创建一个全局的异常捕获器。我创建的这个异常捕获器捕获的是Exception异常,范围比较大,如果在真实的开发环境中,你可能需要自定义异常来抛出和捕获。
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public String handleException(Exception e){ return e.getMessage(); } }
接着我们启动项目来测试一下。为了方便截图我就不用浏览器打开了,我是用PostMan进行测试。
- 第一次访问结果如下
- 五秒内再次访问结果如下
- 五秒后访问结果如下
💻分布式环境下防止接口重复提交
📕导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
📂项目结构
🚀创建自定义注解
分布式环境下的就要复杂一些了
- 创建CacheLock
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented @Inherited public @interface CacheLock { /** * 锁的前缀 * @return */ String prefix() default ""; /** * 过期时间 * @return */ int expire() default 5; /** * 过期单位 * @return */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * key的分隔符 * @return */ String delimiter() default ":"; }
这个CacheLock也是加锁的注解,这个注解内包含了很多的信息,这些信息都要作为Redis加锁的参数。
创建CacheParam
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.PARAMETER,ElementType.FIELD}) @Documented public @interface CacheParam { /** * 参数的名称 * @return */ String name() default ""; }
这个参数是需要加在具体的参数上边的,代表着这个参数要作为key构建的一部分,当然也可以加在一个对象的属性上边。
🚲创建key的生成工具类
看到代码的你一定慌了吧,不要急,在这之前我会先给你讲一下我的思路。我们讲的防止接口重复提交,是防止用户对一个接口多次传入相同的信息,这种情况我要进行处理。我的构建思路是想要构建一个这样的key。加了CacheParam的参数我获取参数具体的值,并且把值作为key的一部分。
倘若我们的参数都没有加CacheParam呢?这个时候就会去获取这个参数的类,比如说是Student类,我们就去看看这个传来的Student类当中有没有属性是加了CacheParam注解的,如果有就获取值。
@Component public class RedisKeyGenerator { @Autowired HttpServletRequest request; public String getKey(ProceedingJoinPoint joinPoint) throws IllegalAccessException { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); // 获取方法 Method method = methodSignature.getMethod(); // 获取参数 Object [] args = joinPoint.getArgs(); // 获取注解 final Parameter [] parameters = method.getParameters(); CacheLock cacheLock = method.getAnnotation(CacheLock.class); String prefix = cacheLock.prefix(); StringBuilder sb = new StringBuilder(); StringBuilder sb2 = new StringBuilder(); sb2.append(".").append(joinPoint.getTarget().getClass().getName()).append(".").append(method.getName()); for(int i = 0;i<args.length;i++){ CacheParam cacheParam = parameters[i].getAnnotation(CacheParam.class); if(cacheParam == null){ continue; } sb.append(cacheLock.delimiter()).append(args[i]); } // 如果方法参数没有CacheParam注解 从参数类的内部尝试获取 if(StringUtils.isEmpty(sb.toString())){ for(int i = 0;i< parameters.length;i++){ final Object object = args[i]; Field [] fields = object.getClass().getDeclaredFields(); for (Field field : fields) { final CacheParam annotation = field.getAnnotation(CacheParam.class); if(annotation==null){ continue; } field.setAccessible(true); sb.append(cacheLock.delimiter()).append(field.get(object)); } } } return prefix+sb2+sb; } }
🔨创建Redis工具类
以下工具类来自引用DDKK.com。
@Component @Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) public class RedisLockHelper { private static final String DELIMITER = "|"; /** * 如果要求比较高可以通过注入的方式分配 */ private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10); private final StringRedisTemplate stringRedisTemplate; @Autowired public RedisLockHelper(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 获取锁(存在死锁风险) * * @param lockKey lockKey * @param value value * @param time 超时时间 * @param unit 过期单位 * @return true or false */ public boolean tryLock(final String lockKey, final String value, final long time, final TimeUnit unit) { return stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(lockKey.getBytes(), value.getBytes(), Expiration.from(time, unit), RedisStringCommands.SetOption.SET_IF_ABSENT)); } /** * 获取锁 * * @param lockKey lockKey * @param uuid UUID * @param timeout 超时时间 * @param unit 过期单位 * @return true or false */ public boolean lock(String lockKey, final String uuid, long timeout, final TimeUnit unit) { final long milliseconds = Expiration.from(timeout, unit).getExpirationTimeInMilliseconds(); boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, (System.currentTimeMillis() + milliseconds) + DELIMITER + uuid,timeout,TimeUnit.SECONDS); if (success) { } else { String oldVal = stringRedisTemplate.opsForValue().getAndSet(lockKey, (System.currentTimeMillis() + milliseconds) + DELIMITER + uuid); final String[] oldValues = oldVal.split(Pattern.quote(DELIMITER)); if (Long.parseLong(oldValues[0]) + 1 <= System.currentTimeMillis()) { return true; } } return success; } /** * @see <a href="http://redis.io/commands/set">Redis Documentation: SET</a> */ public void unlock(String lockKey, String value) { unlock(lockKey, value, 0, TimeUnit.MILLISECONDS); } /** * 延迟unlock * * @param lockKey key * @param uuid client(最好是唯一键的) * @param delayTime 延迟时间 * @param unit 时间单位 */ public void unlock(final String lockKey, final String uuid, long delayTime, TimeUnit unit) { if (StringUtils.isEmpty(lockKey)) { return; } if (delayTime <= 0) { doUnlock(lockKey, uuid); } else { EXECUTOR_SERVICE.schedule(() -> doUnlock(lockKey, uuid), delayTime, unit); } } /** * @param lockKey key * @param uuid client(最好是唯一键的) */ private void doUnlock(final String lockKey, final String uuid) { String val = stringRedisTemplate.opsForValue().get(lockKey); final String[] values = val.split(Pattern.quote(DELIMITER)); if (values.length <= 0) { return; } if (uuid.equals(values[1])) { stringRedisTemplate.delete(lockKey); } } }
🔥创建Student类
public class Student { @CacheParam private String name; @CacheParam private Integer age; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } }
🚗创建AOP切面类
注意下边我注释掉的一行代码,如果加上了以后你就看不到防止重复提交的提示了,下边的代码和单机环境的思路是一样的,只不过加锁用的是Redis。
@Aspect @Component public class Lock { @Autowired private RedisLockHelper redisLockHelper; @Autowired private RedisKeyGenerator redisKeyGenerator; @Pointcut("execution(* com.my.controller.*.*(..))&&@annotation(com.my.annotation.CacheLock)") public void pointCut(){} @Around("pointCut()") public Object interceptor(ProceedingJoinPoint joinPoint) throws IllegalAccessException { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); CacheLock cacheLock = method.getAnnotation(CacheLock.class); if (StringUtils.isEmpty(cacheLock.prefix())) { throw new RuntimeException("锁的前缀不能为空"); } int expireTime = cacheLock.expire(); TimeUnit timeUnit = cacheLock.timeUnit(); String key = redisKeyGenerator.getKey(joinPoint); System.out.println(key); String value = UUID.randomUUID().toString(); Object object; try { final boolean tryLock = redisLockHelper.lock(key,value,expireTime,timeUnit); if(!tryLock){ throw new RuntimeException("重复提交"); } try { object = joinPoint.proceed(); }catch (Throwable e){ throw new RuntimeException("系统异常"); } } finally { // redisLockHelper.unlock(key,value); } return object; } }
🛵创建Controller
@RestController
@RequestMapping("/student")
public class StudentController {
@RequestMapping("/get-student")
@CacheLock(prefix = "stu2",expire = 5,timeUnit = TimeUnit.SECONDS)
public String getStudent(){
return "张三";
}
@RequestMapping("/get-student2")
@CacheLock(prefix = "stu2",expire = 5,timeUnit = TimeUnit.SECONDS)
public String getStudent2(Student student){
return "张三";
}
}
调用get-student测试
- 第一次调用
- 第二次调用
调用get-student2测试
- 第一次调用
- 第二次调用
最后,上边的key生成还有待商榷,分布式环境下key的生成并不是一个轻松的问题。本文的内容仅建议作为学习使用。