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();
}
}