若依源码:防止重复提交的实现
公众号:emanjusaka的编程栈
by emanjusaka from https://www.emanjusaka.com/archives/ruoyi-repeat-submit 彼岸花开可奈何
本文是若依的源码解读,这是一个系列文章,欢迎关注我的博客或者微信公众号获取后续文章更新。
防止重复提交的作用是避免用户对同一操作(如表单提交、订单创建等)进行多次重复请求,确保数据准确性、避免系统资源浪费和业务逻辑混乱。
自定义注解
/**
* 自定义注解防止表单重复提交
*
* @author ruoyi
*
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit
{
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
public int interval() default 5000;
/**
* 提示消息
*/
public String message() default "不允许重复提交,请稍候再试";
}
若依脚手架自定义了注解来防止重复提交,可以自己设置重复提交的间隔时间和提示信息,默认的间隔时间是 5000ms,默认的提示信息是“不允许重复提交,请稍候再试”。
防止重复提交拦截器
/**
* 防止重复提交拦截器
*
* @author ruoyi
*/
@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor
{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
// 判断handler是否为HandlerMethod的实例
if (handler instanceof HandlerMethod)
{
// 将handler强制转换为HandlerMethod类型
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 获取方法对象
Method method = handlerMethod.getMethod();
// 获取方法上的RepeatSubmit注解HandlerMethod
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
// 判断方法上是否存在RepeatSubmit注解
if (annotation != null)
{
// 判断请求是否为重复提交
if (this.isRepeatSubmit(request, annotation))
{
// 构造AjaxResult对象,表示请求失败
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
// 将AjaxResult对象转换为JSON字符串,并写入响应
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
// 返回false,表示请求被拦截
return false;
}
}
// 如果方法上没有RepeatSubmit注解,或者请求不是重复提交,则返回true,表示请求可以继续处理
return true;
}
else
{
// 如果handler不是HandlerMethod的实例,则直接返回true,表示请求可以继续处理
return true;
}
}
/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
*
* @param request 请求信息
* @param annotation 防重复注解参数
* @return 结果
* @throws Exception
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}
首先判断下handler是否为HandlerMethod的实例,如果是表明是基于@Controller和@RequestMapping注解的方法需要进行防重复提交的拦截,如果不是就直接放行。
接着判断方法上是否有@RepeatSubmit注解,存在注解就验证是否重复提交。是否重复提交交由子类去实现的。
是否重复提交子类实现
/**
* 判断请求url和数据是否和上一次相同,
* 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
*
* @author ruoyi
*/
@Component
public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
{
public final String REPEAT_PARAMS = "repeatParams";
public final String REPEAT_TIME = "repeatTime";
// 令牌自定义标识
@Value("${token.header}")
private String header;
@Autowired
private RedisCache redisCache;
@SuppressWarnings("unchecked")
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation)
{
String nowParams = "";
// 判断请求是否为RepeatedlyRequestWrapper的实例
if (request instanceof RepeatedlyRequestWrapper)
{
// 将request转换为RepeatedlyRequestWrapper类型
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
// 获取请求体内容
nowParams = HttpHelper.getBodyString(repeatedlyRequest);
}
// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams))
{
// 将请求参数转换为JSON字符串
nowParams = JSON.toJSONString(request.getParameterMap());
}
// 创建一个Map用于存储当前请求的参数和时间戳
Map<String, Object> nowDataMap = new HashMap<String, Object>();
// 存储当前请求参数
nowDataMap.put(REPEAT_PARAMS, nowParams);
// 存储当前时间戳
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 唯一值(没有消息头则使用请求地址)
String submitKey = StringUtils.trimToEmpty(request.getHeader(header));
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
// 从redis中获取缓存对象
Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null)
{
// 将缓存对象转换为Map类型
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
// 如果缓存中存在当前url对应的请求参数
if (sessionMap.containsKey(url))
{
// 获取之前的请求参数
Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
// 比较当前请求参数和之前请求参数是否相同,并且比较时间间隔是否在允许的范围内
if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval()))
{
return true;
}
}
}
// 如果不是重复提交,则将当前请求参数存入缓存
Map<String, Object> cacheMap = new HashMap<String, Object>();
// 将当前url和请求参数存入缓存Map中
cacheMap.put(url, nowDataMap);
// 将缓存Map存入redis中,并设置过期时间和时间单位
redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
return false;
}
/**
* 判断参数是否相同
*/
private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap)
{
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}
/**
* 判断两次间隔时间
*/
private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval)
{
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) < interval)
{
return true;
}
return false;
}
}
若依判断是否为重复提交的思路就是从请求体中获取请求的参数和从 redis 缓存中的获取到的旧的缓存参数进行比较,如果参数一样并且间隔时间没有超过设置的时间就认定为重复提交。
redis 中缓存数据的 key 为固定前缀+请求地址+特定请求头(Authorization
,如果存在的话)
这个Authorization
其实就是用户的 token 数据,把这个组合作为缓存的键,也就是某个用户发起的某个请求,然后再去比较其参数是否一样。两次请求参数不一样的话也不认为是重复提交。
最后注意
使用若依的这个防止重复提交的方法在并发的时候是会有问题的,在同时刻进来多个请求可能会出现几种情况:
- 多个请求在从 redis 中获取数据时可能获取都是 null,都被认定为不是重复提交
- 同一个接口之前缓存的参数可能是 a,然后新进来的多个请求参数都是 b,在进行参数比较时因为参数不同,这几个请求都会放行
- 在向 redis 设置缓存数据在并发时同样也可能会有问题,取值的时候新的数据还没有设置,取到了旧值
导致上面这些问题的原因是给 redis 取值、进行参数比较、redis 设值这三个操作不是原子性的。
改进方案
可以考虑setIfAbsent
的方法原子性来解决这个问题。若依的原本方法就是因为操作不是原子性的在并发时会出现问题。
原本key 是方法路径 + header 中的 token,现在增加方法参数并且进行 md5 作为 key。利用setIfAbsent
方法的原子性,如果这个 key 存在会返回 true(其实是 1 或者是累加后的数字),如果不存在会返回 false(其实是 null)。
返回了 true 证明这个方法间隔时间内已经提交过了可以触发重复提交的拦截机制了。