SpringBoot定义拦截器+自定义注解+Redis实现接口防刷(限流)
[*]实现思路
[*]在拦截器Interceptor中拦截请求
[*]通过地址+请求uri作为调用者访问接口的区分在Redis中进行计数达到限流目的
[*]简单实现
[*]定义参数
[*]访问周期
[*]最大访问次数
[*]禁用时长
#接口防刷配置,时间单位都是秒.如果second秒内访问次数达到times,就禁用lockTime秒
access:
limit:
second: 10 #一段时间内
times: 3#最大访问次数
lockTime: 5 #禁用时长
[*]代码实现
[*]定义拦截器:实现HandlerInterceptor接口,重写preHandle()方法
@Slf4j
@Component
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate redisTemplate;
//锁住时的key前缀
private static final String LOCK_PREFIX = "LOCK";
//统计次数的key前缀
private static final String COUNT_PREFIX = "COUNT";
//访问周期
@Value("${access.limit.second}")
private long second;
//访问周期内最大访问次数
@Value("${access.limit.times}")
private int times;
//禁用时长
@Value("${access.limit.lockTime}")
private long lockTime;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
[*]注册拦截器:配置类实现WebMvcConfigurer接口,重写addInterceptors()方法
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private AccessLimintInterceptor accessLimintInterceptor;
//在这个方法中注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册拦截器
InterceptorRegistration interceptorRegistration = registry.addInterceptor(accessLimintInterceptor);
//配置要拦截的路径。优化为实现自定义注解,那就拦截所有路径,在拦截器中判断是否使用了注解,没使用就放行
// interceptorRegistration.addPathPatterns("/search/**");
interceptorRegistration.addPathPatterns("/**");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
[*]自定义异常,方便错误提示。
/*
* @Description TODO (自定义访问限制异常,防刷)
* 创建人: 程长新
* 创建时间:2023/11/12 8:46
**/
public class AccessLimitException extends RuntimeException{
public AccessLimitException() {
}
public AccessLimitException(Throwable e) {
super(e.getMessage(),e);
}
public AccessLimitException(String message) {
super(message);
}
}添加全局异常捕捉
/*
* @Description TODO (全局异常处理)
* 创建人: 程长新
* 创建时间:2023/11/7 9:54
**/
@RestControllerAdvice
public class AdviceController {
@ExceptionHandler(Exception.class)
public String exceptionHandler(HttpServletRequest request,
HttpServletResponse response,
Exception e){
return e.getMessage();
}
@ExceptionHandler(AccessLimitException.class)
public String exceptionHandler(AccessLimitException e){
return "访问次数过多,请稍候再试";
}
}
[*]处理逻辑
/** 不使用自定义注解时的逻辑
*获取锁key
*1 锁key为空,未被禁用,进入处理逻辑
* 获取计数key
* 1)计数key为空,说明首次访问,设置计数key为1,放行
* 2)计数key不为空,判断是否达到最大访问次数
* (1)达到:返回错误提示
* (2)未达到:计数值+1
*2 锁key不为空,已被禁用,直接返回提示
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("进入拦截器");
//获取访问的url和访问者ip
String requestURI = request.getRequestURI();
String remoteAddr = request.getRemoteAddr();
String lockKey = LOCK_PREFIX + requestURI + remoteAddr;
Object o = redisTemplate.opsForValue().get(lockKey);
if (Objects.isNull(o)){
//还未被禁用
//查看当前访问次数
String countKey = COUNT_PREFIX + requestURI + remoteAddr;
Integer count = (Integer)redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(count)){
//首次访问
log.info("{}用户首次访问接口{}",remoteAddr,requestURI);
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
log.info("访问次数写入redis");
}else {
log.info("{}用户第{}次访问接口{}", remoteAddr, count + 1, requestURI);
//此用户在设置的一段时间内已经访问过该接口
//判断次数+1是否超过最大限制
if (count++ >= times){
//超过最大限制,禁用该用户对此接口的访问
log.info("{}用户访问接口{}已达到最大限制,禁用",remoteAddr,requestURI);
redisTemplate.opsForValue().set(lockKey, 1, lockTime, TimeUnit.SECONDS);
//返回提示
// throw new RuntimeException("服务器繁忙,请稍候再试");
throw new AccessLimitException();
}else {
//访问次数+1
ValueOperations valueOperations = redisTemplate.opsForValue();
valueOperations.set(countKey, count, second, TimeUnit.SECONDS);
}
}
}else {
//已被禁用,返回提示
throw new AccessLimitException();
}
return true;
}
[*]目前存在的问题
此时已经简单实现了限流功能,但是上边配置拦截路径直接写了/**,是为了方便测试,但是如果正常开发应该不会写全部,应该单个配置,那么就要为每个接口添加配置,比较繁琐。并且现在对所有接口的限制都是一样的规则,时间都是一样的,如果想要有不同的时间规则,那么就需要设置多个过滤器,明显是不合适的,所以需要优化。
[*]优化一:自定义注解+反射
[*]定义注解
/*
* @Description TODO (自定义接口防刷注解)
* 创建人: 程长新
* 创建时间:2023/11/12 9:03
**/
@Target({ElementType.METHOD})//注解可以作用在方法上
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimit {
/**
* 时间周期
*/
long second() default 5L;
/**
* 最大访问次数
*/
int times() default 3;
/**
* 禁用时长
*/
long lockTime() default 3L;
}
[*]将注解标注写需要限流的方法上
@AccessLimit(second = 10L, times = 5, lockTime = 2L)
@GetMapping("/search")
public String search(){
return "进来了";
}
[*]修改处理逻辑
主要修改:通过反射获取到方法注解,判断是否需要进行限流,如果需要就获取注解中的参数进行处理
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("进入拦截器");
//判断拦截的是否为接口方法
if (handler instanceof HandlerMethod){
log.info("开始处理");
//转化为目标方法对象
HandlerMethod targetMethod = (HandlerMethod) handler;
//获取对象的AccessLimit注解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
//如果获取到注解再进行处理,否则直接放行
if(Objects.nonNull(accessLimit)){
//防刷处理逻辑
//获取访问的接口的访问者IP
String remoteAddr = request.getRemoteAddr();
String requestURI = request.getRequestURI();
//拼接锁key和计数key
String lockKey = LOCK_PREFIX + requestURI + remoteAddr;
String countKey = COUNT_PREFIX + requestURI + remoteAddr;
//从redis中获取锁值
Object o = redisTemplate.opsForValue().get(lockKey);
if (Objects.nonNull(o)){
log.info("用户{},访问{}接口,被禁用",remoteAddr,requestURI);
//获取锁值不为空说明已经禁用,直接返回
throw new AccessLimitException();
}else {
//未被禁用
//获取注解中设置的x,y,z时间值
long second1 = accessLimit.second();
int times1 = accessLimit.times();
long lockTime1 = accessLimit.lockTime();
//获取访问次数
Integer o1 = (Integer) redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(o1)){
log.info("用户{},访问{}接口,首次访问",remoteAddr,requestURI);
//首次访问,保存访问次数为1
redisTemplate.opsForValue().set(countKey,1,second1,TimeUnit.SECONDS);
}else {
//判断访问次数
if (o1 == times1){
log.info("用户{},访问{}接口,达到次数限制被禁用",remoteAddr,requestURI);
//已经达到限制,禁用,返回
redisTemplate.opsForValue().set(lockKey,1,lockTime1,TimeUnit.SECONDS);
//删除计数key,已经禁用,这个也就没必要了
redisTemplate.delete(countKey);
throw new AccessLimitException();
}else {
log.info("用户{},访问{}接口,现在第{}次访问",remoteAddr,requestURI,(o1 + 1));
//次数加1
redisTemplate.opsForValue().set(countKey,++o1,second1,TimeUnit.SECONDS);
}
}
}
}
}
return true;
}
[*]目前存在的问题
对需要进行限流的每个方法得挨个添加注解,那么如果一个controller中的所以接口都需要限流处理的话,每个接口挨个添加注解的做法属实不怎么样。应该做到如果在一个controller上添加了注解,那么这个controller中的所以接口都进行限流,如果某个接口上也添加了注解,那么就采用就近原则使用接口上注解的参数。仍然需要优化
[*]优化二:注解作用于类上
[*]添加注解作用范围
@Target({ElementType.METHOD, ElementType.TYPE})//添加ElementType.TYPE范围
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimit {
/**
* 时间周期
*/
long second() default 5L;
/**
* 最大访问次数
*/
int times() default 3;
/**
* 禁用时长
*/
long lockTime() default 3L;
}
[*]修改处理逻辑
/**自定义注解可以作用在类上之后的逻辑
* 1 获取类上的注解
* 2 获取方法上的注解
* 3 判断类是是否有注解
* 1)类上没有
* 判断方法上是否存在注解
* 不存在:说明该接口不需要防刷,放行就可以
* 存在:获取注解中的值,进行处理
* 2)类上存在注解
* 判断方法上是否存在注解
* 不存在:说明该方法使用类上的统一配置
* 存在:采用就近原则,使用方法上注解的值进行处理
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断拦截的是否为接口方法
if (handler instanceof HandlerMethod){
//转化为目标方法
HandlerMethod targetMethod = (HandlerMethod) handler;
//获取目标类上的注解
//不可以直接使用targetMethod.getClass(),这样获取到的是HandlerMethod,不是真正想要的controller类
// Class<? extends HandlerMethod> aClass = targetMethod.getClass();
Class<?> targetClass = targetMethod.getMethod().getDeclaringClass();
AccessLimit classAccessLimit = targetClass.getAnnotation(AccessLimit.class);
//获取目标方法上的注解,
AccessLimit methodAccessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
//类名#方法名[参数个数]
String shortLogMessage = targetMethod.getShortLogMessage();
long second = 0L;//一段时间内
int times = 0;//最大访问次数
long lockTime = 0L;//禁用时长
if (Objects.nonNull(classAccessLimit)){
//类上存在注解
if (Objects.nonNull(methodAccessLimit)){
//方法上存在注解,就近原则,使用方法上注解的参数
second = methodAccessLimit.second();
times = methodAccessLimit.times();
lockTime = methodAccessLimit.lockTime();
}else {
second = classAccessLimit.second();
times = classAccessLimit.times();
lockTime = classAccessLimit.lockTime();
}
//只传uri的话,如果请求中含有路径参数,那么请求同一个接口但传递不同参数也会记录为不同的key,就会导致防刷失效,所以将uri改为类名+方法名
if(isLimit(second, times, lockTime, request.getRemoteAddr(), shortLogMessage)){
throw new AccessLimitException();
}
}else {
//类上不存在注解
//判断方法上是否存在
if (Objects.nonNull(methodAccessLimit)){
//方法上存在注解
second = methodAccessLimit.second();
times = methodAccessLimit.times();
lockTime = methodAccessLimit.lockTime();
if(isLimit(second, times, lockTime, request.getRemoteAddr(), shortLogMessage)){
throw new AccessLimitException();
}
}
//方法上不存在,不用分支了,直接到最后return true
}
}
return true;
}
/**
* 判断该ip访问此uri是否已经被限制
* @param second
* @param times
* @param lockTime
* @param ip
* @param uri 请求的接口名:类名#方法名[参数个数]
* @returntrue:禁用 false:未禁用
*/
public boolean isLimit(long second, int times, long lockTime, String ip, String uri){
String lockKey = LOCK_PREFIX + ip + uri;
String countKey = COUNT_PREFIX + ip + uri;
Object o = redisTemplate.opsForValue().get(lockKey);
if (Objects.nonNull(o)){
log.info("用户{},访问{}接口,被禁用",ip,uri);
//获取锁值不为空说明已经禁用,直接返回
return true;
}else {
//未被禁用
//获取访问次数
Integer o1 = (Integer) redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(o1)){
log.info("用户{},访问{}接口,首次访问",ip,uri);
//首次访问,保存访问次数为1
redisTemplate.opsForValue().set(countKey,1,second,TimeUnit.SECONDS);
}else {
//判断访问次数
if (o1 == times){
log.info("用户{},访问{}接口,达到次数限制被禁用",ip,uri);
//已经达到限制,禁用,返回
redisTemplate.opsForValue().set(lockKey,1,lockTime,TimeUnit.SECONDS);
//删除计数key,已经禁用,这个也就没必要了
redisTemplate.delete(countKey);
return true;
}else {
log.info("用户{},访问{}接口,现在第{}次访问",ip,uri,(o1 + 1));
//次数加1
// redisTemplate.opsForValue().set(countKey,++o1,second,TimeUnit.SECONDS);
Long increment = redisTemplate.opsForValue().increment(countKey);
}
}
}
return false;
}
[*]到此限流方案完善
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页:
[1]