当前位置:   article > 正文

SpringBoot 接口访问频率限制(二)_springboot 限制请求频率

springboot 限制请求频率

前言
SpringBoot 接口访问频率限制(一)中,我们已经通过编写代码实现了对接口访问频率的有效控制。然而,若要在不修改原有代码和逻辑的前提下,为源代码补充额外的信息或功能,注解(Annotation)便是一个理想的选择。通过注解,我们可以在不侵入原有代码的情况下,为接口或方法添加频率限制功能的增强。

注解实现

注解

定义一个多策略的容器注解

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface FrequencyControlContainer {
    FrequencyControl[] value();
}
  • 1
  • 2
  • 3
  • 4
  • 5

定义关键频控策略注解@FrequencyControl之前前
回顾一下我们使用的策略算法需要什么样的参数
在这里插入图片描述
频控注解如下:

/**
 * 频控注解
 */
@Repeatable(FrequencyControlContainer.class)//可重复
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface FrequencyControl {
    /**
     * key的前缀,默认取方法全限定名,除非我们在不同方法上对同一个资源做频控,就自己指定
     *
     * @return key的前缀
     */
    String prefixKey() default "";

    /**
     * 频控对象,默认el表达指定具体的频控对象
     * 对于ip 和uid模式,需要是http入口的对象,保证RequestHolder里有值
     *
     * @return 对象
     */
    Target target() default Target.EL;

    /**
     * springEl 表达式,target=EL必填
     *
     * @return 表达式
     */
    String spEl() default "";

    /**
     * 频控时间范围,默认单位秒
     *
     * @return 时间范围
     */
    int time();

    /**
     * 频控时间单位,默认秒
     *
     * @return 单位
     */
    TimeUnit unit() default TimeUnit.SECONDS;

    /**
     * 单位时间内最大访问次数
     *
     * @return 次数
     */
    int count();

    enum Target {
        UID, IP, EL
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55

关键就在于@Repeatable可重复的配置,这样就可以把相同的注解加在一个方法上。可以参考注解@Repeatable详解
在频率控制的实现中,频控对象对应于Redis中的一个特定key,这要求我们提供prefixKey参数来指定key的前缀,以及使用EL表达式spEl参数来动态生成key的剩余部分。

time和unit参数用于控制统计的时间范围,它们共同决定了频率控制的窗口大小。而count参数则指定了在指定时间范围内允许的最大访问次数。

那么,为什么还需要target参数呢?这主要是因为我们的频控机制主要应用于接口层面。在实际应用中,接口拦截器会解析出用户的IP地址和用户ID(uid)。很多场景下,我们直接希望根据uid或IP地址来进行频率控制。当指定了uid后,我们甚至可以省略EL表达式的编写,因为切面能够自动从上下文中获取uid,这样使得注解的使用更加简洁和直观。通过target参数,我们可以明确指定是针对uid还是IP地址进行频率控制,使得注解功能更加灵活和强大。

切面

@Slf4j
@Aspect
@Component
public class FrequencyControlAspect {

    @Around("@annotation(FrequencyControl)||@annotation(FrequencyControlContainer)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        FrequencyControl[] annotationsByType = method.getAnnotationsByType(FrequencyControl.class);
        Map<String, FrequencyControl> keyMap = new HashMap<>();
        for (int i = 0; i < annotationsByType.length; i++) {
            FrequencyControl frequencyControl = annotationsByType[i];
            String prefix = StrUtil.isBlank(frequencyControl.prefixKey()) ? SpElUtils.getMethodKey(method) + ":index:" + i : frequencyControl.prefixKey();//默认方法限定名+注解排名(可能多个)
            String key = "";
            switch (frequencyControl.target()) {
                case EL:
                    key = SpElUtils.parseSpEl(method, joinPoint.getArgs(), frequencyControl.spEl());
                    break;
                case IP:
                    //从上下午获取ip,自行处理业务逻辑
                    key = RequestHolder.get().getIp();
                    break;
                case UID:
                     //从上下午获取uid,自行处理业务逻辑
                    key = RequestHolder.get().getUid().toString();
            }
            keyMap.put(prefix + ":" + key, frequencyControl);
        }
        // 将注解的参数转换为编程式调用需要的参数
        List<FrequencyControlDTO> frequencyControlDTOS = keyMap.entrySet().stream().map(entrySet -> buildFrequencyControlDTO(entrySet.getKey(), entrySet.getValue())).collect(Collectors.toList());
        // 调用编程式注解
        return FrequencyControlUtil.executeWithFrequencyControlList(TOTAL_COUNT_WITH_IN_FIX_TIME_FREQUENCY_CONTROLLER, frequencyControlDTOS, joinPoint::proceed);
    }

    /**
     * 将注解参数转换为编程式调用所需要的参数
     *
     * @param key              频率控制Key
     * @param frequencyControl 注解
     * @return 编程式调用所需要的参数-FrequencyControlDTO
     */
    private FrequencyControlDTO buildFrequencyControlDTO(String key, FrequencyControl frequencyControl) {
        FrequencyControlDTO frequencyControlDTO = new FrequencyControlDTO();
        frequencyControlDTO.setCount(frequencyControl.count());
        frequencyControlDTO.setTime(frequencyControl.time());
        frequencyControlDTO.setUnit(frequencyControl.unit());
        frequencyControlDTO.setKey(key);
        return frequencyControlDTO;
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

根据不同的频控需求对象,我们需要组装出独特的key来标识每一次的频控操作。通常情况下,这些key的前缀会默认采用类名和方法名的组合,以确保其唯一性。然而,考虑到可能存在多个相同的频控注解,我们还需要为每个频控对象添加一个专属的下标,以防止key的重复和冲突。因此,在添加新的频控策略注解时,应确保其位于最下方,以便正确地为其分配下标。

在实现Redis频控时,我们面临多种选择:固定时间、滑动窗口、露桶、令牌桶。考虑到实现的复杂度和效率,我们选择了最简单的固定时间方式。这种方式的基本思路是在指定的时间窗口内统计请求的次数,一旦超过设定的阈值,便触发限流操作。通过利用Redis的expire命令,我们可以轻松地实现指定时间的过期机制,从而在过期时自动重置计数器。

当然,我们的思路并不局限于当前的实现方式。未来,我们可以考虑扩展底层实现策略,为频控注解添加一个参数,以支持不同策略的配置。这将使我们的频控机制更加灵活和强大。

最后,需要特别注意的是,在增加频控对象的次数时,我们必须确保操作的原子性。具体来说,当key不存在时,我们需要设置过期时间;而一旦key存在,就不能再次设置过期时间。由于增加次数和设置过期时间是两个独立的操作,它们在并发环境下可能不是原子的。因此,我们需要编写一个Lua脚本,将这两个操作封装在一起,以确保它们的原子执行。这样,无论并发请求如何频繁,我们都能准确地控制频控的逻辑,确保系统的稳定性和安全性。
在这里插入图片描述

spel表达式

SpEL(Spring Expression Language),即Spring表达式语言,能在运行时构建复杂表达式、存取对象属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。

实现原理

  1. 创建解析器:SpEL使用ExpressionParser接口表示解析器,提供SpelExpressionParser默认实现
  2. 解析表达式:使用ExpressionParser的parseExpression来解析相应的表达式为Expression对象
  3. 构造上下文:准备比如变量定义等等表达式需要的上下文数据。
  4. 求值:通过Expression接口的getValue方法根据上下文(EvaluationContext,RootObject)获得表达式值。

最小例子

一个最简单的使用el表达式的例子

public static void main(String[] args) {
   
    List<Integer> primes = new ArrayList<Integer>();
	primes.addAll(Arrays.asList(2,3,5,7,11,13,17));

     // 创建解析器
    ExpressionParser parser = new SpelExpressionParser();
    //构造上下文
    StandardEvaluationContext context = new StandardEvaluationContext();
    context.setVariable("primes",primes);

    //解析表达式
    Expression exp =parser.parseExpression("#primes.?[#this>5]");
    // 求值
    List<Integer> primesGreaterThanTen = (List<Integer>)exp.getValue(context);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

深入思考一下,我们之所以能够通过EL表达式获取到方法入参的值,这背后其实有赖于Spring框架的强大支持。在Spring中,当方法被调用时,框架会智能地将所有的入参信息放入当前的上下文中。这意味着,在方法执行的过程中,我们可以随时通过EL表达式来访问这些入参的值,从而实现了参数的动态获取和使用。

然而,值得注意的是,如果我们仅仅依赖JDK的反射机制来获取方法参数,那么得到的将是一系列没有具体参数名的占位符,如arg0、arg1等。这样的参数信息对于我们的实际需求来说显然是不够的。要想真正获取到参数的名字,我们还需要借助Spring的参数解析器,比如DefaultParameterNameDiscoverer。这个解析器能够分析方法的字节码,从而准确地提取出每个参数的名字。有了这些参数名,我们就可以在EL表达式中更加精确地引用它们,进而实现更灵活、更强大的功能。

spel工具类

由于我们的两个注解都需要el解析,也都需要类名+方法名作为前缀,把通用的逻辑抽成一个工具类。

public class SpElUtils {
    private static final ExpressionParser parser = new SpelExpressionParser();
    private static final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

    public static String parseSpEl(Method method, Object[] args, String spEl) {
        String[] params = parameterNameDiscoverer.getParameterNames(method);//解析参数名
        EvaluationContext context = new StandardEvaluationContext();//el解析需要的上下文对象
        for (int i = 0; i < params.length; i++) {
            context.setVariable(params[i], args[i]);//所有参数都作为原材料扔进去
        }
        Expression expression = parser.parseExpression(spEl);
        return expression.getValue(context, String.class);
    }

    public static String getMethodKey(Method method){
        return method.getDeclaringClass()+"#"+method.getName();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

SpringBoot 接口访问频率限制(一)

声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号