前言
在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表达式
SpEL(Spring Expression Language),即Spring表达式语言,能在运行时构建复杂表达式、存取对象属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。
实现原理
- 创建解析器:SpEL使用ExpressionParser接口表示解析器,提供SpelExpressionParser默认实现
- 解析表达式:使用ExpressionParser的parseExpression来解析相应的表达式为Expression对象
- 构造上下文:准备比如变量定义等等表达式需要的上下文数据。
- 求值:通过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);
}
深入思考一下,我们之所以能够通过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();
}
}