• 基于redis的计数器限流算法实现


    前言

    昨天我们已经预告了今天的内容——实现计数器限流算法,所以今天不需要过多说明,我们直接开始正文。

    计数器限流算法

    关于计数器限流算法的实现原理,我们昨天已经介绍过了,今天的内容算是基于我们昨天所说的原理的一种应用和实现,当然还是有必要说下我们的实现思路的:

    在接口内部最开始的地方,设置调用方的计数器(key为调用方唯一的身份信息),第一次调用时将其值设置为1并放进缓存中,同时缓存设置过期时间,有效期内每次调用计数器+1,时间过期,缓存会自动删除。可以把相关逻辑封装成自定义注解,搞成通用组件,这样只需要在需要限速的接口上加上对应的的注解即可,明天我们可以来实现下。

    创建项目

    这里我们直接创建一个spring bootweb项目,然后引入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>
    

    redis用的是spring bootRedisTemplate,当然你也可以用其他的,没有任何限制,然后是redis客户端设置:

    spring:
      redis:
        database: 0
        host: 127.0.0.1
        port: 6379
        password: redis1234567
        # 连接超时时间(ms)
        timeout: 5000
        # 高版本springboot中使用jedis或者lettuce
        jedis:
          pool:
            # 连接池最大连接数(负值表示无限制)
            max-active: 8
            # 连接池最大阻塞等待时间(负值无限制)
            max-wait: 5000
            # 最大空闲链接数
            max-idle: 8
            # 最小空闲链接数
            min-idle: 1
    

    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表示调用方身份唯一性的参数名,比如userIdlimitTimes表示限制访问次数,也就是他在指定时间内可以访问多少次;timeout表示限制访问次数的有效期,一分钟还是一个小时;timeUnit表示限速实际的单位,秒、分钟、小时等。

    限速拦截器

    没做之前,考虑的是通过切面来实现,但是今天实际实践的时候,发现之前想偏了(竟然会犯入参低级错误,说明最近轮子造的有点少),最终是通过拦截器实现的(忠告:没事还是要多造轮子,不然容易手生):

    @Component
    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();
                        logger.info("result:{}, expire: {}",  result, expire);
                        return true;
                    // 否则告知调用方达到限速上线
                    } else {
                        result.put("result", "达到访问次数限制,禁止访问");
                        Long expire = boundGeoOperations.getExpire();
                        logger.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返回了异常信息,实际项目开发中,这种写法肯定是不合理的,各位小伙伴一定要注意哦!

    拦截器配置

    这一块就属于复习内容了,也属于比较入门级别的spring boot操作了,这里不再过多赘述,详细代码如下:

    @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,这个注解的参数我们上面已经解释过了,所以这里也就不再赘述:

    	@CounterLimit(name = "token",limitTimes = 5, timeout = 60, timeUnit = TimeUnit.SECONDS)
        @GetMapping("/limit/count-test")
        public Object counterLimiter(String name, String token) {
            JSONObject result = new JSONObject();
           result.put("data", "success");
            return result;
        }
    

    测试

    完成以上内容之后,我们就可以进行相关测试了,首先将我们的项目启动起来,然后直接访问我们的接口即可,访问接口的时候记得带着我们的token(唯一key),最终访问结果如下:

    从结果中我们可以看出来,在第一次访问的时候,token的过期时间为60,我们连续访问5次之后,接口限制我们访问的,然后等到限制过期之后(token过期),又可以继续访问了。至此,我们的计数器限流的算法实现也算是完美达成,是不是很简单呢?

    总结

    本次demo总体来说很简单,除了算法本身之外,基本上都是java或者spring boot的简单知识点应用,但是从我自己实践的感受来说,我觉得以后还是得多造轮子,因为之前比较熟悉得好多配置和写法都生疏了,好多都要翻看之前的demo才能想起来。当然,话句话说就是,很多看起来很简单的实例或者demo,其实在真正实践的时候并不简单,因为我们往往总会高估自己的能力……

    项目完整代码:

    https://github.com/Syske/learning-dome-code/tree/dev/spring-boot-counter-limiter
    

    好了,各位小伙伴,晚安吧!

  • 相关阅读:
    js比较两个String字符串找出不同,并将不同处高亮显示
    C# 去掉webapi返回json所带的转义字符
    .Net C#向远程服务器Api上传文件
    Access-Control-Allow-Origin,跨域
    ajax时间戳或随机数
    SqlServer收缩日志文件
    MsDepSvc 启动失败
    SqlServer收缩数据库语句
    sql server datetime类型字段使用isnull返回1900-01-01 00:00:00.000的问题
    EXCEPTION与ERROR的区别
  • 原文地址:https://www.cnblogs.com/caoleiCoding/p/15491778.html
Copyright © 2020-2023  润新知