Note

最近先更新微服务和web相关。大数据后补

SpringBoot防止表单重复提交。基于拦截器对带注解的请求进行拦截,处理。

后面总结一下为什么要如此使用。

应用场景:

  1. 使用浏览器后退按钮重复之前的操作,导致重复提交表单。重要业务会导致很重大问题,例如最常见的下单场景。下两个单,计算的金额就不一样了。

  2. 我们的程序那么忙也没必要处理重复的HTTP请求。

注意:

  1. 单节点(多节点的不适用)
  2. 前后端分离(前后端不分离的更简单。后面说)

Github

地址:https://github.com/ithuhui/hui-base-java
分支:master
模块:【hui-base-common】
位置:com.hui.base.common.interceptor

Ready

  • maven
  • IDEA
  • SpringBoot 2.0.3

Core-Code

新增注解@AvoidDuplicateFormToken

/**
 * <b><code>AvoidDuplicateSubmit</code></b>
 * <p/>
 * Description: 防止表单重复提交注解
 * <p/>
 * <b>Creation Time:</b> 2018/11/28 19:27.
 *
 * @author Hu weihui
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AvoidDuplicateFormToken {

}

异常处理

/**
 * <b><code>FormTokenException</code></b>
 * <p/>
 * Description:表单提交异常处理
 * <p/>
 * <b>Creation Time:</b> 2018/12/3 15:26.
 *
 * @author Hu weihui
 */
public class FormTokenException extends RuntimeException{
    private static final long serialVersionUID = 512936007428810210L;

    private String errorCode;

    private String errorMsg;

    public FormTokenException(String errorCode,String errorMsg) {
        super(errorMsg);
        this.errorCode = errorCode;
    }


    public FormTokenException(String errorCode,String errorMsg,Throwable cause) {
        super(errorMsg,cause);
        this.errorCode = errorCode;
    }

    public FormTokenException(FormTokenExceptionEnum formTokenExceptionEnum) {
        super(formTokenExceptionEnum.getErrorMsg());
        this.errorCode = formTokenExceptionEnum.getErrorCode();
    }

    public FormTokenException(FormTokenExceptionEnum formTokenExceptionEnum,Throwable cause) {
        super(formTokenExceptionEnum.getErrorMsg(),cause);
        this.errorCode = errorCode;
    }
/**
 * <b><code>FormExceptionEnum</code></b>
 * <p/>
 * Description: 表单提交异常处理枚举类
 * <p/>
 * <b>Creation Time:</b> 2018/11/29 14:15.
 *
 * @author Hu weihui
 */
@Getter
public enum FormTokenExceptionEnum {
    DUPLICATE_SUBMIT("FT-001", ErrorConstant.NETWORK_ERROR, "表单重复提交"),

    ILLEGAL_SUBMIT("FT-002",ErrorConstant.NETWORK_ERROR,"非法提交表单"),

    SERVER_TOKEN_ERROR("FT-003",ErrorConstant.NETWORK_ERROR,"服务端未接收到请求"),

    UNKONW_ERROR("FT-004", ErrorConstant.NETWORK_ERROR, "表单提交未知错误");


    private String errorCode;

    private String errorType;

    private String errorMsg;

    FormTokenExceptionEnum(String errorCode, String errorType, String errorMsg) {
        this.errorCode = errorCode;
        this.errorType = errorType;
        this.errorMsg = errorMsg;
    }
}
/**
 * <b><code>ErrorConstant</code></b>
 * <p/>
 * Description: 异常常量
 * <p/>
 * <b>Creation Time:</b> 2018/12/3 15:28.
 *
 * @author Hu weihui
 */
public class ErrorConstant {
    public static final String SYSTEM_ERROR = "系统异常";

    public static final String UNKNOW_ERROR = "未知异常";

    public static final String NETWORK_ERROR = "网络异常";

    public static final String BUSINESS_ERROR = "业务异常";

    public static final String VALID_ERROR = "参数校验异常";
}

缓存类

/**
 * <b><code>UserCache</code></b>
 * <p/>
 * Description:
 * <p/>
 * <b>Creation Time:</b> 2018/12/3 11:00.
 *
 * @author Hu weihui
 */
public class UserCache {
    /**
     * 表单重复提交cache,有效期2秒.
     *
     * @return the cache
     * @author : Hu weihui
     */
    @Bean
    public Cache<String,String> getUserCache(){
        return CacheBuilder.newBuilder().expireAfterAccess(2L,TimeUnit.SECONDS).build();
    }
}

自定义表单拦截器

