1.概述
抹茶项目是一个即时的IM通信项目,并且有着万人大群。但凡有几个人刷屏,那消息爆炸的场景,都不敢想象。如果我们需要对项目特定的接口进行频率控制,不仅是业务上的功能,同样也保护了项目的监控运行。而频控又是个很通用东西,好多地方都要用到,因此可以把它实现为一个小组件,也就是注解的形式使用。
2.效果展示
直接看效果,通过频控注解,很轻松的就实现接口的请求频率控制,防止有人瞎点。
有些接口还需要配置多种频控策略,这种我们可以再加个注解,将多个策略包起来。甚至通过一些配置,还能更简洁。
接下来就看看我们是怎么实现切面逻辑的吧。
3.注解实现
定义一个多策略的容器注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//作用在方法上
public @interface FrequencyControlContainer {FrequencyControl[] value();
}
定义关键频控策略注解@FrequencyControl
关键就在于@Repeatable可重复的配置,这样就可以把相同注解加在一个方法上,猜测这是一个语法糖。
其中频控对象对应的就是 redis 中的一个 key,所以也需要 prefixKey 参数和 el 表达式 spEl 参数。time 和 unit 控制统计的时间范围,count 是次数。提供target是因为我们的频控大多是用在接口上的,并且接口拦截器会解析出用户的 ip 和 uid。而很多的场景是直接对 uid 或者 ip 做频率控制的。针对这种情况,我们指定了 uid 后,连 el 表达式都可以不用写了,切面会自动从上下文中获取 uid,让注解的实现更加简洁。
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;/*** 频控注解*/
@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}
}
4.切面
根据不同的频控对象,组装不同的key。前缀默认也是类名+方法名。由于有多个相同注解,我们还需要给每个频控对象加上一个专属下标,防止重复,所以新增的频控策略注解要加在最下方。
redis实现频控其实有三种选择,固定时间,滑动窗口,令牌桶。我们选择的是最简单的固定时间的方式,在指定时间统计次数,超过就限流。通过expire来实现指定时间,以及过期重置的效果。
思路可以拓展一下,后续增加不同的底层实现策略,并且在注解开个参数开放配置不同的策略
import cn.hutool.core.util.StrUtil;
import com.abin.mallchat.common.common.annotation.FrequencyControl;
import com.abin.mallchat.common.common.domain.dto.FrequencyControlDTO;
import com.abin.mallchat.common.common.service.frequencycontrol.FrequencyControlUtil;
import com.abin.mallchat.common.common.utils.RequestHolder;
import com.abin.mallchat.common.common.utils.SpElUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;import static com.abin.mallchat.common.common.service.frequencycontrol.FrequencyControlStrategyFactory.TOTAL_COUNT_WITH_IN_FIX_TIME_FREQUENCY_CONTROLLER;/*** Description: 频控实现*/
@Slf4j
@Aspect
@Component
public class FrequencyControlAspect {@Around("@annotation(com.abin.mallchat.common.common.annotation.FrequencyControl)||@annotation(com.abin.mallchat.common.common.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:key = RequestHolder.get().getIp();break;case 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;}
}
限流工具类
import com.abin.mallchat.common.common.domain.dto.FrequencyControlDTO;
import com.abin.mallchat.common.common.utils.AssertUtil;
import org.apache.commons.lang3.ObjectUtils;import java.util.List;/*** 限流工具类 提供编程式的限流调用方法*/
public class FrequencyControlUtil {/*** 单限流策略的调用方法-编程式调用** @param strategyName 策略名称* @param frequencyControl 单个频控对象* @param supplier 服务提供着* @return 业务方法执行结果* @throws Throwable*/public static <T, K extends FrequencyControlDTO> T executeWithFrequencyControl(String strategyName, K frequencyControl, AbstractFrequencyControlService.SupplierThrowWithoutParam<T> supplier) throws Throwable {AbstractFrequencyControlService<K> frequencyController = FrequencyControlStrategyFactory.getFrequencyControllerByName(strategyName);return frequencyController.executeWithFrequencyControl(frequencyControl, supplier);}public static <K extends FrequencyControlDTO> void executeWithFrequencyControl(String strategyName, K frequencyControl, AbstractFrequencyControlService.Executor executor) throws Throwable {AbstractFrequencyControlService<K> frequencyController = FrequencyControlStrategyFactory.getFrequencyControllerByName(strategyName);frequencyController.executeWithFrequencyControl(frequencyControl, () -> {executor.execute();return null;});}/*** 多限流策略的编程式调用方法调用方法** @param strategyName 策略名称* @param frequencyControlList 频控列表 包含每一个频率控制的定义以及顺序* @param supplier 函数式入参-代表每个频控方法执行的不同的业务逻辑* @return 业务方法执行的返回值* @throws Throwable 被限流或者限流策略定义错误*/public static <T, K extends FrequencyControlDTO> T executeWithFrequencyControlList(String strategyName, List<K> frequencyControlList, AbstractFrequencyControlService.SupplierThrowWithoutParam<T> supplier) throws Throwable {boolean existsFrequencyControlHasNullKey = frequencyControlList.stream().anyMatch(frequencyControl -> ObjectUtils.isEmpty(frequencyControl.getKey()));AssertUtil.isFalse(existsFrequencyControlHasNullKey, "限流策略的Key字段不允许出现空值");AbstractFrequencyControlService<K> frequencyController = FrequencyControlStrategyFactory.getFrequencyControllerByName(strategyName);return frequencyController.executeWithFrequencyControlList(frequencyControlList, supplier);}/*** 构造器私有*/private FrequencyControlUtil() {}
}
5.SPEL表达式
SpEL(Spring Expression Language),即Spring表达式语言,能在运行时构建复杂表达式、存取对象属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。
使用场景:在spring cache中就经常使用了
@Override
@Cacheable(value = "rbac:roleSet", key = "T(org.apache.commons.lang3.StringUtils).join(#roles,'|')", unless = "#result == null || #result.size() == 0")
public List<String> getRoleIdsByRole(Set<String> roles) {return null;
}
实现原理
-
创建解析器: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>10]");// 求值List<Integer> primesGreaterThanTen = (List<Integer>)exp.getValue(context);
}
思考下,为啥我们能通过 el 表达式拿到方法入参的值
那肯定是spring把入参全部放入上下文中了,对吧!
还有一点,我们jdk反射拿到的参数,是没有参数名的,都是arg0,arg1。真想拿到参数名,还有一点儿难度。所以我们还要借助spring的参数解析器DefaultParameterNameDiscoverer,具体的原理可以看:论java如何通过反射获得方法真实参数名及扩展研究_java_AB教程网。
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();}
}