利用Spring AOP理念,自定义注解实现接口级访问次数限制
访问次数记录使用Redis存储,Redis的过期机制很适合当前场景,而且可以在更大程度上提升性能
定义注解
package com.cong.core.rate;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** 周期,单位是秒 */
int cycle() default 5;
/** 请求次数 */
int number() default 1;
/** 默认提示信息 */
String msg() default "请勿重复点击";
}
默认是5秒调用一次,现在网上一大堆脚本,贴吧发帖跟帖自动化,实际上打字点击发帖的正常频率也不会超过2秒一次吧,但是机器很容易就超过这个速度了,在一定程度上也可以限制这种情况的发生。
接口级限制,所以当前注解只作用在方法上。
定义接口访问频次限制接口
package com.cong.core.rate;
public interface RateLimitService {
/**
* 接口频次限制校验
*
* @param ip
* 客户端IP
* @param uri
* 请求接口名
* @param rateLimit
* 限制频次信息
* @return
* @author single-聪
* @date 2020年6月1日
* @version 1.6.1
*/
Boolean limit(String ip, String uri, RateLimit rateLimit);
}
因为Interceptor拦截器最终返回值是true或false,所以当前接口返回值为boolean类型。
关于参数,可以设法获取设备Mac地址,对于某些明显是攻击的IP及设备封禁。
RateLimitService接口默认实现类
package com.cong.core.rate;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.RedisTemplate;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DefaultRateLimitServiceImpl implements RateLimitService {
private RedisTemplate<String, Integer> redisTemplate;
public void setRedisTemplate(RedisTemplate<String, Integer> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Boolean limit(String ip, String uri, RateLimit rateLimit) {
log.info("默认的实现,请自定义实现类覆盖当前实现");
String key = "rate:" + ip + ":" + uri;
// 缓存中存在key,在限定访问周期内已经调用过当前接口
if (redisTemplate.hasKey(key)) {
// 访问次数自增1
redisTemplate.opsForValue().increment(key, 1);
// 超出访问次数限制
if (redisTemplate.opsForValue().get(key) > rateLimit.number()) {
return false;
}
// 未超出访问次数限制,不进行任何操作,返回true
} else {
// 第一次设置数据,过期时间为注解确定的访问周期
redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS);
}
return true;
}
}
默认实现类中使用Redis作为存储策略,加上下面的Bean注入策略你就可以自定义接口实现类使用自己的存储方式了。
Bean配置
package com.cong.core.rate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
@Configuration
public class RateLimitBeanConfig {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Bean
@ConditionalOnMissingBean(RateLimitService.class)
public RateLimitService rateLimitService() {
DefaultRateLimitServiceImpl defaultRateLimitServiceImpl = new DefaultRateLimitServiceImpl();
defaultRateLimitServiceImpl.setRedisTemplate(redisTemplate);
return defaultRateLimitServiceImpl;
}
}
此配置意为让用户编写接口实现类覆盖默认实现。
定义拦截器
package com.cong.core.rate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
@Component
public class RateLimitInterceptor extends HandlerInterceptorAdapter {
private RateLimitService rateLimitService;
public void setRateLimitService(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
@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) {
return true;
}
// 请求IP地址
String ip = request.getRemoteAddr();
// 请求url路径
String uri = request.getRequestURI();
return rateLimitService.limit(ip, uri, rateLimit);
}
return true;
}
}
重点,只对添加了@RateLimit
注解的接口进行访问频次限制。
配置拦截器
package com.cong.config;
import com.cong.core.rate.RateLimitInterceptor;
import com.cong.core.rate.RateLimitService;
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;
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Autowired
private RateLimitService rateLimitService;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
RateLimitInterceptor rateLimitInterceptor = new RateLimitInterceptor();
rateLimitInterceptor.setRateLimitService(rateLimitService);
registry.addInterceptor(rateLimitInterceptor);
}
}
文中的很多地方接口使用set方式注入,是为了防止接口注入失败,报错空指针异常(应该很多人遇到过)。
使用注解
@RestController
@RequestMapping("open/public")
public class OpenPublicController {
@RateLimit(number = 2, cycle = 10)
@PostMapping("rate")
public void rate() {
throw new VersionException();
}
}
上述注解的作用是10秒内可以请求两次,其他的请求就不处理了,VersionException
是我自定义的异常,用于提示用户升级新版本,在2次内返回用户正常提示信息:
{
"state": 1000,
"msg": "请升级到新版本",
"data": null
}
超出限制后无返回信息(RateLimitInterceptor
拦截器中返回的是false,直接结束了这次请求,同时未向前端返回任何信息,实际开发中应该会返回提示信息,补充内容中解决这个问题)
关于拦截器中接口调用超出限制频次的自定义返回:
package com.cong.core.rate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.cong.core.support.ReturnData;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class RateLimitInterceptor extends HandlerInterceptorAdapter {
private RateLimitService rateLimitService;
public void setRateLimitService(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
private ObjectMapper objectMapper;
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@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) {
return true;
}
// 请求IP地址
String ip = request.getRemoteAddr();
// 请求url路径
String uri = request.getRequestURI();
if (!rateLimitService.limit(ip, uri, rateLimit)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ReturnData(rateLimit.msg())));
response.setStatus(HttpStatus.OK.value());
return false;
}
}
return true;
}
}
注入ObjectMapper
需要set一下。
ReturnData
是封装的返回值信息,前端可以根据这个给用户友好的提示,后端也可以自定义提示信息。
@Data
@NoArgsConstructor
public class ReturnData {
private Integer state = 1000;
private String msg;
private Object data;
public ReturnData(String msg) {
super();
this.msg = msg;
}
}
不过建议是自定义失败处理器,这样所有的错误统一走失败处理器,更方便以后的代码维护,这里只是为了实现接口频次限制,其他的这里就不描述了。
超频之后返回值:
接口名 | 注解 | 返回值 |
---|---|---|
open/public/rate | @RateLimit(number = 4, cycle = 10) | { "state": 1000, "msg": "请勿重复点击","data": null} |
open/public/rate1 | @RateLimit(number = 4, cycle = 10, msg = “调用频次过高”) | { "state": 1000, "msg": "调用频次过高","data": null} |
至此即实现接口访问频次限制以及自定义返回提示信息。
我目前的服务端开发用户信息是无状态的Token,基于JWT,使用的Security框架(前段时间的文章有一组笔记),用户权限校验是单独实现的。
关于性能:
使用了当前注解的接口请求耗时会长一点,我的Redis在一台学生机上,而且跨省,耗时大概增加了40ms,本地的话大概也就20ms左右,如果对性能还有要求的话建议使用lua脚本。
定义IP过滤器
在使用Redis的情况下,可以定义IP过滤器,计算指定IP请求速率,在上文中更多的是防止重复提交,但是对于文章开始所说的超高频次的调用并没有处理,建议在过滤器中拦截所有请求,每个IP对于单独接口在访问周期内超出限制之后将当前IP限制一段时间(是限制所有请求还是当前请求自行决定)
基于IP过滤器统计接口访问次数
在IP过滤器中借助Redis计算接口访问次数,每天同步一次,对于后面的服务扩展,接口限流等还是很有好处的。
欢迎留言,共同探讨。