正如本文标题所言,今天我们来聊一聊在Java应用系统中如何防止接口重复提交;简单地讲,这其实就是“重复提交”的话题,本文将从以下几个部分展开介绍:

1.“重复提交”简介与造成的后果

2.“防止接口重复提交”的实现思路

3.“防止接口重复提交”的代码实战

1、“重复提交”简介与造成的后果

对于“重复提交”,想必各位小伙伴都知晓它的意思,简单的理解,它指的是前端用户在间隔很短的时间周期内对同一个请求URL发起请求,导致前端开发者在很短的时间周期内将同一份数据(请求体)提交到后端相同的接口 多次,最终数据库出现多条主键ID不一样而其他业务数据几乎一毛一样的记录;

仔细研究上述整个过程,会发现如果发起的多次请求的时间间隔足够短,即时间趋向于无穷小 时,其过程可以归为“多线程并发导致并发安全”的问题范畴;而对于“并发安全”的话题,debug早在此前自己录制的课程以及之前的文章中介绍过多次了,在此不再赘述;

上述在对“重复提交”的介绍中隐约也提及它所带来的的后果:

(1)数据库DB出现多条一毛一样的数据记录;

(2)如果重复发起的请求足够多、请求体容量足够大,很可能会给系统接口带来极大的压力,导致其出现“接口不稳定”、“DB负载过高”,严重点甚至可能会出现“系统宕机”的情况;

因此,我们需要在一些很可能会出现“重复提交”的后端接口中加入一些处理机制(附注:前端其实也需要配合一同处理的,其处理方式在本文就不做介绍了~);

2、“防止接口重复提交”的实现思路

值得一提的是,绝大部分情况下,只有POST/PUT/DELETE的请求方式才会出现“重复提交”的情况,而对于GET请求方式,只要不是出现人为的意外情况,那么它就具有“幂等性”,谈不上“重复提交”现象的出现,因此,在实际项目中,出现“重复提交”现象比较多的一般是POST请求方式;

而在实际项目开发中,“防止接口重复提交”的实现方式有两类,一类是纯粹的针对请求链接URL的,即防止对同一个URL发起多次请求:此种方式明显粒度过大,容易误伤友军;另一类是针对请求链接URL + 请求体 的,这种方式可以说是比较人性化而且也是比较合理的,而我们在后面要介绍的实现方式正是基于此进行实战的;

为了便于小伙伴理解,接下来我们以“用户在前端提交注册信息”为例,介绍“如何防止接口重复提交”的实现思路,如下图所示为整体的实现思路:




java 防重放 锁 java防止用户重复提交_缓存


从该图中可以得知,如果当前提交的请求URL已经存在于缓存中,且 当前提交的请求体 跟 缓存中该URL对应的请求体一毛一样 且 当前请求URL的时间戳跟上次相同请求URL的时间戳 间隔在8s 内,即代表当前请求属于 “重复提交”;如果这其中有一个条件不成立,则意味着当前请求很有可能是第一次请求,或者已经过了8s时间间隔的 第N次请求了,不属于“重复提交”了。

3、“防止接口重复提交”的代码实战

照着这个思路,接下来我们将采用实际的代码进行实战,其中涉及到的技术:Spring Boot2.0 + 自定义注解 + 拦截器 + 本地缓存(也可以分布式缓存);

(1)首先,需要自定义一个用于加在需要“防止重复提交”的请求方法上 的注解RepeatSubmit,该注解的定义代码很简单,就是一个常规的注解定义,如下代码所示:

@Inherited@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RepeatSubmit {}

之后,是直接创建一个新的控制器SubmitController,并在其中创建一请求方法,用于处理前端用户提交的注册信息 请求,如下代码所示:

