前言
昨天我们已经预告了今天的内容——实现计数器限流算法,所以今天不需要过多说明,我们直接开始正文。
计数器限流算法
关于计数器限流算法的实现原理,我们昨天已经介绍过了,今天的内容算是基于我们昨天所说的原理的一种应用和实现,当然还是有必要说下我们的实现思路的:
在接口内部最开始的地方,设置调用方的计数器(key为调用方唯一的身份信息),第一次调用时将其值设置为1并放进缓存中,同时缓存设置过期时间,有效期内每次调用计数器+1,时间过期,缓存会自动删除。可以把相关逻辑封装成自定义注解,搞成通用组件,这样只需要在需要限速的接口上加上对应的的注解即可,明天我们可以来实现下。
创建项目
项目结构:
我们直接创建一个spring boot的web项目,然后引入redis客户端的依赖:
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.3.6.RELEASE</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.41</version>
</dependency>
redis用的是spring boot的RedisTemplate,然后是redis客户端设置:
spring:
redis:
database: 0
host: 127.0.0.1
port: 6379
password: "0211"
# 连接超时时间(ms)
timeout: 5000
# 高版本springboot中使用jedis或者lettuce
jedis:
pool:
# 连接池最大连接数(负值表示无限制)
max-active: 8
# 连接池最大阻塞等待时间(负值无限制)
max-wait: 5000
# 最大空闲链接数
max-idle: 8
# 最小空闲链接数
min-idle: 1
server:
port: 8888
redis配置类:
@Configuration
public class RedisConfig {
private static Logger logger = LoggerFactory.getLogger(RedisConfig.class);
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.database}")
private int database;
@SuppressWarnings("all")
@Bean
public StringRedisTemplate redisTemplate(RedisConnectionFactory factory){
StringRedisTemplate template = new StringRedisTemplate(factory);
Jackson2JsonRedisSerializer 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);
RedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public JedisConnectionFactory jedisConnectionFactory() {
logger.info("jedisConnectionFactory:初始化了");
RedisStandaloneConfiguration configuration =
new RedisStandaloneConfiguration();
configuration.setHostName(host);
configuration.setPassword(RedisPassword.of(password));
configuration.setPort(port);
configuration.setDatabase(database);
return new JedisConnectionFactory(configuration);
}
}
限流业务实现
为了能够实现业务层面的低耦合,同时也为了便于应用到实际业务中,这里我将限流器封装到拦截器中,然后通过自定义注解的方式实现拦截器的业务去耦合。
限速注解组件
我的第一步是定义一个计数器限流注解组件:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CounterLimit {
/**
* 调用方唯一key的名字
*
* @return
*/
String name();
/**
* 限制访问次数
* @return
*/
int limitTimes();
/**
* 限制时长,也就是计数器的过期时间
*
* @return
*/
long timeout();
/**
* 限制时长单位
*
* @return
*/
TimeUnit timeUnit();
}
注解包括四个属性:
name表示调用方身份唯一性的参数名,比如userId;
limitTimes表示限制访问次数,也就是他在指定时间内可以访问多少次;
timeout表示限制访问次数的有效期,一分钟还是一个小时;
timeUnit表示限速实际的单位,秒、分钟、小时等。
限速拦截器
@Component
@Slf4j
public class CounterLimiterHandlerInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
//判断方法是否包含CounterLimit,有这个注解就需要进行限速操作
if (handlerMethod.hasMethodAnnotation(CounterLimit.class)) {
CounterLimit annotation = handlerMethod.getMethod().getAnnotation(CounterLimit.class);
JSONObject result = new JSONObject();
String token = request.getParameter(annotation.name());
response.setContentType("text/json;charset=utf-8");
result.put("timestamp", System.currentTimeMillis());
BoundValueOperations<String, Integer> boundGeoOperations =
redisTemplate.boundValueOps(token);
// 如果用户身份唯一key为空,直接返回错误
if (StringUtils.isEmpty(token)) {
result.put("result", "token is invalid");
response.getWriter().print(JSON.toJSONString(result));
// 如果限速校验通过,则将请求放行
} else if (checkLimiter(token, annotation)) {
result.put("result", "请求成功");
Long expire = boundGeoOperations.getExpire();
log.info("result:{}, expire: {}", result, expire);
return true;
// 否则告知调用方达到限速上线
} else {
result.put("result", "达到访问次数限制,禁止访问");
Long expire = boundGeoOperations.getExpire();
log.info("result:{}, expire: {}", result, expire);
response.getWriter().print(JSON.toJSONString(result));
}
return false;
}
}
return true;
}
/**
* 限速校验
*/
private Boolean checkLimiter(String token, CounterLimit annotation) {
BoundValueOperations<String, Integer> boundGeoOperations =
redisTemplate.boundValueOps(token);
Integer count = boundGeoOperations.get();
if (Objects.isNull(count)) {
redisTemplate.boundValueOps(token).set(1, annotation.timeout(),
annotation.timeUnit());
} else if (count >= annotation.limitTimes()) {
return Boolean.FALSE;
} else {
redisTemplate.boundValueOps(token).set(count + 1,
boundGeoOperations.getExpire(), annotation.timeUnit());
}
return Boolean.TRUE;
}
}
代码逻辑也比较简单:
首先判断接口方法是否包含CounterLimit注解,有这个注解就需要进行限速操作
如果用户身份唯一key为空,直接返回错误
如果限速校验通过,则将请求放行,否则告知调用方达到限速上线
在校验限速方法中,如果count为空,表示首次访问,则存放一个count,并设置过期时间
如果达到访问限制上限,直接拒绝,未达到则count+1,过期时间设置为剩余时间
代码也有比较详细的注解,各位小伙伴也应该能看懂。
注意: 当然如果你的项目本身已经有了完善的全局异常处理机制,这里的拦截器可以直接抛出对应的异常,这里并没有做全局异常处理,而是直接通过response返回了异常信息,实际项目开发中,这种写法肯定是不合理的,各位小伙伴一定要注意哦!
拦截器配置
详细代码如下:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private CounterLimiterHandlerInterceptor counterLimiterHandlerInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//计数器限速
registry.addInterceptor(counterLimiterHandlerInterceptor).addPathPatterns("/**");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
接口配置
接口就是简单的controller方法,然后方法上多了我们的自定义限速器注解CounterLimit,这个注解的参数我们上面已经解释过了,所以这里也就不再赘述:
@RestController
public class CounterController {
@CounterLimit(name = "token", limitTimes = 5, timeout = 60, timeUnit = TimeUnit.SECONDS)
@GetMapping("/limit/counter")
public Object counterLimiter(String name, String token) {
JSONObject result = new JSONObject();
result.put("data", "success");
return result;
}
}
启动Redis服务:
启动服务,调用结果测试: