文章目录
1、API接口防刷
1.1、概念
顾名思义,就是要实现某个接口在某段时间内只能让某人访问指定次数,超出次数,就不让访问了
1.2、原理
- 在请求的时候,服务器通过 Redis 记录下你请求的次数,如果次数超过限制就不给访问
- 在 Redis 保存的 Redis 是有时效性的,过期就会删除
1.3、目的
主要防止短时间接口被大量调用(攻击),出现系统崩溃和系统爬虫问题,提升服务的可用性
1.4、实现方案介绍
- 拦截器+自定义注解+Redis
- AOP+自定义注解+Redis
我这里准备了一份基础代码:https://gitee.com/colinWu_java/spring-boot-base.git
接下来我会在此主干代码基础上进行开发
2、方案一
好,接下来我们直接实战编码,运用到项目中的话,非常实用,使用起来也非常方便,下面是我们需要写的几个核心类
- 自定义注解
- 拦截器(核心)
- Redis配置类(设置序列化用)
2.1、自定义注解
package org.wujiangbo.annotation;
import java.lang.annotation.*;
/**
* 用于防刷限流的注解
* 默认是5秒内只能调用一次
*/
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** 限流的key */
String key() default "limit:";
/** 周期,单位是秒 */
int cycle() default 5;
/** 请求次数 */
int count() default 1;
/** 默认提示信息 */
String msg() default "请勿重复点击";
}
2.2、拦截器
package org.wujiangbo.interceptor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.wujiangbo.annotation.RateLimit;
import org.wujiangbo.exception.MyException;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;
/**
* 防刷限流的拦截器
* @author wujiangbo
* @date 2022-08-23 18:39
*/
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Integer> redisTemplate;
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 如果请求的是方法,则需要做校验
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 获取目标方法上是否有指定注解
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
//说明目标方法上没有 RateLimit 注解
return true;
}
//代码执行到此,说明目标方法上有 RateLimit 注解,所以需要校验这个请求是不是在刷接口
// 获取请求IP地址
String ip = getIpAddr(request);
// 请求url路径
String uri = request.getRequestURI();
//存到redis中的key
String key = "RateLimit:" + ip + ":" + uri;
// 缓存中存在key,在限定访问周期内已经调用过当前接口
if (redisTemplate.hasKey(key)) {
// 访问次数自增1
redisTemplate.opsForValue().increment(key, 1);
// 超出访问次数限制
if (redisTemplate.opsForValue().get(key) > rateLimit.count()) {
throw new MyException(rateLimit.msg());
}
// 未超出访问次数限制,不进行任何操作,返回true
} else {
// 第一次设置数据,过期时间为注解确定的访问周期
redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS);
}
return true;
}
//如果请求的不是方法,直接放行
return true;
}
//获取请求的归属IP地址
private String getIpAddr(HttpServletRequest request) {
String ipAddress = null;
try {
ipAddress = request.getHeader("x-forwarded-for");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.length() > 15) {
// = 15
if (ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
}
}
} catch (Exception e) {
ipAddress = "";
}
return ipAddress;
}
}
2.3、Redis配置类
package org.wujiangbo.config.redis;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
// 设置序列化
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置redisTemplate
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
RedisSerializer<?> stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);// key序列化
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// value序列化
redisTemplate.setHashKeySerializer(stringSerializer);// Hash key序列化
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);// Hash value序列化
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
2.4、注册拦截器
package cn.wujiangbo.config;
import cn.wujiangbo.interceptor.RateLimitInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
/**
* 配置拦截器
* @author wujiangbo
* @date 2022-08-23 18:51
*/
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
@Autowired
private RateLimitInterceptor rateLimitInterceptor;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor);
}
}
项目需要导入依赖:
<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yml配置文件如下:
server:
port: 8001
undertow:
# 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
# 不要设置过大,如果过大,启动项目会报错:打开文件数过多(CPU有几核,就填写几)
io-threads: 6
# 阻塞任务线程池, 当执行类似servlet请求阻塞IO操作, undertow会从这个线程池中取得线程
# 它的值设置取决于系统线程执行任务的阻塞系数,默认值是:io-threads * 8
worker-threads: 48
# 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理
# 每块buffer的空间大小,越小的空间被利用越充分,不要设置太大,以免影响其他应用,合适即可
buffer-size: 1024
# 每个区分配的buffer数量 , 所以pool的大小是buffer-size * buffers-per-region
buffers-per-region: 1024
# 是否分配的直接内存(NIO直接分配的堆外内存)
direct-buffers: true
spring:
#redis配置
redis:
# 数据库索引
database: 0
# 地址
host: 127.0.0.1
# 端口,默认为6379
port: 6379
# 密码
password: 123456
# 连接超时时间
timeout: 30s
jedis:
pool:
time-between-eviction-runs: 1000
max-active: 200
max-wait: -1ms
min-idle: 5
max-idle: 20
#配置数据库链接信息
datasource:
url: jdbc:mysql://127.0.0.1:3306/test1?useSSL=false&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&rewriteBatchedStatements=true
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
application:
name: springboot #服务名
#MyBatis-Plus相关配置
mybatis-plus:
#指定Mapper.xml路径,如果与Mapper路径相同的话,可省略
mapper-locations: classpath:org/wujiangbo/mapper/*Mapper.xml
configuration:
map-underscore-to-camel-case: true #开启驼峰大小写自动转换
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #开启控制台sql输出
2.5、测试
package org.wujiangbo.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wujiangbo.annotation.CheckPermission;
import org.wujiangbo.annotation.RateLimit;
import org.wujiangbo.result.JSONResult;
/**
* @desc 测试接口类
* @author 波波老师(weixin:javabobo0513)
*/
@RestController
@Slf4j
public class TestController {
//5秒内只能访问2次
@RateLimit(key= "testLimit", count = 2, cycle = 6, msg = "同志,不要请求这么快,好吗")
@GetMapping("/test001")
public JSONResult rate() {
System.out.println("成功发送一条短信");
return JSONResult.success();
}
}
打开浏览器访问:http://localhost:8081/test/test001
开始返回结果:
但是当你多刷几次后,就显示报错信息了:
很显然,我们接口防刷功能就实现了,测试成功
以上代码已经全部提交到:https://gitee.com/colinWu_java/spring-boot-base.git
在分支【InterfacePreventAttack】中
2.6、Lua脚本实现方案
针对上面方案我们可以改用lua脚本实现,关于lua介绍:
- lua本身就是一种编程语言,是一个小巧的脚本语言
- 性能非常高
我们在Redis的场景中使用lua脚本有以下好处:
- 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输
- 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务
- 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用
这里我们的需求是做限流,思路是根据用户的IP和访问的URI来进行计数,达到一定数量之后进行限制访问。这应该是限流操作的计算法,另外还有令牌算法和漏桶算法
我们这里介绍最简单的计算法,首先我们在项目的resources目录下新建limit.lua文件,里面内容如下:
local key = "rate.limit:" .. KEYS[1] --限流KEY
local limit = tonumber(ARGV[1]) --限流大小
local cycle = ARGV[2] --过期周期
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
return 0
else --请求数+1,并设置过期时间
redis.call("INCRBY", key, "1")
redis.call("expire", key, cycle)
return current + 1
end
上面就是我们的lua限流脚本
然后我们Redis配置类中新增下面方法,用来读取上面的lua脚本:
@Bean
public DefaultRedisScript<Number> redisluaScript() {
DefaultRedisScript<Number> redisScript = new DefaultRedisScript<>();
//读取 lua 脚本
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limit.lua")));
redisScript.setResultType(Number.class);
return redisScript;
}
然后拦截器类代码改成下面这样了:
package cn.wujiangbo.interceptor;
import cn.wujiangbo.annotation.RateLimit;
import cn.wujiangbo.exception.MyException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
/**
* 防刷限流的拦截器
* @author wujiangbo
* @date 2022-08-23 18:39
*/
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Integer> redisTemplate;
@Autowired
private DefaultRedisScript<Number> redisLuaScript;
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 如果请求的是方法,则需要做校验
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 获取目标方法上是否有指定注解
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
//说明目标方法上没有 RateLimit 注解
return true;
}
//代码执行到此,说明目标方法上有 RateLimit 注解,所以需要校验这个请求是不是在刷接口
// 获取请求IP地址
String ip = getIpAddr(request);
// 请求url路径
String uri = request.getRequestURI();
//存到redis中的key
String key = rateLimit.key() + ip + ":" + uri;
//将key转成List类型
List<String> keys = Collections.singletonList(key);
Number number = redisTemplate.execute(redisLuaScript, keys, rateLimit.count(), rateLimit.cycle());
if (number != null && number.intValue() != 0 && number.intValue() <= rateLimit.count()) {
System.out.println(rateLimit.cycle() + "秒内访问第:" + number.toString() + " 次" + getCurrentTime());
return true;
}
throw new MyException(rateLimit.msg());
}
//如果请求的不是方法,直接放行
return true;
}
//获取当前时间
public static String getCurrentTime(){
LocalDateTime localDateTime = LocalDateTime.now();
return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
//获取请求的归属IP地址
private String getIpAddr(HttpServletRequest request) {
String ipAddress = null;
try {
ipAddress = request.getHeader("x-forwarded-for");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.length() > 15) {
// = 15
if (ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
}
}
} catch (Exception e) {
ipAddress = "";
}
return ipAddress;
}
}
测试代码:
package cn.wujiangbo.controller;
import cn.wujiangbo.annotation.RateLimit;
import cn.wujiangbo.result.JSONResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 测试接口
* @author wujiangbo
* @date 2022-08-23 18:50
*/
@RestController
@RequestMapping("/test")
public class TestController {
//4秒内只能访问2次
@RateLimit(key= "testLimit", count = 2, cycle = 4, msg = "同志,不要请求这么快,好吗")
@GetMapping("/test001")
public JSONResult rate() {
System.out.println("成功发送一条短信");
return JSONResult.success();
}
}
然后浏览器再访问做测试,就可以实现4秒内只能访问两次接口了
3、方案二
AOP方案需要导入依赖:
<!-- aop切面 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
3.1、自定义注解
package cn.wujiangbo.annotation;
import java.lang.annotation.*;
/**
* 用于防刷限流的注解
* 默认是5秒内只能调用一次
* @author wujiangbo
* @date 2022-08-23 18:36
*/
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** 限流的key */
String key() default "limit:";
/** 周期,单位是秒 */
int cycle() default 5;
/** 请求次数 */
int count() default 1;
/** 默认提示信息 */
String msg() default "请勿重复点击";
}
3.2、切面类
package cn.wujiangbo.aspect;
import cn.wujiangbo.annotation.RateLimit;
import cn.wujiangbo.exception.MyException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* 切面类:实现限流校验
* @author wujiangbo
* @date 2022-08-24 11:27
*/
@Aspect
@Component
public class AccessLimitAspect {
@Resource
private RedisTemplate<String, Integer> redisTemplate;
/**
* 这里我们使用注解的形式
* 当然,我们也可以通过切点表达式直接指定需要拦截的package,需要拦截的class 以及 method
*/
@Pointcut("@annotation(cn.wujiangbo.annotation.RateLimit)")
public void limitPointCut() {
}
/**
* 环绕通知
*/
@Around("limitPointCut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 获取被注解的方法
MethodInvocationProceedingJoinPoint mjp = (MethodInvocationProceedingJoinPoint) pjp;
MethodSignature signature = (MethodSignature) mjp.getSignature();
Method method = signature.getMethod();
// 获取方法上的注解
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit == null) {
// 如果没有注解,则继续调用,不做任何处理
return pjp.proceed();
}
/**
* 代码走到这里,说明有 RateLimit 注解,那么就需要做限流校验了
* 1、这里可以使用Redis的API做计数校验
* 2、这里也可以使用Lua脚本做计数校验,都可以
*/
//获取request对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取请求IP地址
String ip = getIpAddr(request);
// 请求url路径
String uri = request.getRequestURI();
//存到redis中的key
String key = "RateLimit:" + ip + ":" + uri;
// 缓存中存在key,在限定访问周期内已经调用过当前接口
if (redisTemplate.hasKey(key)) {
// 访问次数自增1
redisTemplate.opsForValue().increment(key, 1);
// 超出访问次数限制
if (redisTemplate.opsForValue().get(key) > rateLimit.count()) {
throw new MyException(rateLimit.msg());
}
// 未超出访问次数限制,不进行任何操作,返回true
} else {
// 第一次设置数据,过期时间为注解确定的访问周期
redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS);
}
return pjp.proceed();
}
//获取请求的归属IP地址
private String getIpAddr(HttpServletRequest request) {
String ipAddress = null;
try {
ipAddress = request.getHeader("x-forwarded-for");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.length() > 15) {
// = 15
if (ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
}
}
} catch (Exception e) {
ipAddress = "";
}
return ipAddress;
}
}
3.3、测试
package cn.wujiangbo.controller;
import cn.wujiangbo.annotation.RateLimit;
import cn.wujiangbo.result.JSONResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 测试接口
* @author wujiangbo
* @date 2022-08-23 18:50
*/
@RestController
@RequestMapping("/test")
public class TestController {
//4秒内只能访问2次
@RateLimit(key= "testLimit", count = 2, cycle = 4, msg = "同志,不要请求这么快,好吗")
@GetMapping("/test001")
public JSONResult rate() {
System.out.println("成功发送一条短信");
return JSONResult.success();
}
}
然后浏览器再访问做测试,就可以实现4秒内只能访问两次接口了
4、限流算法介绍(了解)
4.1、令牌桶算法
- 令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌
- 桶中存放的令牌数有最大上限,超出之后就被丢弃或者拒绝
- 当流量或者网络请求到达时,每个请求都要获取一个令牌,如果能够获取到,则直接处理,并且令牌桶删除一个令牌。如果获取不到,该请求就要被限流,要么直接丢弃,要么在缓冲区等待
4.2、漏桶算法
- 漏桶算法的实现往往依赖于队列,请求到达如果队列未满则直接放入队列,然后有一个处理器按照固定频率从队列头取出请求进行处理
- 如果请求量大,则会导致队列满,那么新来的请求就会被抛弃
5、总结
- 实际项目中,接口防刷是一个非常普遍的需求
- 一般的处理方案都是采用自定义注解+拦截器+Redis处理的
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/116640.html