摘要:客户端在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,可以得到如下重复提交拦截效果:
{"code":429,"message":"请勿重复请求","data":null}
控制台打印的日志为:
请勿重复请求,uri =【/wiener/user/getUserObjById】
小结
同一客户端在5秒内请求同一URL,而且请求参数中的第一个参数也相等,则视此次请求为重复提交。这个判断表单是否重复提交的前提是请求参数中的第一个参数通常每次提交都不相等。其实,如果遇到肆意妄为地模仿真实提交场景,每次变更第一个请求参数的情况,当前拦截策略无法成功拦截;关于这个问题,大家有什么好的拦截策略吗?我想到的是在字符串"getUserObjById:arg[0]"后面再加上当前登录客户ID,提高辨识度。
由于缓存 Guava Cache 不支持分布式环境;因此,如果需要支持分布式环境,可以把缓存策略重构为Redis集群。