@Auto-Annotation自定义注解——防重复提交篇
自定义通用注解连更系列—连载中…
首页介绍:点这里
前言
在实际的开发项目中,一个对外暴露的接口往往会面临很多次请求,表单重复提交在日常 Web 应用中是最常见且带来麻烦最多的一个问题。
有很多的应用场景都会遇到表单重复提交问题,比如由于用户误操作,多次点击表单提交按钮;由于网速等原因造成页面卡顿,用户重复刷新提交页面,甚至会有黑客或恶意用户使用工具重复恶意提交表单来对网站进行攻击,这就造成请求发送多次,引发数据混乱,数据重复等问题,所以说防止表单重复提交在 Web 应用中的重要性是极高的。
本文通过自定义注解的方式对接口进行防重复提交限制,实现方式也很简单,接口统一拦截,redis判断是否在一定时间内重复提交了该接口。
所需依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
<version>1.4.6.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.10</version>
</dependency>
防重复提交注解@RepeatSubmit
自定义@RepeatSubmit
注解,定义间隔时间,则在该时间范围内不允许重复提交相同的接口。定义提示信息,响应错误提示。
/** 防重复提交注解
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
/**
* 提示消息
*/
String message() default "不允许重复提交,请稍后再试";
}
防重复提交请求拦截器
实现HandlerInterceptor
接口重写preHandle
方法进行接口拦截,在此拦截被注解标记的接口,注意该接口为抽象类,子类只需实现其抽象方法即可。
/** 防重复提交请求拦截器
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod){
HandlerMethod handlerMethod = (HandlerMethod) handler;
RepeatSubmit repeatSubmit = handlerMethod.getMethodAnnotation(RepeatSubmit.class);
if (repeatSubmit != null && this.isRepeatSubmit(request,repeatSubmit)){
//重复提交,响应提示
String message = repeatSubmit.message();
ResultResponse.buildResponse(response,message);
return false;
}
}
return true;
}
/**
* 是否重复提交(子类实现具体规则)
* @param request 请求对象
* @param repeatSubmit 注解
* @return 结果集
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request,RepeatSubmit repeatSubmit);
}
注册拦截器
实现WebMvcConfigurer
接口重写addInterceptors
方法将自定义拦截器注册进去,使拦截器生效。
/**
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Configuration
public class InterceptorConfigurer implements WebMvcConfigurer {
@Resource
private UrlMatchRepeatSubmitInterceptor urlMatchRepeatSubmitInterceptor;
@Value("${auto.enable.repeat-submit:false}")
private boolean isEnableRepeatSubmit;
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (isEnableRepeatSubmit) {
registry.addInterceptor(urlMatchRepeatSubmitInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/error");
}
}
}
防重复提交请求拦截器实现类
在此判断是否接口+参数重复提交,这里我们可以利用策略模式实现扩展,是用内存还是redis作为数据存储器.
支持自定义接口请求头唯一标识作为接口标识符。提高接口拦截精确性。
/** 防重复提交请求拦截器实现类
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Slf4j
@Component
public class UrlMatchRepeatSubmitInterceptor extends RepeatSubmitInterceptor {
/**
* 内存类型
*/
@Value("${auto.memory-type:MEMORY}")
private MemoryTypeEnum memoryType;
/**
* 请求头用户唯一标识
*/
@Value(("${auto.memory-flag:sessionId}"))
private String headerFlag;
/**
* 重复提交标识key
*/
private static final String REQUEST_KEY = "repeat_submit:";
@Resource
private List<IMemoryTypeStrategy> memoryTypeStrategyList;
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) {
//获取请求路径uri
String requestUri = request.getRequestURI();
//获取请求体body
String requestParam = getRequestBody(request);
//获取请求参数param
if (ObjectUtils.isEmpty(requestParam)) {
requestParam = JSONUtil.toJsonStr(request.getParameterMap());
}
//获取请求头用户唯一标识
String header = request.getHeader(headerFlag) != null ? request.getHeader(headerFlag):request.getSession().getId();
String requestFinalKey = REQUEST_KEY + header + requestUri;
//校验是否在内存中存在
IMemoryTypeStrategy memoryTypeStrategy = memoryTypeStrategyList.stream()
.filter(x -> memoryType.equals(x.isSupport()))
.findFirst()
.orElseGet(null);
return memoryTypeStrategy.isRepeatSubmit(repeatSubmit, requestFinalKey, requestParam);
}
private String getRequestBody(HttpServletRequest request) {
try (ServletInputStream is = request.getInputStream()) {
StringBuilder sb = new StringBuilder();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
String readStr;
while ((readStr = bufferedReader.readLine()) != null) {
sb.append(readStr);
}
return sb.toString();
} catch (IOException e) {
log.error("读取请求参数异常", e);
}
return null;
}
}
内存策略接口
主要为两个方法,内存类型判断执行哪个实现类,是否重复提交实现重复提交校验逻辑。
/**
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
public interface IMemoryTypeStrategy {
/**
* 是否支持该内存类型
* @return 结果集
*/
MemoryTypeEnum isSupport();
/**
* 是否重复提交
* @param repeatSubmit 注解对象
* @param requestKey key
* @param requestValue value
* @return 结果集
*/
boolean isRepeatSubmit(RepeatSubmit repeatSubmit,String requestKey, String requestValue);
}
内存策略实现类
内存提交策略者
/**
* 内存提交策略者
*
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Component
public class MemorySubmitAction implements IMemoryTypeStrategy {
private static final TimedCache<String, String> TIMED_CACHE = CacheUtil.newTimedCache(10*1000);
@Override
public MemoryTypeEnum isSupport() {
return MemoryTypeEnum.MEMORY;
}
@Override
public boolean isRepeatSubmit(RepeatSubmit repeatSubmit, String requestKey, String requestValue) {
String cacheValue = TIMED_CACHE.get(requestKey);
if (CharSequenceUtil.isBlank(cacheValue)) {
TIMED_CACHE.put(requestKey, requestValue,repeatSubmit.interval());
return false;
}
return cacheValue.equals(requestValue);
}
}
Redis内存提交策略者
/**
* redis内存提交策略者
*
* @Author: 清峰
* @Description: May there be no bug in the world!
*/
@Component
public class RedisSubmitAction implements IMemoryTypeStrategy {
@Resource
private RedisCache redisCache;
@Override
public MemoryTypeEnum isSupport() {
return MemoryTypeEnum.REDIS;
}
@Override
public boolean isRepeatSubmit(RepeatSubmit repeatSubmit, String requestKey, String requestValue) {
String cacheValue = redisCache.getCacheObject(requestKey);
if (CharSequenceUtil.isBlank(cacheValue)){
redisCache.setCacheObject(requestKey,requestValue,repeatSubmit.interval(), TimeUnit.MILLISECONDS);
return false;
}
return cacheValue.equals(requestValue);
}
}
标记防重复提交接口
@RepeatSubmit(interval = 6000,message = "不允许重复提交,请稍后再试")
@PostMapping("saveUser")
private void saveUser(User user){
System.out.println("保存用户信息逻辑...");
}
总结:
至此防重复提交实现完毕,从复盘来看,防重复提交需理解的核心在于接口如何拦截,redis如何对接口参数进行再次校验,以及为方便扩展对设计模式的运用。