??📝個人主頁:哈__
期待您的關注?
目錄
🌼前言?
?🔒單機環境下防止接口重復提交
?📕導入依賴
📂項目結構?
🚀創建自定義注解
?創建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 {@AutowiredHttpServletRequest 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;@Autowiredpublic 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 {@CacheParamprivate String name;@CacheParamprivate 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 {@Autowiredprivate RedisLockHelper redisLockHelper;@Autowiredprivate 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的生成并不是一個輕松的問題。本文的內容僅建議作為學習使用。