@RestController@RequestMapping("submit")public class SubmitController extends BaseController{    //用户注册    @RepeatSubmit    @PostMapping("register")    public BaseResponse register(@RequestBody RegisterDto dto) throws Exception{        BaseResponse response=new BaseResponse(StatusCode.Success);        //log.info("用户注册,提交上来的请求信息为:{}",dto);        //将用户信息插入到db        response.setData(dto);        return response;    }}

其中,RegisterDto 为自定义的实体类,代码定义如下所示:

@Datapublic class RegisterDto implements Serializable{    private String userName;    private String nickName;    private Integer age;}

(2)将注解加上去之后,接下来需要自定义一个拦截器RepeatSubmitInterceptor,用于拦截并获取 加了上述这个注解的所有请求方法的相关信息,包括其请求URL和请求体数据,其核心代码如下所示:

@Componentpublic abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter{    //开始拦截    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        if (handler instanceof HandlerMethod){            HandlerMethod handlerMethod= (HandlerMethod) handler;            Method method=handlerMethod.getMethod();            RepeatSubmit submitAnnotation=method.getAnnotation(RepeatSubmit.class);            if (submitAnnotation!=null){                //如果是重复提交,则进行拦截,拒绝请求                if (this.isRepeatSubmit(request)){                    BaseResponse subResponse=new BaseResponse(StatusCode.CanNotRepeatSubmit);                    CommonUtil.renderString(response,new Gson().toJson(subResponse));                    return false;                }            }            return true;        }else{            return super.preHandle(request, response, handler);        }    }    //自定义方法逻辑-判定是否重复提交    public abstract boolean isRepeatSubmit(HttpServletRequest request);}

在这里我们将其定义为抽象类,并自定义一个抽象方法:“判断当前请求是否为重复提交isRepeatSubmit()”,之所以这样做,是因为“判断是否重复提交”可以有多种实现方式,而每种实现方式可以通过继承该抽象类 并 实现该抽象方法 从而将其区分开来,某种程度降低了耦合性(面向接口/抽象类编程);如下代码所示为该抽象类的其中一种实现方式:

/** * 判断是否重复提交,整体的思路: * 获取当前请求的URL作为键Key,暂且标记为:A1,其取值为映射Map(Map里面的元素由:请求的链接url 和 请求体的数据组成) 暂且标记为V1; * 从缓存中(本地缓存或者分布式缓存)查找Key=A1的值V2,如果V2和V1的值一样,即代表当前请求是重复提交的,拒绝执行后续的请求,否则可以继续往后面执行 * 其中,设定重复提交的请求的间隔有效时间为8秒 * * 注意点:如果在有效时间内,如8秒内,一直发起同个请求url、同个请求体,那么重复提交的有效时间将会自动延长 * @author 修罗debug * @date 2020/10/21 8:12 * @link 微信:debug0868  QQ:1948831260 * @blog fightjava.com */@Componentpublic class SameUrlDataRepeatInterceptor extends RepeatSubmitInterceptor{    private static final String REPEAT_PARAMS = "RepeatParams";    private static final String REPEAT_TIME = "RepeatTime";    //防重提交key    public static final String REPEAT_SUBMIT_KEY = "Repeat_Submit:";    private static final int IntervalTime = 8;    //构建本地缓存,有效时间为8秒钟    private final Cache cache= CacheBuilder.newBuilder().expireAfterWrite(IntervalTime, TimeUnit.SECONDS).build();    //真正实现“是否重复提交的逻辑”    @Override    public boolean isRepeatSubmit(HttpServletRequest request) {        String currParams=HttpHelper.getBodyString(request);        if (StringUtils.isBlank(currParams)){            currParams=new Gson().toJson(request.getParameterMap());        }        //获取请求地址,充当A1        String url=request.getRequestURI();        //充当B1        RepeatSubmitCacheDto currCacheData=new RepeatSubmitCacheDto(currParams,System.currentTimeMillis(),url);        //充当键A1        String cacheRepeatKey=REPEAT_SUBMIT_KEY+url;        String cacheValue=cache.getIfPresent(cacheRepeatKey);        //从缓存中查找A1对应的值,如果存在,说明当前请求不是第一次了.        if (StringUtils.isNotBlank(cacheValue)){            //充当B2            RepeatSubmitCacheDto preCacheData=new Gson().fromJson(cacheValue,RepeatSubmitCacheDto.class);            if (this.compareParams(currCacheData,preCacheData) && this.compareTime(currCacheData,preCacheData)){                return true;            }        }        //否则,就是第一次请求        Map cacheMap = new HashMap<>();        cacheMap.put(url, currCacheData);        cache.put(cacheRepeatKey,new Gson().toJson(currCacheData));        return false;    }    //比较参数    private boolean compareParams(RepeatSubmitCacheDto currCacheData, RepeatSubmitCacheDto preCacheData){        Boolean res=currCacheData.getRequestData().equals(preCacheData.getRequestData());        return res;    }    //判断两次间隔时间    private boolean compareTime(RepeatSubmitCacheDto currCacheData, RepeatSubmitCacheDto preCacheData){        Boolean res=( (currCacheData.getCurrTime() - preCacheData.getCurrTime()) < (IntervalTime * 1000) );        return res;    }}

该代码虽然看起来有点多,但是仔细研读,会发现其实这些代码 就是笔者在上文中贴出的实现流程图 的具体实现,可以说是将理论知识进行真正的落地实现;

在这里再重复赘述一下,其整体的实现思路为:获取当前请求的URL作为键Key,暂且标记为:A1,其取值为映射Map(Map里面的元素由:请求的链接url 、 请求体的数据、和 请求时的时间戳 三部分组成) 暂且标记为V1;从缓存中(本地缓存或者分布式缓存)查找Key=A1的值V2,如果V2和V1里的请求体数据一样 且 两次请求是在8s内,即代表当前请求是重复提交的,系统将拒绝执行后续的业务逻辑;否则可以继续往后面执行 “将用户信息插入到数据库中” 的业务逻辑;

(3)最后,需要将上述自定义的拦截器加入中系统全局配置中,如下所示:

@Componentpublic class CustomWebConfig implements WebMvcConfigurer{    @Autowired    private RepeatSubmitInterceptor submitInterceptor;    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(submitInterceptor);    }}

运行项目,打开Postman,连续多番进行测试,如下几张图所示:


java 防重放 锁 java防止用户重复提交_缓存_02


java 防重放 锁 java防止用户重复提交_java 如何只暴露接口_03


java 防重放 锁 java防止用户重复提交_java 如何只暴露接口_04


至此,我们已经采用实际的代码实战实现了“如何防止接口重复提交”的功能,值得一提的是,上述代码在实现过程中,其核心在于缓存组件的搭建;在“重复提交”这一业务场景中,它需要满足两个条件方可发挥作用:一个是可以用于缓存信息,即具有Key - Value的特性;另一个是可以对存储的数据设置过期时间;

在这里笔者采用的是google开发工具类中的CacheBuilder构建本地缓存组件的,感兴趣的小伙伴可以自行搜索相关资料;然而这种实现方式在集群多实例部署的情况下是有问题的,因为CacheBuilder只适用于单一架构体系,所以如果是多实例集群部署的情况,最好用Redis。