前言
我們在瀏覽網(wǎng)站后臺的時候,假如我們頻繁請求,那么網(wǎng)站會提示 “請勿重復(fù)提交” 的字樣,那么這個功能究竟有什么用呢,又是如何實現(xiàn)的呢?
其實這就是接口防刷的一種處理方式,通過在一定時間內(nèi)限制同一用戶對同一個接口的請求次數(shù),其目的是為了防止惡意訪問導(dǎo)致服務(wù)器和數(shù)據(jù)庫的壓力增大,也可以防止用戶重復(fù)提交。
思路分析
接口防刷有很多種實現(xiàn)思路,例如:攔截器/AOP+redis、攔截器/AOP+本地緩存、前端限制等等很多種實現(xiàn)思路,在這里我們來講一下 攔截器+Redis 的實現(xiàn)方式。
其原理就是 在接口請求前由攔截器攔截下來,然后去 redis 中查詢是否已經(jīng)存在請求了,如果不存在則將請求緩存,若已經(jīng)存在則返回異常。具體可以參考下圖

具體實現(xiàn)
注:以下代碼中的 AjaxResult 為統(tǒng)一返回對象,這里就不貼出代碼了,大家可以根據(jù)自己的業(yè)務(wù)場景來編寫。
編寫 RedisUtils
JAVA復(fù)制代碼import com.Apply.core.exception.MyRedidsException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Redis工具類
*/
@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/****************** common start ****************/
/**
* 指定緩存失效時間
*
* @param key 鍵
* @param time 時間(秒)
* @return
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 根據(jù)key 獲取過期時間
*
* @param key 鍵 不能為null
* @return 時間(秒) 返回0代表為永久有效
*/
public long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
/**
* 判斷key是否存在
*
* @param key 鍵
* @return true 存在 false不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 刪除緩存
*
* @param key 可以傳一個值 或多個
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}
/****************** common end ****************/
/****************** String start ****************/
/**
* 普通緩存獲取
*
* @param key 鍵
* @return 值
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
/**
* 普通緩存放入
*
* @param key 鍵
* @param value 值
* @return true成功 false失敗
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 普通緩存放入并設(shè)置時間
*
* @param key 鍵
* @param value 值
* @param time 時間(秒) time要大于0 如果time小于等于0 將設(shè)置無限期
* @return true成功 false 失敗
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 遞增
*
* @param key 鍵
* @param delta 要增加幾(大于0)
* @return
*/
public long incr(String key, long delta) {
if (delta < 0) {
throw new MyRedidsException("遞增因子必須大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 遞減
*
* @param key 鍵
* @param delta 要減少幾(小于0)
* @return
*/
public long decr(String key, long delta) {
if (delta < 0) {
throw new MyRedidsException("遞減因子必須大于0");
}
return redisTemplate.opsForValue().increment(key, -delta);
}
/****************** String end ****************/
}
定義Interceptor
java復(fù)制代碼import com.alibaba.fastjson.JSON;
import com.apply.common.utils.redis.RedisUtils;
import com.apply.common.validator.annotation.AccessLimit;
import com.apply.core.http.AjaxResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
/**
* @author Bummon
* @description 重復(fù)請求攔截
* @date 2023-08-10 14:14
*/
@Component
public class RepeatRequestIntercept extends HandlerInterceptorAdapter {
@Autowired
private RedisUtils redisUtils;
/**
* 限定時間 單位:秒
*/
private final int seconds = 1;
/**
* 限定請求次數(shù)
*/
private final int max = 1;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判斷請求是否為方法的請求
if (handler instanceof HandlerMethod) {
String key = request.getRemoteAddr() + "-" + request.getMethod() + "-" + request.getRequestURL();
Object requestCountObj = redisUtils.get(key);
if (Objects.isNull(requestCountObj)) {
//若為空則為第一次請求
redisUtils.set(key, 1, seconds);
} else {
response.setContentType("application/json;charset=utf-8");
ServletOutputStream os = response.getOutputStream();
AjaxResult<Void> result = AjaxResult.error(100, "請求已提交,請勿重復(fù)請求");
String jsonString = JSON.toJSONString(result);
os.write(jsonString.getBytes());
os.flush();
os.close();
return false;
}
}
return true;
}
}
然后我們 將攔截器注冊到容器中
java復(fù)制代碼import com.apply.common.validator.intercept.RepeatRequestIntercept;
import com.apply.core.base.entity.Constants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author Bummon
* @description
* @date 2023-08-10 14:17
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RepeatRequestIntercept repeatRequestIntercept;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatRequestIntercept);
}
}
我們再來編寫一個接口用于測試
java復(fù)制代碼import com.apply.common.validator.annotation.AccessLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Bummon
* @description
* @date 2023-08-10 14:35
*/
@RestController
public class TestController {
@GetMapping("/test")
public String test(){
return "SUCCESS";
}
}
最后我們來看一下結(jié)果是否符合我們的預(yù)期:
1秒內(nèi)的第一次請求:

1秒內(nèi)的第二次請求:

確實已經(jīng)達到了我們的預(yù)期,但是如果我們對特定接口進行攔截,或?qū)Σ煌涌诘南薅〝r截時間和次數(shù)不同的話,這種實現(xiàn)方式無法滿足我們的需求,所以我們要提出改進。
改進
我們可以去寫一個自定義的注解,并將 seconds 和 max 設(shè)置為該注解的屬性,再在攔截器中判斷請求的方法是否包含該注解,如果包含則執(zhí)行攔截方法,如果不包含則直接返回。
自定義注解 RequestLimit
java復(fù)制代碼import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author Bummon
* @description 冪等性注解
* @date 2023-08-10 15:10
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequestLimit {
/**
* 限定時間
*/
int seconds() default 1;
/**
* 限定請求次數(shù)
*/
int max() default 1;
}
改進 RepeatRequestIntercept
java復(fù)制代碼/**
* @author Bummon
* @description 重復(fù)請求攔截
* @date 2023-08-10 15:14
*/
@Component
public class RepeatRequestIntercept extends HandlerInterceptorAdapter {
@Autowired
private RedisUtils redisUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判斷請求是否為方法的請求
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
//獲取方法中是否有冪等性注解
RequestLimit anno = hm.getMethodAnnotation(RequestLimit.class);
//若注解為空則直接返回
if (Objects.isNull(anno)) {
return true;
}
int seconds = anno.seconds();
int max = anno.max();
String key = request.getRemoteAddr() + "-" + request.getMethod() + "-" + request.getRequestURL();
Object requestCountObj = redisUtils.get(key);
if (Objects.isNull(requestCountObj)) {
//若為空則為第一次請求
redisUtils.set(key, 1, seconds);
} else {
//限定時間內(nèi)的第n次請求
int requestCount = Integer.parseInt(requestCountObj.toString());
//判斷是否超過最大限定請求次數(shù)
if (requestCount < max) {
//未超過則請求次數(shù)+1
redisUtils.incr(key, 1);
} else {
//否則拒絕請求并返回信息
refuse(response);
return false;
}
}
}
return true;
}
/**
* @param response
* @date 2023-08-10 15:25
* @author Bummon
* @description 拒絕請求并返回結(jié)果
*/
private void refuse(HttpServletResponse response) throws IOException {
response.setContentType("application/json;charset=utf-8");
ServletOutputStream os = response.getOutputStream();
AjaxResult<Void> result = AjaxResult.error(100, "請求已提交,請勿重復(fù)請求");
String jsonString = JSON.toJSONString(result);
os.write(jsonString.getBytes());
os.flush();
os.close();
}
}
這樣我們就可以實現(xiàn)我們的需求了。
原文鏈接:
https://juejin.cn/post/7265565809823760441