使用AOP防止表单重复提交

一、简介

最近在项目中,有一个用户移动端打卡上班的功能,如果短时间内快速双击几次打卡按钮的话,数据库会生成几条一模一样的打卡记录,很明显这就是重复提交数据了。

表单重复提交的几种情况:

  1. 由于用户误操作,多次点击表单提交按钮;
  2. 由于网速等原因造成页面卡顿,用户重复刷新提交页面;
  3. 黑客或恶意用户使用postman等工具重复恶意提交表单(攻击网站);

以上这些情况都会导致表单重复提交,造成数据重复,增加服务器负载,严重甚至会造成服务器宕机。因此有效防止表单重复提交有一定的必要性。

二、解决方案

一般需要防止表单重复提交的话,可以从前端表单按钮提交事件、限制后端接口调用、数据库唯一约束三个方面同时进行,多维度结合可以有效防止产生重复的垃圾数据。

【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过期后,才可以进行提交操作。

实现原理:

  1. 自定义防止重复提交标记;
  2. 对需要防止重复提交的Congtroller里的mapping方法加上该注解;
  3. 新增Aspect切入点;
  4. 每次提交表单时,Aspect都会保存当前key到reids(须设置过期时间);
  5. 重复提交时Aspect会判断当前redis是否有该key,若有则拦截;

三、总结

以上就是关于处理重复提交,从前端、后端、数据库三个方面一起防止,相信通过这三个方面,能很大程度地防止表单出现重复提交现象,防止数据库出现一大堆脏数据增加数据库压力。以上仅仅是笔者在项目中的处理方式,如果小伙伴们还有更好的方式,可以分享一哈,相互学习才能进步。

你可能感兴趣的:(Java后端)