最近在项目中,有一个用户移动端打卡上班的功能,如果短时间内快速双击几次打卡按钮的话,数据库会生成几条一模一样的打卡记录,很明显这就是重复提交数据了。
表单重复提交的几种情况:
以上这些情况都会导致表单重复提交,造成数据重复,增加服务器负载,严重甚至会造成服务器宕机。因此有效防止表单重复提交有一定的必要性。
一般需要防止表单重复提交的话,可以从前端表单按钮提交事件、限制后端接口调用、数据库唯一约束三个方面同时进行,多维度结合可以有效防止产生重复的垃圾数据。
【a】前端表单按钮提交事件
这种方式是修改前端表单提交按钮事件,可以在点击打卡的时候给一个“loading”加载中的状态或者禁用掉提交按钮,防止用户重复点击按钮导致重复发几次一样的请求。
【b】数据库唯一约束
这种我感觉是最简单粗暴的方式,直接在数据库加一个唯一约束,这样就不可能存在多条一模一样的数据,例如打卡时间精确到"10:30"分,那么我们可以通过建立【打卡类型(上班、下班)、打卡人、打卡分钟数】这三个字段的组合唯一约束,有效防止重复数据产生。
【c】限制后端接口调用
通过后端方式防止表单重复提交,实际上就是防止一个接口短时间不能被同一个客户端重复调用多次,所以这里需要使用【调用的目标方法的全限定名 + 客户端IP作为重复调用的判断标志】。这里我采用了Spring AOP切面来实现,我们都知道AOP可以在不修改现有代码的情况下,动态增强我们代码的功能。
下面是具体的实现细节:
(1)、定义一个自定义注解类:避免表单重复提交
package com.ly.cloud.annoation;
import java.lang.annotation.*;
/**
* @Description: 自定义注解: 避免表单重复提交
* @author: weishihuai
* @Date: 2020/7/3 09:27
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AvoidFormRepeatableCommit {
/**
* 指定时间内不可重复提交,单位毫秒
*/
int timeout() default 2000;
}
(2)、定义获取IP地址的工具类IpUtils.java
package com.ly.cloud.common.utils;
import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* @Description: 获取访问接口的客户端的ip地址
* @author: weishihuai
* @Date: 2020/7/3 09:57
*/
public class IpUtils {
public static String getIpAddress(HttpServletRequest request) {
String ipAddress;
try {
ipAddress = request.getHeader("x-forwarded-for");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
if (ipAddress.equals("127.0.0.1")) {
// 根据网卡取本机配置的IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (UnknownHostException e) {
e.printStackTrace();
}
ipAddress = inet.getHostAddress();
}
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.length() > 15) {
// = 15
if (ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
}
}
} catch (Exception e) {
ipAddress = "";
}
return "0:0:0:0:0:0:0:1".equals(ipAddress) ? "127.0.0.1" : ipAddress;
}
}
(3)、定义防止表单重复提交AOP切面
这里结合Redis的Key过期自动删除的功能,防止接口方法短时间不能被同一个客户端调用多次。
package com.ly.cloud.aop;
import com.ly.cloud.annoation.AvoidFormRepeatableCommit;
import com.ly.cloud.common.utils.IpUtils;
import com.ly.zhxg.exception.NHWarmingException;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @Description: 防止表单重复提交AOP切面
* @author: weishihuai
* @Date: 2020/7/3 09:29
*/
@Component
@Aspect
public class AvoidFormRepeatableCommitAop {
/**
* 日志
*/
private static final Logger logger = LoggerFactory.getLogger(AvoidFormRepeatableCommitAop.class);
/**
* Redis简单操作模板类
*/
@Autowired
private RedisTemplate redisTemplate;
/**
* Pointcut切点: 拦截所有标注AvoidFormRepeatableCommit注解的方法
*/
@Pointcut("@annotation(com.ly.cloud.annoation.AvoidFormRepeatableCommit)")
private void pointCut() {
}
/**
* 调用目标方法前和调用后完成特定的业务
*
* @param point 连接点
* @return
* @throws Throwable
*/
@Around("pointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
//获取HttpServletRequest请求对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
//通过HttpServletRequest对象获取发起请求的客户端IP地址
String ipAddress = IpUtils.getIpAddress(request);
//获取MethodSignature方法签名
MethodSignature signature = (MethodSignature) point.getSignature();
//根据签名获取对应的方法
Method targetMethod = signature.getMethod();
//根据方法名称获取声明类的全限类名
String targetClassName = targetMethod.getDeclaringClass().getName();
//目标方法名称
String methodName = targetMethod.getName();
int hashCode = Math.abs(String.format("%s#%s", targetClassName, methodName).hashCode());
//根据IP地址生成缓存到Redis的Key
String redisKey = String.format("%s_%d", ipAddress, hashCode);
logger.info("请求客户端缓存Key: {}", redisKey);
AvoidFormRepeatableCommit avoidFormRepeatableCommit = targetMethod.getAnnotation(AvoidFormRepeatableCommit.class);
//获取注解AvoidFormRepeatableCommit指定的timeout属性值
int timeout = avoidFormRepeatableCommit.timeout();
if (timeout < 0) {
//如果指定的超时时间小于0,取默认过期时间为2秒钟
timeout = 2000;
}
String value = (String) redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isNotBlank(value)) {
logger.error("请勿重复提交.....");
throw new NHWarmingException("请勿重复提交.....");
}
//保存(客户端请求IP地址->UUID随机字符串)到Redis中
redisTemplate.opsForValue().set(redisKey, UUID.randomUUID().toString(), timeout, TimeUnit.MILLISECONDS);
//执行Controller目标方法
return point.proceed();
}
}
(4)、在Controller控制层方法上面加上自定义注解,标识这个接口需要进行调用限制
@ApiOperation(value = "保存打卡记录", notes = "保存打卡记录", httpMethod = "POST")
@ApiImplicitParams({@ApiImplicitParam(paramType = "body", name = "formData", dataType = "map", required = true, value = "formData")})
@RequestMapping(value = "/saveStudentDkjl", method = RequestMethod.POST)
@AvoidFormRepeatableCommit
public JsonResult saveStudentDkjl(@RequestBody Map formData) {
try {
return JsonResult.success(openAPIService.saveStudentDkjl(formData));
} catch (Exception e) {
logger.error(e.getMessage(), e);
return JsonResult.failure(e.getMessage());
}
}
(5)、测试
我们测试在短时间快速点击表单提交按钮,观察后端服务日志。
后端服务日志:
2020-07-11 16:50:45.905 INFO 31952 --- [nio-6272-exec-8] c.l.c.aop.AvoidFormRepeatableCommitAop : 请求客户端缓存Key: 192.168.6.67_1161762349
2020-07-11 16:50:45.996 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.base.XsGzlDkjlPOMapper.insert : ==> Preparing: INSERT INTO ZHXG_QGZX_XS_GZL_DKJL ( DKJLID,RZGWID,XSID,DKSJ,DKLX,DKDD,SFBK,SHZT,BKLY,SHYJ,BKSQSJ,DKSJFZS ) VALUES ( ?,?,?,?,?,?,?,?,?,?,?,? )
2020-07-11 16:50:45.997 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.base.XsGzlDkjlPOMapper.insert : ==> Parameters: f4f8c369-beeb-41ea-8929-d370c3867bb6(String), 9a342d34-6b86-44b0-bcd7-49a595dead08(String), 51B4EF80E11C45D7AC68DD920DEC7051(String), 2020-07-11 16:50:45.0(Timestamp), 0(String), 天河区广州信息港(科韵路西)(String), 0(String), null, null, null, null, 2020-07-11 16:50(String)
2020-07-11 16:50:46.018 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.base.XsGzlDkjlPOMapper.insert : <== Updates: 1
2020-07-11 16:50:46.019 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.o.O.getRecordOfClockInAndOut : ==> Preparing: select t.dkjlid, t.rzgwid, t.xsid, t.dksj, t.dklx from zhxg_qgzx_xs_gzl_dkjl t where t.xsid = ? and t.rzgwid = ? and to_char(t.dksj, 'yyyy-mm-dd') = ? order by t.dksj
2020-07-11 16:50:46.019 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.o.O.getRecordOfClockInAndOut : ==> Parameters: 51B4EF80E11C45D7AC68DD920DEC7051(String), 9a342d34-6b86-44b0-bcd7-49a595dead08(String), 2020-07-11(String)
2020-07-11 16:50:46.021 INFO 31952 --- [nio-6272-exec-6] org.reflections.Reflections : Reflections took 32 ms to scan 1 urls, producing 12 keys and 342 values
2020-07-11 16:50:46.023 INFO 31952 --- [nio-6272-exec-6] c.l.c.aop.AvoidFormRepeatableCommitAop : 请求客户端缓存Key: 192.168.6.67_1161762349
2020-07-11 16:50:46.024 ERROR 31952 --- [nio-6272-exec-6] c.l.c.aop.AvoidFormRepeatableCommitAop : 请勿重复提交.....
2020-07-11 16:50:46.026 DEBUG 31952 --- [nio-6272-exec-8] c.l.c.m.o.O.getRecordOfClockInAndOut : <== Total: 1
2020-07-11 16:50:46.045 WARN 31952 --- [nio-6272-exec-6] .m.m.a.ExceptionHandlerExceptionResolver : Resolved exception caused by Handler execution: java.lang.reflect.UndeclaredThrowableException
可见,短时间内我这个客户端,不能多次调用该接口,必须等Redis中客户端IP的key过期后,才可以进行提交操作。
实现原理:
以上就是关于处理重复提交,从前端、后端、数据库三个方面一起防止,相信通过这三个方面,能很大程度地防止表单出现重复提交现象,防止数据库出现一大堆脏数据增加数据库压力。以上仅仅是笔者在项目中的处理方式,如果小伙伴们还有更好的方式,可以分享一哈,相互学习才能进步。