SpringBoot中防止接口重復(fù)提交的有效方法
前言
在Web應(yīng)用開(kāi)發(fā)過(guò)程中,接口重復(fù)提交問(wèn)題一直是一個(gè)需要重點(diǎn)關(guān)注和解決的難題。無(wú)論是由于用戶誤操作、網(wǎng)絡(luò)延遲導(dǎo)致的重復(fù)點(diǎn)擊,還是由于惡意攻擊者利用自動(dòng)化工具進(jìn)行接口轟炸,都可能對(duì)系統(tǒng)造成嚴(yán)重的負(fù)擔(dān),甚至導(dǎo)致數(shù)據(jù)不一致、服務(wù)不可用等嚴(yán)重后果。特別是在SpringBoot這樣的現(xiàn)代化Java框架中,我們更需要一套行之有效的策略來(lái)防止接口重復(fù)提交。
本文將從SpringBoot應(yīng)用的角度出發(fā),探討在單機(jī)環(huán)境和分布式環(huán)境下如何有效防止接口重復(fù)提交。單機(jī)環(huán)境雖然相對(duì)簡(jiǎn)單,但基本的防護(hù)策略同樣適用于分布式環(huán)境的部署。
接下來(lái),我們將首先分析接口重復(fù)提交的原因和危害,然后詳細(xì)介紹在SpringBoot應(yīng)用中可以采取的防護(hù)策略,包括前端控制、后端校驗(yàn)、使用令牌機(jī)制(如Token)、利用數(shù)據(jù)庫(kù)的唯一約束等。對(duì)于分布式環(huán)境,我們還將探討如何使用分布式鎖、Redis等中間件來(lái)確保數(shù)據(jù)的一致性和防止接口被重復(fù)調(diào)用。
在深入解析各種防護(hù)策略的同時(shí),我們也將結(jié)合實(shí)際案例,展示如何在SpringBoot項(xiàng)目中具體實(shí)現(xiàn)這些策略,并給出一些優(yōu)化建議,以幫助讀者在實(shí)際開(kāi)發(fā)中更好地應(yīng)用這些技術(shù)。希望通過(guò)本文的介紹,讀者能夠掌握在SpringBoot應(yīng)用中防止接口重復(fù)提交的有效方法,為Web應(yīng)用的穩(wěn)定性和安全性提供堅(jiān)實(shí)的保障。
單機(jī)環(huán)境下防止接口重復(fù)提交
在這種單機(jī)的應(yīng)用場(chǎng)景下,我并沒(méi)有使用redis進(jìn)行處理,而是使用了本地緩存機(jī)制。在用戶對(duì)接口進(jìn)行訪問(wèn)的時(shí)候,我們獲取接口的一些參數(shù)信息,并且根據(jù)這些參數(shù)生成一個(gè)唯一的ID存儲(chǔ)到緩存中,下一次在發(fā)送請(qǐng)求的時(shí)候,先判斷這個(gè)緩存中是否有對(duì)應(yīng)的ID,若有則阻攔,若沒(méi)有那么就放行。
導(dǎo)入依賴
<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>
項(xiàng)目結(jié)構(gòu)
創(chuàng)建自定義注解
我們也說(shuō)過(guò)了,要根據(jù)接口的一些信息來(lái)生成一個(gè)ID,在單機(jī)環(huán)境下,我定義了一個(gè)注解,這個(gè)注解里邊保存著一個(gè)key作為ID,同時(shí),在把這個(gè)注解加到接口上,那么這個(gè)接口就以這個(gè)key作為ID,在訪問(wèn)接口的時(shí)候,存儲(chǔ)的也是這個(gè)ID值。
@Target(ElementType.METHOD) @Retention(value = RetentionPolicy.RUNTIME) @Documented @Inherited public @interface LockCommit { String key() default ""; }
創(chuàng)建AOP切面
為了方便之后的接口限流,同時(shí)也想把這件事情做一個(gè)模塊化處理,我使用的是AOP切面,這樣做可以減少代碼耦合,方便維護(hù)。
此外使用了一個(gè)Cache本地緩存用于存儲(chǔ)我們接口的ID,同時(shí)設(shè)置緩存的最大容量和內(nèi)容的過(guò)期時(shí)間,在這里我設(shè)置的是5秒鐘,5秒鐘過(guò)后ID就會(huì)過(guò)期,這個(gè)接口就可以繼續(xù)訪問(wèn)。
主要的就是這個(gè)環(huán)繞通知了,我先獲取了調(diào)用的接口,也就是具體的方法,之后獲取加在這個(gè)方法上的注解LockCommit,也就是我們上邊自定義的注解。之后拿到注解內(nèi)的key作為ID傳入緩存中。存入之前先判斷是否有這個(gè)ID,如果有就報(bào)錯(cuò),沒(méi)有就加入到緩存中,這個(gè)邏輯不難。
@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("請(qǐng)勿重復(fù)提交"); } CACHES.put(key,key); } Object object = null; try { object = joinPoint.proceed(); } catch (Throwable e) { e.printStackTrace(); } return object; } }
創(chuàng)建Conotroller
可以看到我在接口上加上了key是stu,對(duì)接口訪問(wèn)后,stu就作為ID保存到CACHE中。這里需要多加注意,如果是多個(gè)人訪問(wèn)這個(gè)接口,那么都會(huì)出現(xiàn)防止重復(fù)提交的問(wèn)題,所以這個(gè)key的值并不能僅僅設(shè)置的這么簡(jiǎn)單??梢约尤胍恍┯脩鬒D,參數(shù)的值,IP等信息作為key的構(gòu)建參數(shù)。這里我僅僅是為了演示。
@RestController @RequestMapping("/student") public class StudentController { @RequestMapping("/get-student") @LockCommit(key = "stu") public String getStudent(){ return "張三"; } }
如果你不想要后臺(tái)報(bào)錯(cuò),而是把錯(cuò)誤的提示信息傳到前端的話,那么你就可以創(chuàng)建一個(gè)全局的異常捕獲器。我創(chuàng)建的這個(gè)異常捕獲器捕獲的是Exception異常,范圍比較大,如果在真實(shí)的開(kāi)發(fā)環(huán)境中,你可能需要自定義異常來(lái)拋出和捕獲。
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public String handleException(Exception e){ return e.getMessage(); } }
接著我們啟動(dòng)項(xiàng)目來(lái)測(cè)試一下。為了方便截圖我就不用瀏覽器打開(kāi)了,我是用PostMan進(jìn)行測(cè)試。
- 第一次訪問(wèn)結(jié)果如下
- 五秒內(nèi)再次訪問(wèn)結(jié)果如下
- 五秒后訪問(wèn)結(jié)果如下
分布式環(huán)境下防止接口重復(fù)提交
導(dǎo)入依賴
<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>
項(xiàng)目結(jié)構(gòu)
創(chuàng)建自定義注解
分布式環(huán)境下的就要復(fù)雜一些了
- 創(chuàng)建CacheLock
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented @Inherited public @interface CacheLock { /** * 鎖的前綴 * @return */ String prefix() default ""; /** * 過(guò)期時(shí)間 * @return */ int expire() default 5; /** * 過(guò)期單位 * @return */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * key的分隔符 * @return */ String delimiter() default ":"; }
這個(gè)CacheLock也是加鎖的注解,這個(gè)注解內(nèi)包含了很多的信息,這些信息都要作為Redis加鎖的參數(shù)。
創(chuàng)建CacheParam
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.PARAMETER,ElementType.FIELD}) @Documented public @interface CacheParam { /** * 參數(shù)的名稱 * @return */ String name() default ""; }
這個(gè)參數(shù)是需要加在具體的參數(shù)上邊的,代表著這個(gè)參數(shù)要作為key構(gòu)建的一部分,當(dāng)然也可以加在一個(gè)對(duì)象的屬性上邊。
創(chuàng)建key的生成工具類
看到代碼的你一定慌了吧,不要急,在這之前我會(huì)先給你講一下我的思路。我們講的防止接口重復(fù)提交,是防止用戶對(duì)一個(gè)接口多次傳入相同的信息,這種情況我要進(jìn)行處理。我的構(gòu)建思路是想要構(gòu)建一個(gè)這樣的key。加了CacheParam的參數(shù)我獲取參數(shù)具體的值,并且把值作為key的一部分。
倘若我們的參數(shù)都沒(méi)有加CacheParam呢?這個(gè)時(shí)候就會(huì)去獲取這個(gè)參數(shù)的類,比如說(shuō)是Student類,我們就去看看這個(gè)傳來(lái)的Student類當(dāng)中有沒(méi)有屬性是加了CacheParam注解的,如果有就獲取值。
@Component public class RedisKeyGenerator { @Autowired HttpServletRequest request; public String getKey(ProceedingJoinPoint joinPoint) throws IllegalAccessException { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); // 獲取方法 Method method = methodSignature.getMethod(); // 獲取參數(shù) 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]); } // 如果方法參數(shù)沒(méi)有CacheParam注解 從參數(shù)類的內(nèi)部嘗試獲取 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; } }
創(chuàng)建Redis工具類
以下工具類來(lái)自引用DDKK.com。
@Component @Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) public class RedisLockHelper { private static final String DELIMITER = "|"; /** * 如果要求比較高可以通過(guò)注入的方式分配 */ private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10); private final StringRedisTemplate stringRedisTemplate; @Autowired public RedisLockHelper(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 獲取鎖(存在死鎖風(fēng)險(xiǎn)) * * @param lockKey lockKey * @param value value * @param time 超時(shí)時(shí)間 * @param unit 過(guò)期單位 * @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 超時(shí)時(shí)間 * @param unit 過(guò)期單位 * @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 rel="external nofollow" >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 延遲時(shí)間 * @param unit 時(shí)間單位 */ 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); } } }
創(chuàng)建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; } }
創(chuàng)建AOP切面類
注意下邊我注釋掉的一行代碼,如果加上了以后你就看不到防止重復(fù)提交的提示了,下邊的代碼和單機(jī)環(huán)境的思路是一樣的,只不過(guò)加鎖用的是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("重復(fù)提交"); } try { object = joinPoint.proceed(); }catch (Throwable e){ throw new RuntimeException("系統(tǒng)異常"); } } finally { // redisLockHelper.unlock(key,value); } return object; } }
創(chuàng)建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 "張三"; } }
調(diào)用get-student測(cè)試
- 第一次調(diào)用
- 第二次調(diào)用
調(diào)用get-student2測(cè)試
- 第一次調(diào)用
- 第二次調(diào)用
最后,上邊的key生成還有待商榷,分布式環(huán)境下key的生成并不是一個(gè)輕松的問(wèn)題。本文的內(nèi)容僅建議作為學(xué)習(xí)使用。
以上就是SpringBoot中防止接口重復(fù)提交的有效方法的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot接口重復(fù)提交的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- springboot 防止重復(fù)請(qǐng)求防止重復(fù)點(diǎn)擊的操作
- SpringBoot?使用AOP?+?Redis?防止表單重復(fù)提交的方法
- SpringBoot+Redis使用AOP防止重復(fù)提交的實(shí)現(xiàn)
- SpringBoot整合redis+Aop防止重復(fù)提交的實(shí)現(xiàn)
- SpringBoot攔截器實(shí)現(xiàn)項(xiàng)目防止接口重復(fù)提交
- SpringBoot利用Redis實(shí)現(xiàn)防止訂單重復(fù)提交的解決方案
- SpringBoot整合ShedLock解決定時(shí)任務(wù)防止重復(fù)執(zhí)行的問(wèn)題
- springboot基于注解實(shí)現(xiàn)去重表消息防止重復(fù)消費(fèi)
相關(guān)文章
解決MyEclipse中的Building workspace問(wèn)題的三個(gè)方法
這篇文章主要介紹了解決MyEclipse中的Building workspace問(wèn)題的三個(gè)方法,需要的朋友可以參考下2015-11-11SpringBoot中配置多數(shù)據(jù)源的方法詳解
這篇文章主要為大家詳細(xì)介紹了SpringBoot中配置多數(shù)據(jù)源的方法的相關(guān)知識(shí),文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2024-02-02java中對(duì)字符串每個(gè)字符統(tǒng)計(jì)的方法
java中對(duì)字符串每個(gè)字符統(tǒng)計(jì)的方法,需要的朋友可以參考一下2013-03-03java遠(yuǎn)程調(diào)用接口、URL的方式代碼
我們都知道接口有自己本地的,也有遠(yuǎn)程別人寫好的,而調(diào)用遠(yuǎn)程接口的就需要使用遠(yuǎn)程調(diào)用啦,這篇文章主要給大家介紹了關(guān)于java遠(yuǎn)程調(diào)用接口、URL的相關(guān)資料,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-11-11