顾名思义,接口防刷的目的是限制某个接口在指定时间段内只能被请求有限次数。在Web开发中,一个常见的问题是用户快速连续点击按钮导致重复提交。虽然Web端以往可通过Token机制防止表单重复提交,但更通用的方案是交由后端统一控制。
原理
其核心原理在于服务器通过Redis记录客户端请求的次数。当请求到达时,系统会检查在设定的时间窗口内该请求是否已超过最大限制次数。如果超出,则拒绝访问。存储在Redis中的Key会设置过期时间,到期自动删除,从而重置计数。
代码实现
为了提升代码的可复用性和优雅度,我们采用自定义注解的方式来实现此功能。
@RequestLimit 注解
该注解用于标记需要防刷的类或方法。
import java.lang.annotation.*;
/**
* 请求限制的自定义注解
*
* @Target 注解可修饰的对象范围,ElementType.METHOD 作用于方法,ElementType.TYPE 作用于类
* (ElementType)取值有:
* 1.CONSTRUCTOR:用于描述构造器
* 2.FIELD:用于描述域
* 3.LOCAL_VARIABLE:用于描述局部变量
* 4.METHOD:用于描述方法
* 5.PACKAGE:用于描述包
* 6.PARAMETER:用于描述参数
* 7.TYPE:用于描述类、接口(包括注解类型) 或enum声明
*
* @Retention 定义了该Annotation被保留的时间长短:某些Annotation仅出现在源代码中,而被编译器丢弃;
* 而另一些却被编译在class文件中;编译在class文件中的Annotation可能会被虚拟机忽略,
* 而另一些在class被装载时将被读取(请注意并不影响class的执行,因为Annotation与class在使用上是被分离的)。
* 使用这个meta-Annotation可以对 Annotation的“生命周期”限制。
* (RetentionPoicy)取值有:
* 1.SOURCE:在源文件中有效(即源文件保留)
* 2.CLASS:在class文件中有效(即class保留)
* 3.RUNTIME:在运行时有效(即运行时保留)
*
* @Inherited
* 元注解是一个标记注解,@Inherited 阐述了某个被标注的类型是被继承的。
* 如果一个使用了@Inherited修饰的annotation类型被用于一个class,则这个annotation将被用于该class的子类。
*/
@Documented
@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
// 在 second 秒内,最大只能请求 maxCount 次
int second() default 1;
int maxCount() default 1;
}
RequestLimitIntercept 拦截器
自定义一个拦截器,在请求处理前进行次数校验。这是Spring Boot应用中实现全局拦截的常见方式。
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import top.lrshuai.limit.annotation.RequestLimit;
import top.lrshuai.limit.common.ApiResultEnum;
import top.lrshuai.limit.common.Result;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* 请求拦截
*/
@Slf4j
@Component
public class RequestLimitIntercept extends HandlerInterceptorAdapter {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/**
* isAssignableFrom() 判定此 Class 对象所表示的类或接口与指定的 Class 参数所表示的类或接口是否相同,或是否是其超类或超接口
* isAssignableFrom()方法是判断是否为某个类的父类
* instanceof关键字是判断是否某个类的子类
*/
if(handler.getClass().isAssignableFrom(HandlerMethod.class)){
//HandlerMethod 封装方法定义相关的信息,如类,方法,参数等
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 获取方法中是否包含注解
RequestLimit methodAnnotation = method.getAnnotation(RequestLimit.class);
//获取 类中是否包含注解,也就是controller 是否有注解
RequestLimit classAnnotation = method.getDeclaringClass().getAnnotation(RequestLimit.class);
// 如果 方法上有注解就优先选择方法上的参数,否则类上的参数
RequestLimit requestLimit = methodAnnotation != null?methodAnnotation:classAnnotation;
if(requestLimit != null){
if(isLimit(request,requestLimit)){
resonseOut(response,Result.error(ApiResultEnum.REQUST_LIMIT));
return false;
}
}
}
return super.preHandle(request, response, handler);
}
//判断请求是否受限
public boolean isLimit(HttpServletRequest request,RequestLimit requestLimit){
// 受限的redis 缓存key ,因为这里用浏览器做测试,我就用sessionid 来做唯一key,如果是app ,可以使用 用户ID 之类的唯一标识。
String limitKey = request.getServletPath()+request.getSession().getId();
// 从缓存中获取,当前这个请求访问了几次
Integer redisCount = (Integer) redisTemplate.opsForValue().get(limitKey);
if(redisCount == null){
//初始 次数
redisTemplate.opsForValue().set(limitKey,1,requestLimit.second(), TimeUnit.SECONDS);
}else{
if(redisCount.intValue() >= requestLimit.maxCount()){
return true;
}
// 次数自增
redisTemplate.opsForValue().increment(limitKey);
}
return false;
}
/**
* 回写给客户端
* @param response
* @param result
* @throws IOException
*/
private void resonseOut(HttpServletResponse response, Result result) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = null ;
String json = JSONObject.toJSON(result).toString();
out = response.getWriter();
out.append(json);
}
}
WebMvcConfig 配置类
拦截器编写完成后,需要在Spring MVC配置中进行注册。对于Spring Boot 2.x 项目,实现 WebMvcConfigurer 接口即可;若是 Spring Boot 1.x,则需继承 WebMvcConfigurerAdapter 类。
@Slf4j
@Component
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private RequestLimitIntercept requestLimitIntercept;
@Override
public void addInterceptors(InterceptorRegistry registry){
log.info("添加拦截");
registry.addInterceptor(requestLimitIntercept);
}
}
Controller 测试
最后,在控制器中进行测试。@RequestLimit 注解支持两种使用方式:
- 在类上使用:对该类下所有方法生效。
- 在方法上使用:仅对该方法生效。
注解参数说明:
maxCount: 单位时间内的最大请求次数。
second: 时间窗口,单位秒。
默认值为1秒内每个接口只能请求1次。若类和方法上同时存在注解,则以方法上的参数为准。
@RestController
@RequestMapping("/index")
@RequestLimit(maxCount = 5,second = 1)
public class IndexController {
/**
* @RequestLimit 修饰在方法上,优先使用其参数
* @return
*/
@GetMapping("/test1")
@RequestLimit
public Result test(){
//TODO ...
return Result.ok();
}
/**
* @RequestLimit 修饰在类上,用的是类的参数
* @return
*/
@GetMapping("/test2")
public Result test2(){
//TODO ...
return Result.ok();
}
}
代码地址
本文完整的示例代码已托管至代码仓库,你可以通过以下地址获取:
总结
通过结合Redis的高性能计数与过期特性,以及Spring Boot拦截器的灵活性,我们实现了一个轻量级且可配置的API防刷方案。自定义注解让代码意图更加清晰,也便于对不同接口实施差异化的限流策略。这种模式是保护系统免遭恶意或异常高频请求冲击的有效手段之一。如果你想深入探讨更多关于后端架构或高并发的实践,欢迎前往云栈社区与广大开发者交流。