赞
踩
前言
在SpringBoot 接口访问频率限制(一)中,我们已经通过编写代码实现了对接口访问频率的有效控制。然而,若要在不修改原有代码和逻辑的前提下,为源代码补充额外的信息或功能,注解(Annotation)便是一个理想的选择。通过注解,我们可以在不侵入原有代码的情况下,为接口或方法添加频率限制功能的增强。
定义一个多策略的容器注解
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface FrequencyControlContainer {
FrequencyControl[] value();
}
定义关键频控策略注解@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 } }
关键就在于@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; } }
根据不同的频控需求对象,我们需要组装出独特的key来标识每一次的频控操作。通常情况下,这些key的前缀会默认采用类名和方法名的组合,以确保其唯一性。然而,考虑到可能存在多个相同的频控注解,我们还需要为每个频控对象添加一个专属的下标,以防止key的重复和冲突。因此,在添加新的频控策略注解时,应确保其位于最下方,以便正确地为其分配下标。
在实现Redis频控时,我们面临多种选择:固定时间、滑动窗口、露桶、令牌桶。考虑到实现的复杂度和效率,我们选择了最简单的固定时间方式。这种方式的基本思路是在指定的时间窗口内统计请求的次数,一旦超过设定的阈值,便触发限流操作。通过利用Redis的expire命令,我们可以轻松地实现指定时间的过期机制,从而在过期时自动重置计数器。
当然,我们的思路并不局限于当前的实现方式。未来,我们可以考虑扩展底层实现策略,为频控注解添加一个参数,以支持不同策略的配置。这将使我们的频控机制更加灵活和强大。
最后,需要特别注意的是,在增加频控对象的次数时,我们必须确保操作的原子性。具体来说,当key不存在时,我们需要设置过期时间;而一旦key存在,就不能再次设置过期时间。由于增加次数和设置过期时间是两个独立的操作,它们在并发环境下可能不是原子的。因此,我们需要编写一个Lua脚本,将这两个操作封装在一起,以确保它们的原子执行。这样,无论并发请求如何频繁,我们都能准确地控制频控的逻辑,确保系统的稳定性和安全性。
SpEL(Spring Expression Language),即Spring表达式语言,能在运行时构建复杂表达式、存取对象属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。
一个最简单的使用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); }
深入思考一下,我们之所以能够通过EL表达式获取到方法入参的值,这背后其实有赖于Spring框架的强大支持。在Spring中,当方法被调用时,框架会智能地将所有的入参信息放入当前的上下文中。这意味着,在方法执行的过程中,我们可以随时通过EL表达式来访问这些入参的值,从而实现了参数的动态获取和使用。
然而,值得注意的是,如果我们仅仅依赖JDK的反射机制来获取方法参数,那么得到的将是一系列没有具体参数名的占位符,如arg0、arg1等。这样的参数信息对于我们的实际需求来说显然是不够的。要想真正获取到参数的名字,我们还需要借助Spring的参数解析器,比如DefaultParameterNameDiscoverer。这个解析器能够分析方法的字节码,从而准确地提取出每个参数的名字。有了这些参数名,我们就可以在EL表达式中更加精确地引用它们,进而实现更灵活、更强大的功能。
由于我们的两个注解都需要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(); } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。