SpringBoot中防止接口重復(fù)提交的有效方法
前言
在Web應(yīng)用開發(fā)過程中,接口重復(fù)提交問題一直是一個(gè)需要重點(diǎn)關(guān)注和解決的難題。無論是由于用戶誤操作、網(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框架中,我們更需要一套行之有效的策略來防止接口重復(fù)提交。
本文將從SpringBoot應(yīng)用的角度出發(fā),探討在單機(jī)環(huán)境和分布式環(huán)境下如何有效防止接口重復(fù)提交。單機(jī)環(huán)境雖然相對(duì)簡(jiǎn)單,但基本的防護(hù)策略同樣適用于分布式環(huán)境的部署。
接下來,我們將首先分析接口重復(fù)提交的原因和危害,然后詳細(xì)介紹在SpringBoot應(yīng)用中可以采取的防護(hù)策略,包括前端控制、后端校驗(yàn)、使用令牌機(jī)制(如Token)、利用數(shù)據(jù)庫(kù)的唯一約束等。對(duì)于分布式環(huán)境,我們還將探討如何使用分布式鎖、Redis等中間件來確保數(shù)據(jù)的一致性和防止接口被重復(fù)調(diào)用。
在深入解析各種防護(hù)策略的同時(shí),我們也將結(jié)合實(shí)際案例,展示如何在SpringBoot項(xiàng)目中具體實(shí)現(xiàn)這些策略,并給出一些優(yōu)化建議,以幫助讀者在實(shí)際開發(fā)中更好地應(yīng)用這些技術(shù)。希望通過本文的介紹,讀者能夠掌握在SpringBoot應(yīng)用中防止接口重復(fù)提交的有效方法,為Web應(yīng)用的穩(wěn)定性和安全性提供堅(jiān)實(shí)的保障。
單機(jī)環(huán)境下防止接口重復(fù)提交
在這種單機(jī)的應(yīng)用場(chǎng)景下,我并沒有使用redis進(jìn)行處理,而是使用了本地緩存機(jī)制。在用戶對(duì)接口進(jìn)行訪問的時(shí)候,我們獲取接口的一些參數(shù)信息,并且根據(jù)這些參數(shù)生成一個(gè)唯一的ID存儲(chǔ)到緩存中,下一次在發(fā)送請(qǐng)求的時(shí)候,先判斷這個(gè)緩存中是否有對(duì)應(yīng)的ID,若有則阻攔,若沒有那么就放行。

導(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)建自定義注解
我們也說過了,要根據(jù)接口的一些信息來生成一個(gè)ID,在單機(jī)環(huán)境下,我定義了一個(gè)注解,這個(gè)注解里邊保存著一個(gè)key作為ID,同時(shí),在把這個(gè)注解加到接口上,那么這個(gè)接口就以這個(gè)key作為ID,在訪問接口的時(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)容的過期時(shí)間,在這里我設(shè)置的是5秒鐘,5秒鐘過后ID就會(huì)過期,這個(gè)接口就可以繼續(xù)訪問。
主要的就是這個(gè)環(huán)繞通知了,我先獲取了調(diào)用的接口,也就是具體的方法,之后獲取加在這個(gè)方法上的注解LockCommit,也就是我們上邊自定義的注解。之后拿到注解內(nèi)的key作為ID傳入緩存中。存入之前先判斷是否有這個(gè)ID,如果有就報(bào)錯(cuò),沒有就加入到緩存中,這個(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ì)接口訪問后,stu就作為ID保存到CACHE中。這里需要多加注意,如果是多個(gè)人訪問這個(gè)接口,那么都會(huì)出現(xiàn)防止重復(fù)提交的問題,所以這個(gè)key的值并不能僅僅設(shè)置的這么簡(jiǎn)單。可以加入一些用戶ID,參數(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í)的開發(fā)環(huán)境中,你可能需要自定義異常來拋出和捕獲。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public String handleException(Exception e){
return e.getMessage();
}
}接著我們啟動(dòng)項(xiàng)目來測(cè)試一下。為了方便截圖我就不用瀏覽器打開了,我是用PostMan進(jìn)行測(cè)試。
- 第一次訪問結(jié)果如下