  1. 情况一:单节点应用
  2. 情况二:前后端分离==>这个时候前端一般通过请求头传入经过校验的UserToken
  3. 情况三:前后端不分离==>通过userId获取到用户信息保存到session进行校验

下面的情况是前后端分离。

前后端不分离很简单。request.getSession()做后续操作就OK

/**
 * <b><code>DuplicateSubmitInterceptor</code></b>
 * <p/>
 * Description: 表单重复提交拦截器(单节点,前后端分离情况)
 *            前后端分离->前端请求头传入USER_TOKEN
 *            前后端不分离->用户信息保存在Session
 * <p/>
 * <b>Creation Time:</b> 2018/12/3 14:25.
 *
 * @author Hu weihui
 */
@Slf4j
public class DuplicateSubmitInterceptor extends HandlerInterceptorAdapter {

    private static final String USER_TOKEN_KEY = "token";

    @Autowired
    private Cache<String, String> cache;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
 		if (handler instanceof ResourceHttpRequestHandler) {
            return true;
        }
        
        HandlerMethod handlerMethod = (HandlerMethod) handler;

        Method method = handlerMethod.getMethod();

        AvoidDuplicateFormToken annotation = method.getAnnotation(AvoidDuplicateFormToken.class);
		//查看是否有注解
        if (annotation != null) {
            boolean result = !isDuplicateSubmit(request);
            return result;
        }
        return super.preHandle(request, response, handler);
    }


    /**
     * 判断是否重复提交表单.
     *
     * @param request the request
     * @return the boolean
     * @author : Hu weihui
     */
    private boolean isDuplicateSubmit(HttpServletRequest request) {
        try {
            //请求头是否有token,没有则为非法提交
            String userToken = request.getHeader(USER_TOKEN_KEY);

            if (StringUtils.isEmpty(userToken)) {

                throw new FormTokenException(FormTokenExceptionEnum.ILLEGAL_SUBMIT);

            }

            String clientoken = cache.getIfPresent(userToken);
            //查看cache内是否有token,token2秒内清除,有则为重复提交
            if (null != clientoken){
                log.info("表单重复提交:用户token: {},表单token: {}", userToken);
                throw new FormTokenException(FormTokenExceptionEnum.DUPLICATE_SUBMIT);
            }else {
                //没有token则当做首次/二次提交,记录在cache
                cache.put(userToken,UUID.randomUUID().toString());
            }

        } catch (Exception e) {

            log.info("重复提交表单拦截器错误,{}", e.getMessage());

            throw new FormTokenException(FormTokenExceptionEnum.SERVER_TOKEN_ERROR);

        }

        return false;
    }
}

SpringBoot配置拦截器

/**
 * <b><code>WebConfig</code></b>
 * <p/>
 * Description:
 * <p/>
 * <b>Creation Time:</b> 2018/12/3 15:31.
 *
 * @author Hu weihui
 */
public class WebConfig implements WebMvcConfigurer {
	//新增拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new DuplicateSubmitInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**");
    }
}

Controller使用

	@AvoidDuplicateFormToken
    @GetMapping("/test")
    public ResponseEntity<?> test() {
        return null;
    }

总结

注意点

  1. SpringBoot2.x使用的是implements WebMvcConfigurer{}实现拦截器功能

  2. 【DuplicateSubmitInterceptor】

    HandlerMethod handlerMethod = (HandlerMethod) handler;报错

    java.lang.ClassCastException: org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler cannot be cast to org.springframework.web.method.HandlerMethod

    当请求里面还带有其他的类型请求的时候,而且不是你配置的拦截的规则,那么它转换类型的时候就报错了,这里明显就是因为swagger的静态资源匹配请求的问题了。

    这个方法会默认当做处理静态资源,因此需要排除

    .excludePathPatterns("/swagger-resources/", "/webjars/", “/v2/", "/swagger-ui.html/”);

    这里参考了,这个朋友的源码分析,十分感谢:https://yq.aliyun.com/articles/515182

  3. 有的朋友说为什么不能用hashmap来做存储。这里我反问一句什么时候remove呢?我们没法控制,最好的实施方案就是用echache,配置expireTime超时时间。

  4. 这个方案是单节点的。分布式的时候我们可以用redis,弱一点的甚至用database等都可以,重点是记录下来token。

个人建议

  1. 防止表单重复提交要根据业务做。不需要每个系统都有这个功能。最经典场面就是购物车提交订单。实际业务才是我们定制功能和架构的基准
  2. 不管是否使用表单重复提交,我们数据库要进行唯一约束,也是解决一般重复提交的问题。

作者

 作者:HuHui
 转载:欢迎一起讨论web和大数据问题,转载请注明作者和原文链接,感谢
Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