摘要:客户端在5秒内请求同一URL,而且关键请求参数相等,则视此次请求为重复提交,利用自定义注解 、Spring AOP 和 Guava Cache 技术栈在服务器端实现拦截表单重复提交,防止刷单。

前言

  在平时开发中,如果遇到网速比较慢的情况,用户点击提交按钮提交表单后,发现服务器半天都没有响应,那么用户可能会以为是自己没有提交表单,就会再点击提交按钮提交表单。我们在开发中必须防止表单重复提交,尤其涉及金钱的表单,一不留神就会造成不必要的麻烦,如向客户多收款,重复审批申请单等等。本文在Spring Boot项目中,基于面向切面编程技术和自定义注解填这个坑。

导入Maven依赖

       <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>24.0-jre</version>
</dependency>

防止重复提交表单

  Spring AOP 和自定义注解的基本概念可以分别参考《Spring AOP 面向切面编程之AOP是什么》和《Spring注解之自定义注解入门》。创建一个自定义注解类SubmitLock和Guava缓存类UrlCache。UrlCache中设置缓存过期时间为5秒钟,这个时间有点长,目的是利于测试,在生产环境需要具体问题具体分析,譬如,调整为2秒等。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; /**
* @功能描述 防止重复提交标记注解
* @author Wiener
* @date 2021-02
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SubmitLock {
String key() default "";
}
package com.eg.wiener.utils;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import java.util.concurrent.TimeUnit; /**
* @功能描述 内存缓存
*/
@Configuration
public class UrlCache {
@Bean
public Cache<String, String> getCache() {
return CacheBuilder.newBuilder()
// 最大缓存 1000 个,请结合业务需求和内存大小设置
.maximumSize(1000)
// 设置缓存有效期 5 秒钟
.expireAfterWrite(5, TimeUnit.SECONDS)
.build();
}
}

  在类NoRepeatSubmitAop中 submitInterceptor() 方法上添加环绕增强注解@Around(),使其监听所有添加 SubmitLock 注解的API,从而校验这些API是否在5秒内被请求,并且此URL对应的第一个请求参数相同,如果请求数据符合这两个条件则视为重复提交,果断拦截。

package com.eg.wiener.aop;

import com.eg.wiener.config.result.ResultData;
import com.eg.wiener.config.result.ResultStatus;
import com.google.common.cache.Cache;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile; import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method; /**
* 把5秒内对同一URL和第一个请求参数相同的提交视为重复提交
*
* @author Wiener
* @date 2021-02
*/
@Aspect
@Component
public class NoRepeatSubmitAop { private static Logger logger = LoggerFactory.getLogger(NoRepeatSubmitAop.class); @Autowired
private Cache<String, String> CACHES; /**
* 引入切入点,根据sessionId判断是否为重复提交
*
*
* @param pjp
* @param lock
* @return
*/
@Around("execution(* com.eg..*Controller.*(..)) && @annotation(lock)")
public Object submitInterceptor(ProceedingJoinPoint pjp, SubmitLock lock) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// String url = request.getRequestURL().toString();// 请求url
String uri = request.getRequestURI();// 请求uri
// String queryString = request.getQueryString();// get请求的查询参数 MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod(); String targetName = pjp.getTarget().getClass().getName();// 真实类名字
String methodName = pjp.getSignature().getName();// 真实方式
Object[] arguments = pjp.getArgs();// 所有请求参数
Object[] args = new Object[arguments.length]; SubmitLock localLock = method.getAnnotation(SubmitLock.class);
String key = setKey(localLock.key(), pjp.getArgs());
if (!StringUtils.isEmpty(key)) {
if (CACHES.getIfPresent(key) != null) {
logger.error("请勿重复请求,uri =【{}】", uri);
return ResultData.failure(ResultStatus.TOO_MANY_REQUESTS);
}
// 如果是第一次请求,就将 key,即当前对象存入缓存
CACHES.put(key, key);
}
Object result = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable throwable) {
throw new RuntimeException("服务器异常");
} finally {
int order = 0;
for (Object arg : arguments) {
if (arg instanceof ServletRequest || arg instanceof ServletResponse || arg instanceof MultipartFile) {
//ServletRequest不能序列化,从入参里排除,否则报异常:java.lang.IllegalStateException: It is illegal to call this method if the current request is not in asynchronous mode (i.e. isAsyncStarted() returns false)
//ServletResponse亦能序列化 从入参里排除,否则报异常:java.lang.IllegalStateException: getOutputStream() has already been called for this response
continue;
}
args[order] = arg;
order ++;
}
// 使用 AOP 实现统一记录请求方法返回值日志,当然,也可以存入数据库
logger.info("调用Controller方法返回结果,targetName = {}, methodName = {}, args = {}, result = {}",
targetName, methodName, args, result);
}
} /**
* key 的生成策略,如果想灵活可以写成接口与实现类的方式(TODO 后续讲解)
*
* @param keyExpress 表达式
* @param args 参数
* @return 生成的key
*/
private String setKey(String keyExpress, Object[] args) {
if (null != args && args.length > 0) {
keyExpress = keyExpress.replace("arg[0]", args[0].toString());
}
return keyExpress;
}
}