- 五秒內(nèi)再次訪問結(jié)果如下

- 五秒后訪問結(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 "";
/**
* 過期時(shí)間
* @return
*/
int expire() default 5;
/**
* 過期單位
* @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ù)都沒有加CacheParam呢?這個(gè)時(shí)候就會(huì)去獲取這個(gè)參數(shù)的類,比如說是Student類,我們就去看看這個(gè)傳來的Student類當(dāng)中有沒有屬性是加了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ù)沒有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工具類
以下工具類來自引用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;
}
/**
* 獲取鎖(存在死鎖風(fēng)險(xiǎn))
*
* @param lockKey lockKey
* @param value value
* @param time 超時(shí)時(shí)間
* @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 超時(shí)時(shí)間
* @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 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)境的思路是一樣的,只不過加鎖用的是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è)輕松的問題。本文的內(nèi)容僅建議作為學(xué)習(xí)使用。
以上就是SpringBoot中防止接口重復(fù)提交的有效方法的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot接口重復(fù)提交的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
java開發(fā)中如何使用JVisualVM進(jìn)行性能分析
JVisualVM是由Sun提供的性能分析工具,如此強(qiáng)大的后盾怎能不強(qiáng)大?在Jdk6.0以后的版本中是自帶的,配置好環(huán)境變量然后在運(yùn)行中輸入“JVisualVm”或直接到Jdk的安裝目錄的Bin目錄下找到運(yùn)行程序即可運(yùn)行。如果是用Jdk1.5或以前版本的朋友就得要單獨(dú)安裝了2015-12-12
java序列化對(duì)象根據(jù)不同配置動(dòng)態(tài)改變屬性名的方法
本文主要介紹了java序列化對(duì)象根據(jù)不同配置動(dòng)態(tài)改變屬性名的方法,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-05-05
SpringBoot集成Druid連接池進(jìn)行SQL監(jiān)控的問題解析
這篇文章主要介紹了SpringBoot集成Druid連接池進(jìn)行SQL監(jiān)控的問題解析,在SpringBoot工程中引入Druid連接池非常簡(jiǎn)單,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),需要的朋友可以參考下2021-07-07
java無鎖hashmap原理與實(shí)現(xiàn)詳解
本文主要介紹了java無鎖hashmap原理與實(shí)現(xiàn),大家參考使用吧2014-01-01
java 用redisTemplate 的 Operations存取list集合操作
這篇文章主要介紹了java 用redisTemplate 的 Operations存取list集合操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-08-08
Python自定義計(jì)算時(shí)間過濾器實(shí)現(xiàn)過程解析
這篇文章主要介紹了Python自定義計(jì)算時(shí)間過濾器實(shí)現(xiàn)過程解析,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-01-01
spring?boot如何通過自定義注解和AOP攔截指定的請(qǐng)求
這篇文章主要介紹了spring?boot通過自定義注解和AOP攔截指定的請(qǐng)求,本文主要通過切面類和自定注解的方式,攔截指定的接口(代碼中已經(jīng)作了詳細(xì)的說明),需要的朋友可以參考下2024-06-06
淺析如何在Java應(yīng)用中優(yōu)雅的發(fā)送短信
很多業(yè)務(wù)場(chǎng)景里,我們都需要發(fā)送短信,比如登陸驗(yàn)證碼、告警、營(yíng)銷通知、節(jié)日祝福等等,這篇文章,我們聊聊 Java 應(yīng)用中如何優(yōu)雅的發(fā)送短信,文中有詳細(xì)的代碼示例供大家參考,需要的朋友可以參考下2023-11-11
SpringBoot基于Redis實(shí)現(xiàn)短信登錄的操作
驗(yàn)證碼登錄是非常常見的一種登錄方式,能夠簡(jiǎn)化用戶登錄的過程,本文主要介紹了SpringBoot基于Redis實(shí)現(xiàn)短信登錄的操作,具有一定的參考價(jià)值,感興趣的可以了解一下2023-12-12

