<返回更多

Java如何防止接口重复提交

2020-10-28    
加入收藏

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

干货实战~Java如何防止接口重复提交

 

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

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

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

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


@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @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 为自定义的实体类,代码定义如下所示:

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

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

@Component
public 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
 */
@Component
public 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<String,String> 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<String, Object> 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)最后,需要将上述自定义的拦截器加入中系统全局配置中,如下所示:

@Component
public class CustomWebConfig implements WebMvcConfigurer{
    @Autowired
    private RepeatSubmitInterceptor submitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(submitInterceptor);
    }
}

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

干货实战~Java如何防止接口重复提交

 


干货实战~Java如何防止接口重复提交

 


干货实战~Java如何防止接口重复提交

 

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

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

(1)文中涉及到的代码已经放在gitee上了,访问链接如下所示,别忘了给个star哦:https://gitee.com/steadyjack/SpringBootTechnologyA。

(2)期间如何有任何问题都可以私信debug。

(3)请继续关注“程序员实战基地”,您的关注和转发 就是 debug勤劳写技术文的动力!!!

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>