前言
表單提交是web項(xiàng)目的基礎(chǔ)功能,用戶點(diǎn)擊提交/保存按鈕后别洪,即會(huì)將提交的數(shù)據(jù)保存到服務(wù)端叨恨,使服務(wù)端對(duì)應(yīng)的數(shù)據(jù)發(fā)生變更。用戶在操作時(shí)挖垛,可能對(duì)一份表單數(shù)據(jù)在短時(shí)間內(nèi)進(jìn)行多次重復(fù)提交痒钝,如果是編輯數(shù)據(jù)這種情況是沒有影響的,但是如果是新增數(shù)據(jù)痢毒,如果不加以限制會(huì)導(dǎo)致同一份數(shù)據(jù)同一時(shí)間內(nèi)進(jìn)入服務(wù)端送矩,在服務(wù)端生成多條記錄,在大多數(shù)業(yè)務(wù)場(chǎng)景下哪替,是不能夠允許出現(xiàn)這種現(xiàn)象的栋荸。
防重復(fù)提交
防重復(fù)提交在服務(wù)端和客戶端都可以做,客戶端可以做提交按鈕做一下限制凭舶,在一次點(diǎn)擊請(qǐng)求未響應(yīng)之前不允許再次點(diǎn)擊晌块,但是這種限制只是操作層面的限制,如果采用postman或者curl調(diào)用仍然會(huì)出現(xiàn)重復(fù)提交的情況帅霜。服務(wù)端防重復(fù)提交最簡(jiǎn)單的方式就是加鎖使接口串行化匆背,這樣重復(fù)提交的數(shù)據(jù)就能夠得到校驗(yàn),但是接口的吞吐量下載身冀,因此要合理控制鎖的粒度钝尸。
因此這里提供了一種基于AOP實(shí)現(xiàn)的防重復(fù)提交校驗(yàn),實(shí)現(xiàn)的基本思路是采用指定的方法入?yún)⑵唇映蒶ey放在Redis中闽铐,并指定過期時(shí)間蝶怔,請(qǐng)求接口時(shí)通過key在Redis進(jìn)行查找,如果查找到了數(shù)據(jù)則表示在短時(shí)間內(nèi)已經(jīng)發(fā)起過包含當(dāng)前請(qǐng)求參數(shù)的請(qǐng)求兄墅,本次請(qǐng)求視作是重復(fù)提交,拋出重復(fù)提交錯(cuò)誤信息澳叉,本次請(qǐng)求終止隙咸;如果在Redis中沒有查找到數(shù)據(jù),則表示當(dāng)前請(qǐng)求是首次提交成洗,將請(qǐng)求放行五督。
注解
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 防重復(fù)提交注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ResubmitCheck {
/**
* 參數(shù)Spring EL表達(dá)式例如 #{param.name},表達(dá)式的值作為防重復(fù)校驗(yàn)key的一部分
*/
String[] argExpressions();
/**
* 重復(fù)提交報(bào)錯(cuò)信息
*/
String message() default Constant.RESUBMIT_MSG;
/**
* Spring EL表達(dá)式,決定是否進(jìn)行重復(fù)提交校驗(yàn),多個(gè)條件之間為且的關(guān)系,默認(rèn)是進(jìn)行校驗(yàn)
*/
String[] conditionExpressions() default {"true"};
/**
* 是否選用當(dāng)前操作用戶的信息作為防重復(fù)提交校驗(yàn)key的一部分
*/
boolean withUserInfoInKey() default true;
/**
* 是否僅在當(dāng)前session內(nèi)進(jìn)行防重復(fù)提交校驗(yàn)
*/
boolean onlyInCurrentSession() default false;
/**
* 防重復(fù)提交校驗(yàn)的時(shí)間間隔
*/
long interval() default 1;
/**
* 防重復(fù)提交校驗(yàn)的時(shí)間間隔的單位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
通過argExpressions
可以將指定參數(shù)作為防重復(fù)校驗(yàn)key的一部分,可以通過此參數(shù)控制鎖的粒度瓶殃;conditionExpressions
表示進(jìn)行重復(fù)提交校驗(yàn)的條件充包,如果指定了多個(gè)表達(dá)式,多個(gè)表達(dá)式之間是&&的關(guān)系,只有當(dāng)所有的條件都滿足時(shí)才進(jìn)行重復(fù)提交校驗(yàn)基矮;withUserInfoInKey
參數(shù)表示是否將當(dāng)前操作人的信息作為key的一部分淆储,可以通過此參數(shù)控制鎖的粒度,即使是相同數(shù)據(jù)的提交家浇,只能對(duì)同一個(gè)人進(jìn)行防重復(fù)提交限制本砰;onlyInCurrentSession
參數(shù)表示是否僅在當(dāng)前session內(nèi)進(jìn)行防重復(fù)提交校驗(yàn),是對(duì)withUserInfoInKey
參數(shù)的補(bǔ)充,如果withUserInfoInKey
指定為false钢悲,可以在session粒度內(nèi)對(duì)重復(fù)數(shù)據(jù)進(jìn)行提交校驗(yàn)点额;interval
表示同一份數(shù)據(jù)防重復(fù)提交的時(shí)間間隔,也即是key在Redis中存放的時(shí)間莺琳。
切面
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 防重復(fù)提交切面
*/
@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 void resubmitCheck(JoinPoint joinPoint, ResubmitCheck annotation) throws Throwable {
final Object[] args = joinPoint.getArgs();
final String[] conditionExpressions = annotation.conditionExpressions();
//根據(jù)條件判斷是否需要進(jìn)行防重復(fù)提交檢查
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::(根據(jù)spring EL表達(dá)式對(duì)參數(shù)進(jìn)行拼接)
*
* @param annotation 注解
* @param args 方法入?yún)? */
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中獲取,可以使用當(dāng)前登錄的用戶id作為標(biāo)識(shí)
keyBuilder.append(RESUBMIT_CHECK_KEY_PREFIX)
//userInfo一般從token中獲取,可以使用當(dāng)前登錄的用戶id作為標(biāo)識(shí)
.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());
}
}
在需要進(jìn)行防重復(fù)提交校驗(yàn)的方法上加上注解ResubmitCheck
并指定參數(shù)还棱,即可對(duì)該方法進(jìn)行防重復(fù)提交校驗(yàn)。
注解屬性argExpressions
,conditionExpressions
采用Spring EL表達(dá)式指定惭等,Spring EL表達(dá)式真是個(gè)好東西珍手,能夠大大增加拼接key的靈活性,精準(zhǔn)控制防重復(fù)提交校驗(yàn)的粒度咕缎。有時(shí)間準(zhǔn)備再仔細(xì)看看Spring EL珠十,下面貼一下我這里解析Spring EL表達(dá)式的代碼。
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表達(dá)式工具類
*/
@SuppressWarnings("unused")
public class ExpressionUtils {
private static final Map<String, org.springframework.expression.Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>(64);
/**
* 獲取Expression對(duì)象
*
* @param expressionString Spring EL 表達(dá)式字符串 例如 #{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;
}
/**
* 根據(jù)Spring EL表達(dá)式字符串從根對(duì)象中求值
*
* @param root 根對(duì)象
* @param expressionString Spring EL表達(dá)式
* @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 根對(duì)象
* @param expressionStrings Spring EL表達(dá)式
* @param <T> 泛型 這里的泛型要慎用,大多數(shù)情況下要使用Object接收避免出現(xiàn)轉(zhuǎn)換異常
* @return 結(jié)果集
*/
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;
}
/**
* 表達(dá)式條件求值
* 如果為值為null則返回false,
* 如果為布爾類型直接返回,
* 如果為數(shù)字類型則判斷是否大于0
*
* @param root 根對(duì)象
* @param expressionString Spring EL表達(dá)式
* @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;
}
/**
* 表達(dá)式條件求值
*
* @param root 根對(duì)象
* @param expressionStrings Spring EL表達(dá)式數(shù)組
* @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;
}
}
測(cè)試
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"
焙蹭,對(duì)其求值后結(jié)果為false,所以這里并不會(huì)進(jìn)行防重復(fù)提交校驗(yàn)嫂伞,如果將該條件去掉孔厉,利用postman自測(cè)在一秒內(nèi)發(fā)出多次請(qǐng)求會(huì)報(bào):請(qǐng)勿重復(fù)提交數(shù)據(jù)!
[示例代碼](https://gitee.com/li-cube/share.git)