ruoyi-vue-plus-防重幂&限流 功能
# 防重幂
防重幂 , 又称 防重提交 , 主要用于解决多次提交相同的请求过程中 , 确保仅执行一次的处理 , 从而避免不必要的重复操作
ruoyi-vue-plus应用文档 : https://plus-doc.dromara.org (opens new window)
实现方案 : SpringAOP实现
# 实现过程
请求前处理 :
- 校验 提交最短间隔时间 , 小于1秒报错
- 获取请求参数 , 将请求参数的数据 转化为JSON , 排除 File、HttpServletRequest、HttpServletResponse、BindingResult 对象
- 获取请求Token , 将请求参数JSON和token拼接
- md5加密 . 将拼接好的字符串进行加密为 submitKey , 避免过长的参数
- 最终拼接
RedisKey + uri + submitKey = cacheRepeatKey
- Redis判断cacheRepeatKey .
- 存在则 提取注解中的提示信息报错回复
- 不存在则 Redis存储cacheRepeatKey 并设过期时间 , 线程缓存 cacheRepeatKey (防止请求失败且未到期的key卡缓存)
请求完成后 :
- 校验响应对象R类 , 不是 R对象响应跳过处理
- 根据R对象判断请求是否完成 , 未完成则 从线程缓存中获取 cacheRepeatKey (快速失败) , 完成则 跳过处理
- 最后清除线程缓存 cacheRepeatKey
请求异常时 :
- 清除Redis缓存 . 从线程缓存中获取 cacheRepeatKey 清除Redis缓存
- 清除线程缓存 . 清除自身
# 应用
在Controller层中的方法上 , 标注上 RepeatSubmit注解
# 源码
RepeatSubmit
自定义注解防止表单重复提交
点击展开
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 提示消息 支持国际化 格式为 {code}
*/
String message() default "{repeat.submit.message}";
}
RepatSubmitAspect
防止重复提交AOP处理(参考美团GTIS防重系统)
点击展开
@Aspect
@Component
public class RepeatSubmitAspect {
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
/**
* 防重幂请求前处理
* 记录Key信息 (包含有 token + data + url)
*/
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
// 如果注解不为0 则使用注解数值
long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
if (interval < 1000) {
throw new ServiceException("重复提交间隔时间不能小于'1'秒");
}
HttpServletRequest request = ServletUtils.getRequest();
// JoinPoint切点中获取请求参数
String nowParams = argsArrayToString(point.getArgs());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 唯一值Token(没有消息头则使用请求地址)
String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName()));
// md5 避免过长的data拼接
submitKey = SecureUtil.md5(submitKey + ":" + nowParams);
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
KEY_CACHE.set(cacheRepeatKey);
} else {
// 国际化翻译处理
String message = repeatSubmit.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
}
throw new ServiceException(message);
}
}
/**
* 处理完请求后执行
* 快速失败处理, 成功记录数据, 失败清除数据(确保下次请求可提交)
*/
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
if (jsonResult instanceof R) {
try {
R<?> r = (R<?>) jsonResult;
// 成功则不删除redis数据 保证在有效时间内无法重复提交
if (r.getCode() == R.SUCCESS) {
return;
}
RedisUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
/**
* 拦截异常操作 (快速失败)
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
RedisUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
StringJoiner params = new StringJoiner(" ");
if (ArrayUtil.isEmpty(paramsArray)) {
return params.toString();
}
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
params.add(JsonUtils.toJsonString(o));
}
}
return params.toString();
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.values()) {
return value instanceof MultipartFile;
}
}
// 避免过滤类型 File、HttpServletRequest、HttpServletResponse、BindingResult
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}
# 限流
限流功能 , 能够防止一段时间内频繁请求
多维度控制限流
- 根据 用户IP 控制限流
- 根据 实例 控制限流 (集群环境)
- 根据 请求参数 控制限流 (支持Spel表达式)
实现方案 : SpringAOP实现
# 实现过程
请求前处理 :
- 获取秘钥 *getCombineKey()*方法
- 提取方法参数名称 (通过 ParameterNameDiscoverer 识别)
- 提取方法参数值
- 为Spel表达式 设置上下文对应的参数名值
- 解析Spel表达式 , 判断前后缀分别为
${
、}
, 匹配则填充上下文的值并解析 , 否则直接解析 - 返回拼接的完整值 ,
<限流RedisKey>:<唯一标识>:Spel表达式解析结果值:
- 设置速率类型 (全局限流、单实例限流)
- 获取许可 , 获取该combineKey的剩余次数 RedisUtils.rateLimiter(combineKey, rateType, count, time) (每次执行剩余次数都会-1)
- 判断剩余次数 , -1则限流 国际化处理回复
# 应用
在Controller层中的方法上 , 标注上 RateLimiter注解
示例代码
点击展开
@Slf4j
@RestController
@RequestMapping("/demo/rateLimiter")
public class RedisRateLimiterController {
/**
* 测试全局限流
* 全局影响
*/
@RateLimiter(count = 2, time = 10)
@GetMapping("/test")
public R<String> test(String value) {
return R.ok("操作成功", value);
}
/**
* 测试请求IP限流
* 同一IP请求受影响
*/
@RateLimiter(count = 2, time = 10, limitType = LimitType.IP)
@GetMapping("/testip")
public R<String> testip(String value) {
return R.ok("操作成功", value);
}
/**
* 测试集群实例限流
* 启动两个后端服务互不影响
*/
@RateLimiter(count = 2, time = 10, limitType = LimitType.CLUSTER)
@GetMapping("/testcluster")
public R<String> testcluster(String value) {
return R.ok("操作成功", value);
}
/**
* 测试请求IP限流(key基于参数获取)
* 同一IP请求受影响
*
* 简单变量获取 #变量 复杂表达式 #{#变量 != 1 ? 1 : 0}
*/
@RateLimiter(count = 2, time = 10, limitType = LimitType.IP, key = "#value")
@GetMapping("/testObj")
public R<String> testObj(String value) {
return R.ok("操作成功", value);
}
}
# 源码
RateLimiter
限流注解
点击展开
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* 限流key,支持使用Spring el表达式来动态获取方法上的参数值
* 格式类似于 #code.id #{#code}
*/
String key() default "";
/**
* 限流时间,单位秒
*/
int time() default 60;
/**
* 限流次数
*/
int count() default 100;
/**
* 限流类型
*/
LimitType limitType() default LimitType.DEFAULT;
/**
* 提示消息 支持国际化 格式为 {code}
*/
String message() default "{rate.limiter.message}";
}
RateLimiterAspect
限流AOP处理
点击展开
@Slf4j
@Aspect
@Component
public class RateLimiterAspect {
/**
* 定义spel表达式解析器
*/
private final ExpressionParser parser = new SpelExpressionParser();
/**
* 定义spel解析模版
*/
private final ParserContext parserContext = new TemplateParserContext();
/**
* 定义spel上下文对象进行解析
*/
private final EvaluationContext context = new StandardEvaluationContext();
/**
* 方法参数解析器
*/
private final ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
int time = rateLimiter.time();
int count = rateLimiter.count();
String combineKey = getCombineKey(rateLimiter, point);
try {
RateType rateType = RateType.OVERALL;
if (rateLimiter.limitType() == LimitType.CLUSTER) {
rateType = RateType.PER_CLIENT;
}
long number = RedisUtils.rateLimiter(combineKey, rateType, count, time);
if (number == -1) {
String message = rateLimiter.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
}
throw new ServiceException(message);
}
log.info("限制令牌 => {}, 剩余令牌 => {}, 缓存key => '{}'", count, number, combineKey);
} catch (Exception e) {
if (e instanceof ServiceException) {
throw e;
} else {
throw new RuntimeException("服务器限流异常,请稍候再试");
}
}
}
public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) {
String key = rateLimiter.key();
// 获取方法(通过方法签名来获取)
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
// 判断是否是spel格式
if (StringUtils.containsAny(key, "#")) {
// 获取参数值
Object[] args = point.getArgs();
// 获取方法上参数的名称
String[] parameterNames = pnd.getParameterNames(method);
if (ArrayUtil.isEmpty(parameterNames)) {
throw new ServiceException("限流key解析异常!请联系管理员!");
}
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
// 解析返回给key
try {
Expression expression;
if (StringUtils.startsWith(key, parserContext.getExpressionPrefix())
&& StringUtils.endsWith(key, parserContext.getExpressionSuffix())) {
expression = parser.parseExpression(key, parserContext);
} else {
expression = parser.parseExpression(key);
}
key = expression.getValue(context, String.class) + ":";
} catch (Exception e) {
throw new ServiceException("限流key解析异常!请联系管理员!");
}
}
/**
* 拼接 Redis key
*/
StringBuilder stringBuffer = new StringBuilder(CacheConstants.RATE_LIMIT_KEY);
stringBuffer.append(ServletUtils.getRequest().getRequestURI()).append(":");
if (rateLimiter.limitType() == LimitType.IP) {
// 获取请求ip
stringBuffer.append(ServletUtils.getClientIP()).append(":");
} else if (rateLimiter.limitType() == LimitType.CLUSTER) {
// 获取客户端实例id
stringBuffer.append(RedisUtils.getClient().getId()).append(":");
}
return stringBuffer.append(key).toString();
}
}