如果是分布式环境,大家可以把缓存策略换成redis,替换掉CACHES即可。测试用例为一个如下所示的API:

    /**
* 示例地址 /wiener/user/getUserObjById?userId=1090330
* @param userId
* @return
* @throws Exception
*/
@SubmitLock(key = "getUserObjById:arg[0]")
@GetMapping(value ="/getUserObjById", produces = "application/json; charset=utf-8")
public Object getUserObjById(Long userId) throws Exception {
User user = new User();
user.setId(userId);
user.setAddress("测试地址是 " + UUID.randomUUID().toString());
logger.info(user.toString());
return user;
}

  在接口上添加 @SubmitLock(key = "userId:arg[0]") 意味着会将 arg[0] 替换成第一个参数的值,生成后的新 key 将被缓存起来,用以判断是否在5秒内被重复请求。在浏览器中迅速请求两次这个API,可以得到如下重复提交拦截效果:

控制台打印的日志为:

请勿重复请求,uri =【/wiener/user/getUserObjById】

小结

  同一客户端在5秒内请求同一URL,而且请求参数中的第一个参数也相等,则视此次请求为重复提交。这个判断表单是否重复提交的前提是请求参数中的第一个参数通常每次提交都不相等。其实,如果遇到肆意妄为地模仿真实提交场景,每次变更第一个请求参数的情况,当前拦截策略无法成功拦截;关于这个问题,大家有什么好的拦截策略吗?我想到的是在字符串"getUserObjById:arg[0]"后面再加上当前登录客户ID,提高辨识度。

  由于缓存 Guava Cache 不支持分布式环境;因此,如果需要支持分布式环境,可以把缓存策略重构为Redis集群。

Reference

