SpringBoot项目防重复提交
前言
表单提交是web项目的基础功能,用户点击提交/保存按钮后,即会将提交的数据保存到服务端,使服务端对应的数据发生变更。用户在操作时,可能对一份表单数据在短时间内进行多次重复提交,如果是编辑数据这种情况是没有影响的,但是如果是新增数据,如果不加以限制会导致同一份数据同一时间内进入服务端,在服务端生成多条记录,在大多数业务场景下,是不能够允许出现这种现象的。
防重复提交
防重复提交在服务端和客户端都可以做,客户端可以做提交按钮做一下限制,在一次点击请求未响应之前不允许再次点击,但是这种限制只是操作层面的限制,如果采用postman或者curl调用仍然会出现重复提交的情况。服务端防重复提交最简单的方式就是加锁使接口串行化,这样重复提交的数据就能够得到校验,但是接口的吞吐量下载,因此要合理控制锁的粒度。
因此这里提供了一种基于AOP实现的防重复提交校验,实现的基本思路是采用指定的方法入参拼接成key放在Redis中,并指定过期时间,请求接口时通过key在Redis进行查找,如果查找到了数据则表示在短时间内已经发起过包含当前请求参数的请求,本次请求视作是重复提交,抛出重复提交错误信息,本次请求终止;如果在Redis中没有查找到数据,则表示当前请求是首次提交,将请求放行。
注解
package com.cube.share.resubmit.check.aspect;
import com.cube.share.resubmit.check.constants.Constant;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* @author cube.li
* @date 2021/7/9 20:45
* @description 防重复提交注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ResubmitCheck {
/**
* 参数Spring EL表达式例如 #{param.name},表达式的值作为防重复校验key的一部分
*/
String[] argExpressions();
/**
* 重复提交报错信息
*/
String message() default Constant.RESUBMIT_MSG;
/**
* Spring EL表达式,决定是否进行重复提交校验,多个条件之间为且的关系,默认是进行校验
*/
String[] conditionExpressions() default {"true"};
/**
* 是否选用当前操作用户的信息作为防重复提交校验key的一部分
*/
boolean withUserInfoInKey() default true;
/**
* 是否仅在当前session内进行防重复提交校验
*/
boolean onlyInCurrentSession() default false;
/**
* 防重复提交校验的时间间隔
*/
long interval() default 1;
/**
* 防重复提交校验的时间间隔的单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
通过argExpressions
可以将指定参数作为防重复校验key的一部分,可以通过此参数控制锁的粒度;conditionExpressions
表示进行重复提交校验的条件,如果指定了多个表达式,多个表达式之间是&&的关系,只有当所有的条件都满足时才进行重复提交校验;withUserInfoInKey
参数表示是否将当前操作人的信息作为key的一部分,可以通过此参数控制锁的粒度,即使是相同数据的提交,只能对同一个人进行防重复提交限制;onlyInCurrentSession
参数表示是否仅在当前session内进行防重复提交校验,是对withUserInfoInKey
参数的补充,如果withUserInfoInKey
指定为false,可以在session粒度内对重复数据进行提交校验;interval
表示同一份数据防重复提交的时间间隔,也即是key在Redis中存放的时间。
切面
package com.cube.share.resubmit.check.aspect;
import com.cube.share.base.templates.CustomException;
import com.cube.share.base.utils.ExpressionUtils;
import com.cube.share.resubmit.check.constants.Constant;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* @author cube.li
* @date 2021/7/9 22:17
* @description 防重复提交切面
*/
@Component
@Aspect
@Order(-1)
@ConditionalOnProperty(name = "enabled", prefix = "resubmit-check", havingValue = "true", matchIfMissing = true)
public class ResubmitCheckAspect {
private static final String REDIS_SEPARATOR = "::";
private static final String RESUBMIT_CHECK_KEY_PREFIX = "resubmitCheckKey" + REDIS_SEPARATOR;
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private HttpServletRequest request;
@Before("@annotation(annotation)")
public Object resubmitCheck(JoinPoint joinPoint, ResubmitCheck annotation) throws Throwable {
final Object[] args = joinPoint.getArgs();
final String[] conditionExpressions = annotation.conditionExpressions();
//根据条件判断是否需要进行防重复提交检查
if (!ExpressionUtils.getConditionValue(args, conditionExpressions) || ArrayUtils.isEmpty(args)) {
return ((ProceedingJoinPoint) joinPoint).proceed();
}
doCheck(annotation, args);
return ((ProceedingJoinPoint) joinPoint).proceed();
}
/**
* key的组成为: prefix::userInfo::sessionId::uri::method::(根据spring EL表达式对参数进行拼接)
*
* @param annotation 注解
* @param args 方法入参
*/
private void doCheck(@NonNull ResubmitCheck annotation, Object[] args) {
final String[] argExpressions = annotation.argExpressions();
final String message = annotation.message();
final boolean withUserInfoInKey = annotation.withUserInfoInKey();
final boolean onlyInCurrentSession = annotation.onlyInCurrentSession();
String methodDesc = request.getMethod();
String uri = request.getRequestURI();
StringBuilder stringBuilder = new StringBuilder(64);
Object[] argsForKey = ExpressionUtils.getExpressionValue(args, argExpressions);
for (Object obj : argsForKey) {
stringBuilder.append(obj.toString());
}
StringBuilder keyBuilder = new StringBuilder();
//userInfo一般从token中获取,可以使用当前登录的用户id作为标识
keyBuilder.append(RESUBMIT_CHECK_KEY_PREFIX)
//userInfo一般从token中获取,可以使用当前登录的用户id作为标识
.append(withUserInfoInKey ? "userId" + REDIS_SEPARATOR : "")
.append(onlyInCurrentSession ? request.getSession().getId() + REDIS_SEPARATOR : "")
.append(uri)
.append(REDIS_SEPARATOR)
.append(methodDesc).append(REDIS_SEPARATOR)
.append(stringBuilder.toString());
if (redisTemplate.opsForValue().get(keyBuilder.toString()) != null) {
throw new CustomException(StringUtils.isBlank(message) ? Constant.RESUBMIT_MSG : message);
}
//值为空
redisTemplate.opsForValue().set(keyBuilder.toString(), "", annotation.interval(), annotation.timeUnit());
}
}
在需要进行防重复提交校验的方法上加上注解ResubmitCheck
并指定参数,即可对该方法进行防重复提交校验。
注解属性argExpressions
,conditionExpressions
采用Spring EL表达式指定,Spring EL表达式真是个好东西,能够大大增加拼接key的灵活性,精准控制防重复提交校验的粒度。有时间准备再仔细看看Spring EL,下面贴一下我这里解析Spring EL表达式的代码。
package com.cube.share.base.utils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author cube.li
* @date 2021/7/9 21:00
* @description Spring EL表达式工具类
*/
@SuppressWarnings("unused")
public class ExpressionUtils {
private static final Map<String, org.springframework.expression.Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>(64);
/**
* 获取Expression对象
*
* @param expressionString Spring EL 表达式字符串 例如 #{param.id}
* @return Expression
*/
@Nullable
public static Expression getExpression(@Nullable String expressionString) {
if (StringUtils.isBlank(expressionString)) {
return null;
}
if (EXPRESSION_CACHE.containsKey(expressionString)) {
return EXPRESSION_CACHE.get(expressionString);
}
Expression expression = new SpelExpressionParser().parseExpression(expressionString);
EXPRESSION_CACHE.put(expressionString, expression);
return expression;
}
/**
* 根据Spring EL表达式字符串从根对象中求值
*
* @param root 根对象
* @param expressionString Spring EL表达式
* @param clazz 值得类型
* @param <T> 泛型
* @return 值
*/
@Nullable
public static <T> T getExpressionValue(@Nullable Object root, @Nullable String expressionString, @NonNull Class<? extends T> clazz) {
if (root == null) {
return null;
}
Expression expression = getExpression(expressionString);
if (expression == null) {
return null;
}
return expression.getValue(root, clazz);
}
@Nullable
public static <T> T getExpressionValue(@Nullable Object root, @Nullable String expressionString) {
if (root == null) {
return null;
}
Expression expression = getExpression(expressionString);
if (expression == null) {
return null;
}
//noinspection unchecked
return (T) expression.getValue(root);
}
/**
* 求值
*
* @param root 根对象
* @param expressionStrings Spring EL表达式
* @param <T> 泛型 这里的泛型要慎用,大多数情况下要使用Object接收避免出现转换异常
* @return 结果集
*/
public static <T> T[] getExpressionValue(@Nullable Object root, @Nullable String... expressionStrings) {
if (root == null) {
return null;
}
if (ArrayUtils.isEmpty(expressionStrings)) {
return null;
}
IAssert.notNull(expressionStrings, "Expressions cannot be null!");
//noinspection ConstantConditions
Object[] values = new Object[expressionStrings.length];
for (int i = 0; i < expressionStrings.length; i++) {
//noinspection unchecked
values[i] = (T) getExpressionValue(root, expressionStrings[i]);
}
//noinspection unchecked
return (T[]) values;
}
/**
* 表达式条件求值
* 如果为值为null则返回false,
* 如果为布尔类型直接返回,
* 如果为数字类型则判断是否大于0
*
* @param root 根对象
* @param expressionString Spring EL表达式
* @return 值
*/
@Nullable
public static boolean getConditionValue(@Nullable Object root, @Nullable String expressionString) {
Object value = getExpressionValue(root, expressionString);
if (value == null) {
return false;
}
if (value instanceof Boolean) {
return (boolean) value;
}
if (value instanceof Number) {
return ((Number) value).longValue() > 0;
}
return true;
}
/**
* 表达式条件求值
*
* @param root 根对象
* @param expressionStrings Spring EL表达式数组
* @return 值
*/
@Nullable
public static boolean getConditionValue(@Nullable Object root, @Nullable String... expressionStrings) {
if (root == null) {
return false;
}
if (ArrayUtils.isEmpty(expressionStrings)) {
return false;
}
IAssert.notNull(expressionStrings, "Expressions cannot be null!");
//noinspection ConstantConditions
for (String expressionString : expressionStrings) {
if (!getConditionValue(root, expressionString)) {
return false;
}
}
return true;
}
}
测试
package com.cube.share.resubmit.check.controller;
import com.cube.share.base.templates.ApiResult;
import com.cube.share.resubmit.check.aspect.ResubmitCheck;
import com.cube.share.resubmit.check.model.Person;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* @author cube.li
* @date 2021/7/9 23:05
* @description
*/
@RestController
public class ResubmitController {
@PostMapping("/save")
@ResubmitCheck(argExpressions = {"[0].id", "[0].name"}, conditionExpressions = "[0].address != null")
public ApiResult save(@RequestBody Person person) {
return ApiResult.success();
}
}
随便写一写吧,这里我指定了conditionExpressions = "[0].address != null"
,对其求值后结果为false,所以这里并不会进行防重复提交校验,如果将该条件去掉,利用postman自测在一秒内发出多次请求会报:请勿重复提交数据!
[示例代码](https://gitee.com/li-cube/share.git)