在数字化业务领域,数据准确性与一致性面临重大挑战。特别是表单数据在短时间内频繁重复提交,这一问题尤为突出,有时甚至会导致数据混乱。那么,如何有效解决这一问题?以下将为大家提供一些实用方法。
了解重复提交的危害
在业务操作中,编辑表单数据尚可应对,可一旦涉及新增数据ajax同步方式可以防重复提交吗,短时间内频繁提交就会导致混乱。以电商平台用户注册为例,重复提交可能导致服务器端出现多个账号信息冗余。从技术层面来看,大量重复数据会导致数据管理和数据分析等多个环节出现问题。此外,用户体验也会受到影响,因为一条数据存在多个冗余记录,可能导致数据混乱,进而影响用户查询订单状态等功能的正常使用。
客户端限制操作的方法
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;
}
我们常常会考虑在客户端设置限制,比如对提交按钮进行一些操作。具体来说,就是在点击请求未得到反馈时,将提交按钮设置为不能重复点击。然而,这种方法有缺陷。比如,开发者在调试接口时,可能会用Postman或Curl这样的工具来测试,这时候客户端的限制就失去了作用,仍然可能发生重复提交的问题。
服务端加锁接口串行化
服务端存在一种做法,就是通过加锁来让接口实现串行化,以此避免重复提交。这种方式能够有效地识别重复提交的数据。曾有一个项目采用了这种方法,但后来发现,虽然它能防止数据问题,但由于所有请求都必须排队依次处理,即使是少量并发请求也会导致接口的吞吐量降低。因此,在使用时必须合理控制锁的粒度。否则,整个系统的性能可能会大幅下降。特别是对于一些流量较大的新闻资讯平台,如果直接这样应用,可能会让用户面临加载缓慢等不良体验。
基于AOP实现防重复提交校验思路
AOP实现防重复提交校验的方法较为实用。我们可以通过拼接请求参数的指定方法,将其作为key存储到Redis中ajax同步方式可以防重复提交吗,并设置相应的过期时间。在请求接口时,我们会在Redis中根据这个key进行查找。如果找到,说明在短时间内已经存在相同请求参数的请求,这就意味着本次请求为重复提交。随后,系统将抛出错误信息,终止本次请求。这种做法在众多金融系统中得到应用,例如用户在进行转账操作时,就会利用这种重复提交校验来防止同一笔转账被多次请求,从而避免资金问题。
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 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();
//根据条件判断是否需要进行防重复提交检查
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());
}
}
灵活控制校验的粒度
有几个参数能够调整锁的精细程度。比如,argExpressions可以将特定参数纳入防重复校验key的构成。以在线办公为例,若某部门提交文档,部门名称可以成为该参数的一部分。withUserInfoInKey参数用于决定是否将操作者信息包含在key中。以在线学习平台学生提交作业为例,将学生信息加入key中,可以实现精确验证。另外,onlyInCurrentSession参数在withUserInfoInKey设置为false时,允许在session级别对重复数据进行提交校验。
注解进行校验操作
实际上,我们可以在需要执行防重复提交校验的方法上添加一个名为ResubmitCheck的注解,并设定相关参数以完成校验任务。在这个过程中,使用注解属性argExpressions和conditionExpressions来指定SpringEL表达式,操作起来非常便捷。以物流订单处理系统为例,我们可以设置conditionExpressions为”[0].address!=null”,以此来实现更为灵活和精确的控制。当这个表达式的求值结果为false时,系统将不会执行防重复提交校验。反之,若不包含这一条件,那么在使用Postman进行自测时,一秒内多次发送请求就会触发“请不要重复提交数据”的错误提示。
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 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 泛型
* @return 值
*/
@Nullable
public static T getExpressionValue(@Nullable Object root, @Nullable String expressionString, @NonNull Class clazz) {
if (root == null) {
return null;
}
Expression expression = getExpression(expressionString);
if (expression == null) {
return null;
}
return expression.getValue(root, clazz);
}
@Nullable
public static 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 泛型 这里的泛型要慎用,大多数情况下要使用Object接收避免出现转换异常
* @return 结果集
*/
public static 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 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();
}
}
暂无评论内容