Spring AOP 面向切面编程之搞定表单重复提交实战的更多相关文章

  1. 详细解读 Spring AOP 面向切面编程(二)

    本文是<详细解读 Spring AOP 面向切面编程(一)>的续集. 在上篇中,我们从写死代码,到使用代理:从编程式 Spring AOP 到声明式 Spring AOP.一切都朝着简单实 ...

  2. 浅谈Spring AOP 面向切面编程 最通俗易懂的画图理解AOP、AOP通知执行顺序~

    简介 我们都知道,Spring 框架作为后端主流框架之一,最有特点的三部分就是IOC控制反转.依赖注入.以及AOP切面.当然AOP作为一个Spring 的重要组成模块,当然IOC是不依赖于Spring ...

  3. spring boot 学习(七)小工具篇:表单重复提交

    注解 + 拦截器:解决表单重复提交 前言 学习 Spring Boot 中,我想将我在项目中添加几个我在 SpringMVC 框架中常用的工具类(主要都是涉及到 Spring AOP 部分知识).比如 ...

  4. 从源码入手,一文带你读懂Spring AOP面向切面编程

    之前<零基础带你看Spring源码--IOC控制反转>详细讲了Spring容器的初始化和加载的原理,后面<你真的完全了解Java动态代理吗?看这篇就够了>介绍了下JDK的动态代 ...

  5. Spring AOP 面向切面编程相关注解

    Aspect Oriented Programming 面向切面编程   在Spring中使用这些面向切面相关的注解可以结合使用aspectJ,aspectJ是专门搞动态代理技术的,所以比较专业.   ...

  6. spring AOP面向切面编程学习笔记

    一.面向切面编程简介: 在调用某些类的方法时,要在方法执行前或后进行预处理或后处理:预处理或后处理的操作被封装在另一个类中.如图中,UserService类在执行addUser()或updateUse ...

  7. 【Spring系列】Spring AOP面向切面编程

    前言 接上一篇文章,在上午中使用了切面做防重复控制,本文着重介绍切面AOP. 在开发中,有一些功能行为是通用的,比如.日志管理.安全和事务,它们有一个共同点就是分布于应用中的多处,这种功能被称为横切关 ...

  8. Spring AOP面向切面编程详解

    前言 AOP即面向切面编程,是一种编程思想,OOP的延续.在程序开发中主要用来解决一些系统层面上的问题,比如日志,事务,权限等等.在阅读本文前希望您已经对Spring有一定的了解 注:在能对代码进行添 ...

  9. Spring AOP 面向切面编程入门

    什么是AOP AOP(Aspect Oriented Programming),即面向切面编程.众所周知,OOP(面向对象编程)通过的是继承.封装和多态等概念来建立一种对象层次结构,用于模拟公共行为的 ...

  10. 详细解读 Spring AOP 面向切面编程(一)

    又是一个周末, 今天我要和大家分享的是 AOP(Aspect-Oriented Programming)这个东西,名字与 OOP 仅差一个字母,其实它是对 OOP 编程方式的一种补充,并非是取而代之. ...

随机推荐

  1. 实测windows系统使用cmd搜索包含某个关键词的代码及PHP自动生成数据字典

    1.windows系统下进入cmd窗口-->替换自己的路径,执行下面命令 findstr /s /i "carlist" D:\phpstudy_pro\WWW\wxx\*. ...

  2. php获取详细访客信息,获取访客IP,IP归属地,访问时间,操作系统,浏览器,移动端/PC端,环境语言,访问URL等信息

    问题描述:需要获取访客访问网站信息 1.代码示例与说明: <?php header("Content-Type: text/html; charset=utf-8");    ...

  3. PyCharm一直indexing,且永不停止。

  4. glib-2.60在win64,msys2下编译

    前阵子,工作原因,需要在win7 64下的msys2来编译glib,下面是一些踩过的坑: 事先声明一下,这些个解决方式及纯粹是为了编译通过,可能有些做法不太适合一些需要正常使用的场合,烦请各位注意下. ...

  5. .NET周刊【3月第1期 2025-03-02】

    国内文章 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章 https://www.cnblogs.com/shanyou/p/18737657 2025年2月25日,.NET ...

  6. Python 潮流周刊#93:为什么“if not list”比len()快2倍?(摘要)

    本周刊由 Python猫 出品,精心筛选国内外的 250+ 信息源,为你挑选最值得分享的文章.教程.开源项目.软件工具.播客和视频.热门话题等内容.愿景:帮助所有读者精进 Python 技术,并增长职 ...

  7. composer remove 卸载依赖

    remove 命令 remove 命令用于移除一个包及其依赖(在依赖没有被其他包使用的情况下),如果依赖被其他包使用,则无法移除: $ composer remove monolog/monolog ...

  8. SpringBoot集成亚马逊的S3对象存储

    依赖导入:aws-java-sdk-s3 <dependencyManagement> <dependencies> <dependency> <groupI ...

  9. RANSAC---从直线拟合到特征匹配去噪

    Ransac全称为Random Sample Consensus,随机一致性采样.该方法是一种十分高效的数据拟合方法.我们通过最简单的拟合直线任务来了解这种方法思路,继而扩展到特征点匹配中的误点剔除问 ...

  10. 二分查找--java进阶day06

    1.二分查找 https://kdocs.cn/l/ciMkwngvaWfz?linkname=150996908 二分查找:每一次查找都从中间的元素查起,根据比较的大小来折半,以此类推,直到最后找到 ...