关于Springboot集成Redis实现接口防刷
小贴士:目前大部分公司都采用前后端分离的开发方式,进行项目的并行开发。在项目中后台只需要提供一套API接口,就可以接入安卓、小程序、IOS、web等多个应用程序,这样可以节约开发成本。但是一个后台接入这么多应用程序的http请求,必然导致后端的压力非常大。所以对于一些请求进行过滤和拦截是非常有必要的,能够有效地减轻后台的压力。
接口防刷机制:主要防止短时间接口被大量调用(攻击),出现系统崩溃和系统爬虫问题,提升服务的可用性。
方案一(AOP)
一、什么是AOP(面向切面编程)
在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
AOP 是一个概念,一个规范,本身并没有设定具体语言的实现,这实际上提供了非常广阔的发展的空间。AspectJ是AOP的一个很悠久的实现,它能够和 Java 配合起来使用。 而我们要用的则是Springboot中对AOP的实现AspectJ ™。
AspectJ ™: 创建于Xerox PARC. 有近十年历史,成熟
缺点:过于复杂;破坏封装;需要专门的Java编译器。
二、需要导入的maven依赖
<!--web依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redis配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.4.3</version>
</dependency>
<!--AOP切面编程-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
三、关于Springboot中AOP功能的相关注解
@Aspect:作用是把当前类标识为一个切面供容器读取
@Component交由Spring容器进行管理
@Before在方法通知之前进行执行
@AfterReturning在方法返回通知后进行执行
@AfterThrowing异常通知在方法抛出异常时进行执行
@Around是一种建议类型,可确保方法执行前后的通知可以运行。
@Pointcut将方法标记为切入点
四、Aspect中的相关类的说明
JoinPoint
方法名 | 功能 |
Signature getSignature() | 获取封装了署名信息的对象,在该对象中可以获取到目标方法名,所属类的Class等信息 |
Object[] getArgs() | 获取传进目标方法的参数对象 |
Object getTarget() | 获取被代理的对象 |
Object getThis() | 获取代理对象 |
五、具体的代码层面实现
- 定义接口访问次数限制注解
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Prevent {
/**
* 限制的时间 (秒)
* @return
*/
long time() default 60L;
/**
* 返回的提示信息
* @return
*/
String message() default "服务器繁忙,请稍后重试!";
/**
* 访问的次数
* @return
*/
int count() default 5;
/**
* 为方便之后对于接口防刷更多拓展,此处采取不同的策略,做出不同的处理
* @return
*/
PreventStrategy strategy() default PreventStrategy.DEFAULT;
}
- 实现对其注解进行的处理切面编程
@Aspect
@Component
@Slf4j
public class PreventAop {
@Resource
private RedisUtil redisUtil;
/**
* 切入点,声明切面编程的切入点是@Prevent
*/
@Pointcut("@annotation(com.xuyujie.birthday.aop.Prevent)")
public void pointcout(){
}
/**
* 处理前,在遇到此切点时执行的方法
*/
@Before("pointcout()")
public void joinPoint(JoinPoint joinPoint) throws Exception {
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
//构建方法信息对象
Method method = joinPoint.getTarget().getClass().getMethod(methodSignature.getName(),
methodSignature.getParameterTypes());
//RequestContextHolder:持有上下文的Request容器,获取到当前请求的request
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
//获取当前请求
HttpServletRequest httpServletRequest = sra.getRequest();
//获取到要封禁的ip
String ip = CommonUtil.getIpAddress(httpServletRequest);
//反射获取当前方法上的注解,并以对象类型返回此注解
Prevent preventAnnotation = method.getAnnotation(Prevent.class);
//获取方法名用于构建redis的key
String MethodFullName = method.getName();
//入口
entrance(preventAnnotation,MethodFullName,ip);
}
/**
* 入口方法选择处理的策略
* @param prevent
* @param methodFullName
* @throws ExceptionVO
*/
private void entrance(Prevent prevent,String methodFullName,String ip) throws Exception { //通过注解信息进而执行不同的策略
PreventStrategy strategy = prevent.strategy();
switch (strategy){
case DEFAULT:
defaultMethod(prevent,methodFullName,ip);
break;
default:
throw new ExceptionVO(ResultDataEnum.INEFFECTIVE);
}
}
/***
* 默认的处理实现
* @param prevent
* @param methodFullName
*/
private void defaultMethod( Prevent prevent, String methodFullName,String ip) throws Exception {
long expire = prevent.time();
String key = RedisKeyUtils.getLimitKey(ip,methodFullName);
boolean checkResult = checkByRedis(prevent, key);
if (checkResult){
return;
}else{
String message = !StringUtils.hasText(prevent.message()) ? prevent.message() :
expire + "秒内不允许重复请求";
throw new ExceptionVO(10000,message);
}
}
/**
* 检测是否到达访问次数,此处使用lua脚本实现对于达到访问次数的事务检测
* @return
*/
private boolean checkByRedis(Prevent prevent,String key){
List<String> keyList = new ArrayList<>();
keyList.add(key);
keyList.add(String.valueOf(prevent.count()));
keyList.add(String.valueOf(prevent.time()));
String res = redisUtil.runLuaScript("ratelimit.lua",keyList);
if (res.equals("1")){
return true;
}else{
return false;
}
}
}
获取ip的方法
public static final String getIpAddress(HttpServletRequest request){
//获取请求主机IP地址,如果通过代理进来,则透过防火墙获取真实IP地址
String ip = request.getHeader("X-Forwarded-For");
if (ip != null){
if (!ip.isEmpty() && !"unKnown".equalsIgnoreCase(ip)) {
int index = ip.indexOf(",");
if (index != -1) {
return ip.substring(0, index);
} else {
return ip;
}
}
}
ip = request.getHeader("X-Real-IP");
if (ip != null) {
if (!ip.isEmpty() && !"unKnown".equalsIgnoreCase(ip)) {
return ip;
}
}
ip = request.getHeader("Proxy-Client-IP");
if (ip != null) {
if (!ip.isEmpty() && !"unKnown".equalsIgnoreCase(ip)) {
return ip;
}
}
ip = request.getHeader("WL-Proxy-Client-IP");
if (ip != null) {
if (!ip.isEmpty() && !"unKnown".equalsIgnoreCase(ip)) {
return ip;
}
}
ip = request.getRemoteAddr();
return ip.equals("0:0:0:0:0:0:0:1") ? "127.0.0.1" : ip;
}
全局异常信息
public class ExceptionVO extends RuntimeException{
private int errorCode;
private String message;
public ExceptionVO(ResultDataEnum resultDataEnum) {
errorCode = resultDataEnum.getCode();
message = resultDataEnum.getMessage();
}
public ExceptionVO(int code, String message) {
this.errorCode = code;
this.message = message;
}
public int getErrorCode() {
return errorCode;
}
public void setErrorCode(int errorCode) {
this.errorCode = errorCode;
}
@Override
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
六、测试
方案二(拦截器进行实现)
一、什么是拦截器
SpringMVC的处理器拦截器类似于Servlet开发中的过滤器Filter,用于对处理器进行预处理和后处理,开发者可以自己定义一些拦截器来实现特定的功能
二、拦截器和过滤器的区别
- 过滤器与拦截器的区别:拦截器是AOP思想的具体应用(即拦截器的实现原理就是AOP横切增强,不会修改源码) ,拦截器属于Servlet层面的组件,在Filter之后进行拦截。
- 拦截器只会拦截访问控制器的方法的请求, 如果访问的是jsp/html/css/image/js这些静态资源,是不会进行拦截的(这一点就比过滤器好,因为过滤器不管请求什么,它都会过滤一遍,比较占用资源和时间)。
注:拦截器属于Springboot的web下的默认组件,因此不需要进行引入其他类型的Sping依赖
三、代码层面的实现
1、自定义接口 防刷的校验注解
//只可以在方法上声明此注解
@Target({ElementType.METHOD})
//声明注解的保存周期,不仅在class中而且到jvm中
@Retention(RetentionPolicy.RUNTIME)
//使此注解声明在java的doc中
@Documented
public @interface RateLimit {
/**周期,单位为秒*/
int circle() default 60;
/**访问的次数 */
int rate() default 1;
/**提示信息 */
String message() default "请求频繁。请稍后再试!";
}
2、实现拦截器
public class RateLimitInterceptor implements HandlerInterceptor{
private RateLimitService rateLimitService;
public RateLimitInterceptor(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断是否是由controller层过来的请求
if(handler instanceof HandlerMethod){
HandlerMethod handlerMethod = (HandlerMethod) handler;
//查看方法上是否有此类注解
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null){
return true;
}
//获取ip
String ip = getIpAddress(request);
//获取请求的接口路径
String uri = request.getRequestURI();
return rateLimitService.limit(ip,uri,rateLimit);
}
return true;
}
}
3、在Spring容器中注册此拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
RateLimitInterceptor rateLimitInterceptor = new RateLimitInterceptor(rateLimitService);
registry.addInterceptor(rateLimitInterceptor);
}
4、方便程序拓展的配置防刷接口的service实现类
@Configuration
public class RateLimitBeanConfig {
/**
* //RateLimitService的实现时,使用默认的实现类,可以重写默认实现
* @return
*/
@Bean
@ConditionalOnMissingBean(RateLimitService.class)
public RateLimitService rateLimitService(){
DefaultRateLimitServiceImpl defaultRateLimitServiceImpl = new DefaultRateLimitServiceImpl();
return defaultRateLimitServiceImpl;
}
}
5、默认的实现类
@Slf4j
public class DefaultRateLimitServiceImpl implements RateLimitService {
@Resource
private RedisUtil redisUtil;
@Override
public Boolean limit(String ip, String uri, RateLimit rateLimit) {
String key = RedisKeyUtils.getLimitKey(ip, uri);
if (redisUtil.hasKey(key)){
int time = (int) redisUtil.get(key);
if (time >= rateLimit.rate()){
throw new MyException(22000,"访问超限,请稍后重试!");
}else{
redisUtil.incr(key,1);
}
}else{
redisUtil.set(key,1,rateLimit.circle());
}
return true;
}
}
总结
个人层面认为方案一对比方案二更加有优势,对于多个请求时,拦截器则需要将请求全部拦截,然后进行判断是否要进行接口访问的限制,而方案一则是通过AOP编程的思想,对于代码的侵入性低.
各有优势,对于拦截器这种实现更容易理解,且可以直接在拦截器层面获取到request进而的到ip和请求的路径。AOP则需要得到请求的上下文对象。