Springboot API接口防刷

1. 概述

接口防刷,顾名思义:就是想让某个接口某个人在某段时间内只能请求N次。

在项目中比较常见的问题也有,比如Web端表单重复提交,可以用token来解决。

具体接口设计可以查看之前的博客【接口设计看这一篇就够了

2. 原理

在请求的时候,服务器通过redis记录请求的次数,如果次数超过限制就不给访问

3. 代码实现

3.1 引入依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<scope>compile</scope>
</dependency>

3.2 添加配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
application:
name: request-limit-demo
redis:
password: xiaoyuge
database: 0
port: 6379
host: localhost
lettuce:
pool:
max-idle: 10
min-idle: 0
max-active: 10
max-wait: -1ms
timeout: 10000ms
server:
port: 8080

3.3 自定义注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 请求限制的自定义注解
*
* @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;
}

3.4 定义拦截器

定义一个拦截器,请求之前,对请求次数校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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)) {
responseOut(response, 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 >= requestLimit.maxCount()) {
return true;
}
// 次数自增
redisTemplate.opsForValue().increment(limitKey);
}
return false;
}

/**
* 回写给客户端
*/
private void responseOut(HttpServletResponse response, ApiResultEnum result) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = null;
String json = result.toString();
out = response.getWriter();
out.append(json);
}
}

3.5 注册拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@Component
public class WebMvcConfig implements WebMvcConfigurer {

@Autowired
private RequestLimitIntercept requestLimitIntercept;

@Override
public void addInterceptors(InterceptorRegistry registry) {
log.info("添加拦截");
registry.addInterceptor(requestLimitIntercept);
}
}

3.6 Redis配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
@Bean
@SuppressWarnings(value = {"unchecked", "rawtypes"})
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);

// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);

// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);

template.afterPropertiesSet();
return template;
}

@Bean
public DefaultRedisScript<Long> limitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(limitScriptText());
redisScript.setResultType(Long.class);
return redisScript;
}

/**
* 限流脚本
*/
private String limitScriptText() {
return "local key = KEYS[1]\n" +
"local count = tonumber(ARGV[1])\n" +
"local time = tonumber(ARGV[2])\n" +
"local current = redis.call('get', key);\n" +
"if current and tonumber(current) > count then\n" +
" return tonumber(current);\n" +
"end\n" +
"current = redis.call('incr', key)\n" +
"if tonumber(current) == 1 then\n" +
" redis.call('expire', key, time)\n" +
"end\n" +
"return tonumber(current);";
}
}

3.7 Controller层测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping
@RequestLimit(maxCount = 5, second = 5)
public class TestController {
/**
* // @RequestLimit 修饰在方法上,优先使用其参数
*/
@GetMapping("/test1")
@RequestLimit
public String test1() {
return "test1";
}
/**
* @RequestLimit 修饰在类上,用的是类的参数
*/
@GetMapping("/test2")
public String test2() {
return "test2";
}
}

参考博客SpringBoot API 接口防刷