面试经验

记得刚毕业的时候,有一次去加入面试。

上来面试官问我:“你们我的项目中是怎么做防反复提交的?”

一开始听到这个问题是蒙圈的,支支吾吾半天没答复进去。

而后面试官间接来一道算法题,脍炙人口地面试失败。

多年过来,尽管很少接触到控台利用,然而近期对于避免反复提交却有了一点本人的心得。

在这里分享给大家,心愿你工作或者面试中遇到相似的问题时,对你有所帮忙。

本文将从以下几个方面开展:

(1)反复提交产生的起因

(2)什么是幂等性

(3)针对反复提交,前后端的解决方案

(4)如果实现一个防反复提交工具

产生起因

因为反复点击或者网络重发

eg:

点击提交按钮两次;

点击刷新按钮;

应用浏览器后退按钮反复之前的操作,导致反复提交表单;

应用浏览器历史记录反复提交表单;

浏览器反复的HTTP申请;

nginx重发等状况;

分布式RPC的try重发等;

次要有 2 个局部:

(1)前端用户操作

(2)网络申请可能存在重试

当然也不排除一些用户的歹意操作。

java 表单防止反复提交

问题

就是同一份信息,反复的提交给服务器。

场景

  1. 点击F5刷新页面: 当用户点击submit将曾经写好的表单数据提交到服务器时,能够在浏览器的url看到地址和参数的变动,但因为网速等问题,用户以后页面并未刷新,或者点击刷新页面,造成表单反复提交。
  2. 反复点击提交按钮: 因为网络问题,未能及时跳转显示内容,局部用户可能会出于心急反复提交提交按钮,造成屡次提交内容到服务器。
  3. 后退后退操作 :有些用户在进行某些工作操作时,可能出于须要或者某种状况,进行后退操作,浏览方才填入的信息,在进行后退和后退的操作可能也会造成表单数据反复提交到服务器。
  4. 应用浏览器历史记录反复拜访: 某些用户可能会出于好奇,利用浏览器的历史记录性能反复拜访提交页面,同样会造成表单反复提交问题。

解决思路

前端

计划一:禁用按钮提交

设置标记位,提交之后禁止按钮。像一些短信验证码的按钮个别都会加一个前端的按钮禁用,毕竟发短信是须要钞票滴~

ps: 以前写前端就用过这种形式。

  • 长处

简略。根本能够避免反复点击提交按钮造成的反复提交问题。

  • 缺点

后退后退操作,或者F5刷新页面等问题并不能失去解决。

最重要的一点,前端的代码只能避免不懂js的用户,如果碰到懂得js的编程人员,那js办法就没用了。

计划二:设置HTTP报头

设置HTTP报头,管制表单缓存,使得所管制的表单不缓存信息,这样用户就无奈通过反复点击按钮去反复提交表单。

<meta http-equiv="Cache-Control" content="no-cache, must-revalidate">

然而这样做也有局限性,用户在提交页面点击刷新也会造成表单的反复提交。

计划三:通过 PRG 设计模式

用来避免F5刷新反复提交表单。

PRG模式通过响应页面Header返回HTTP状态码进行页面跳转代替响应页面跳转过程。

具体过程如下:

客户端用POST办法申请服务器端数据变更,服务器对客户端发来的申请进行解决重定向到另一个后果页面上,客户端所有对页面的显示申请都用get办法告知服务器端,这样做,后退再后退或刷新的行为都收回的是get申请,不会对server产生任何数据更改的影响。

这种办法实现起来绝对比较简单,但此办法也不能避免所有状况。例如用户屡次点击提交按钮;歹意用户避开客户端预防屡次提交伎俩,进行反复提交申请;

上面谈一谈后端的避免反复提交。

后端

幂等性

如果是注册或存入数据库的操作,能够通过在数据库中字段设立惟一标识来解决,这样在进行数据库插入操作时,因为每次插入的数据都雷同,数据库会回绝写入。

这样也防止了向数据库中写入垃圾数据的状况,同时也解决了表单反复提交问题。

然而这种办法在业务逻辑上感觉是说不过去的,原本该有的逻辑,却因为数据库该有的设计暗藏了。

而且这种办法也有肯定的性能局限性,只实用于某系特定的插入操作。

  • 实现形式

这种操作,都须要有一个惟一标识。数据库中做惟一索引束缚,反复插入间接报错。

  • 毛病

有很大的约束性。

个别都是最初的一道防线,当申请走到数据库层的时候,个别曾经耗费了较多的资源。

session 办法

Java 应用Token令牌避免表单反复提交的步骤:

  1. 在服务器端生成一个惟一的随机标识号,专业术语称为Token(令牌),同时在以后用户的Session域中保留这个Token。
  2. 将Token发送到客户端的Form表单中,在Form表单中应用暗藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端。
  3. 在服务器端判断客户端提交上来的Token与服务器端生成的Token是否统一,如果不统一,那就是反复提交了,此时服务器端就能够不解决反复提交的表单。如果雷同则解决表单提交,解决完后革除以后用户的Session域中存储的标识号。

上面的场景将回绝解决用户提交的表单申请

  1. 存储Session域中的Token(令牌)与表单提交的Token(令牌)不同。
  2. 以后用户的Session中不存在Token(令牌)。

这里的 session 依照单机和分布式,能够应用 redis/mysql 等解决分布式的问题。

这种办法算是比拟经典的解决方案,然而须要前后端的配合。

上面来介绍通过加锁的形式,实现纯后盾批改的实现。

为什么要设置一个暗藏域?

这个问题我一开始没想明确,我认为,进入页面的时候设置一个session并且再token设值,增加的时候把这个值删掉。而后这样咱们再按F5的时候就没方法反复提交了(因为这个时候判断session为空)。我感觉这样就ok了,设置hidden域感觉没任何必要。

然而几乎是图样图森破,对于个别用户这样当然是能够的。

然而对于歹意用户呢?如果歹意用户开两个浏览器窗口(同一浏览器的窗口共用一个session)这样窗口1提交完,零碎删掉session,窗口1停留着,他关上第二个窗口进入这个页面,零碎又为他们增加了一个session,这个时候窗口1按下F5,那么间接反复提交!

所以,咱们必须得用hidden暗藏一个uuid的token,并且在后盾比拟它是否与session中的值统一,只有这样能力保障F5是不可能被反复提交的!

间接加锁

为了防止短时间的反复提交,间接应用加锁的形式。

长处:不须要前端配合批改,纯后端。

毛病:无奈像 token 办法,精确的限定为单次。只能限度固定工夫内的操作一次。

集体了解:前端的形式仍然是防小人不防君子。间接通过限度固定工夫内无奈操作,来限度反复提交。

这个工夫不能太长,也不能太短,个别倡议为 10S 左右,依据本人的实在业务调整。

锁也是同样的情理,token 其实也能够了解为一种非凡的锁。

锁同样能够分为单机锁+分布式的锁。

集体了解

前后端联合,前端加重后端的压力,同时晋升用户体验。

后端做最初的把关,防止歹意用户操作,确保数据的正确性。

如何设计防反复提交框架

整体思路

session 形式和加锁的形式,二者实际上是能够对立的。

此处做一个形象:

(1)获取锁

(2)开释锁

session 流程 + 前端

session 的获取 token 让用户本人解决,比方关上页面,放在暗藏域。实际上这是一个开释锁的过程。

当操作的时候,只有 token 信息和后盾统一,才认为是获取到了锁。用完这个锁就始终被锁住了,须要从新获取 token,能力开释锁。

所有的 session 都应该有 token 的生效工夫,防止累计一堆无用的脏数据。

纯后端

  • 获取锁

当申请的时候,间接依据 user_id(或者其余标识)+申请信息(自定义)=惟一的 key

而后把这个 key 存储在 cache 中。

如果是本地 map,能够本人实现 key 的清空。

或者借助 guava 的 key 过期,redis 的主动过期,乃至数据库的过期都能够。

原理是相似的,就是限度肯定工夫内,无奈反复操作。

  • 开释锁

固定工夫后,key 被清空后就开释了锁。

注解定义

只有一个针对锁的获取:

  • acquire
  • tryAcquire

传入信息。

至于锁的开释,则交给实现者本人实现。

属性

  1. 锁的获取策略 = 内存 RAM

内存 ConcurrentHashMP

Guava

Encache

redis

mysql

...

能够基于 session,或者基于锁,

此处实现基于锁。

  1. 锁的过期工夫 = 5min

无论基于什么形式,这个值都须要。

只不过基于 session 的交给实现者解决,此处只是为了对立属性。

基于字节码的实现

测试案例

maven 引入

<dependency>    <group>com.github.houbb</group>    <artifact>resubmit-core</artifact>    <version>0.0.3</version></dependency>

服务类编写

指定 5s 内禁止反复提交。

@Resubmit(ttl = 5)public void queryInfo(final String id) {    System.out.println("query info: " + id);}

测试代码

雷同的参数 5s 内间接提交2次,就会报错。

@Test(expected = ResubmitException.class)public void errorTest() {    UserService service = ResubmitProxy.getProxy(new UserService());    service.queryInfo("1");    service.queryInfo("1");}

外围实现

定义注解

  • Resubmit.java

首先,咱们定义一个注解。

import com.github.houbb.resubmit.api.support.ICache;import com.github.houbb.resubmit.api.support.IKeyGenerator;import com.github.houbb.resubmit.api.support.ITokenGenerator;import java.lang.annotation.*;/** * @author binbin.hou * @since 0.0.1 */@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)@Documentedpublic @interface Resubmit {    /**     * 缓存实现策略     * @return 实现     * @since 0.0.1     */    Class<? extends ICache> cache() default ICache.class;    /**     * key 生成策略     * @return 生成策略     * @since 0.0.1     */    Class<? extends IKeyGenerator> keyGenerator() default IKeyGenerator.class;    /**     * 密匙生成策略     * @return 生成策略     * @since 0.0.1     */    Class<? extends ITokenGenerator> tokenGenerator() default ITokenGenerator.class;    /**     * 存活工夫     *     * 单位:秒     * @return 工夫     * @since 0.0.1     */    int ttl() default 60;}

缓存接口实现

整体流程:

缓存接口,用于寄存对应的申请信息。

每次申请,将 token+method+params 作为惟一的 key 存入,再次申请时判断是否存在。

如果曾经存在,则认为是反复提交。

可自行拓展为基于 redis/mysql 等,解决分布式架构的数据共享问题。

存储信息的清理:

采纳定时工作,每秒钟进行清理。

public class ConcurrentHashMapCache implements ICache {    /**     * 日志信息     * @since 0.0.1     */    private static final Log LOG = LogFactory.getLog(ConcurrentHashMapCache.class);    /**     * 存储信息     * @since 0.0.1     */    private static final ConcurrentHashMap<String, Long> MAP = new ConcurrentHashMap<>();    static {        Executors.newScheduledThreadPool(1)            .scheduleAtFixedRate(new CleanTask(), 1, 1,                    TimeUnit.SECONDS);    }    /**     * 清理工作执行     * @since 0.0.1     */    private static class CleanTask implements Runnable {        @Override        public void run() {            LOG.info("[Cache] 开始清理过期数据");            // 以后工夫固定,不须要思考删除的耗时            // 毕竟最多相差 1s,然而和零碎的时钟交互是比删除耗时多的。            long currentMills = System.currentTimeMillis();            for(Map.Entry<String, Long> entry : MAP.entrySet()) {                long live = entry.getValue();                if(currentMills >= live) {                    final String key = entry.getKey();                    MAP.remove(key);                    LOG.info("[Cache] 移除 key: {}", key);                }            }            LOG.info("[Cache] 完结清理过期数据");        }    }    @Override    public void put(String key, int ttlSeconds) {        if(ttlSeconds <= 0) {            LOG.info("[Cache] ttl is less than 1, just ignore.");            return;        }        long time = System.currentTimeMillis();        long liveTo = time + ttlSeconds * 1000;        LOG.info("[Cache] put into cache, key: {}, live to: {}", key, liveTo);        MAP.putIfAbsent(key, liveTo);    }    @Override    public boolean contains(String key) {        boolean result =  MAP.containsKey(key);        LOG.info("[Cache] contains key: {} result: {}", key, result);        return result;    }}

代理实现

此处以 cglib 代理为例

  • CglibProxy.java
import com.github.houbb.resubmit.api.support.IResubmitProxy;import com.github.houbb.resubmit.core.support.proxy.ResubmitProxy;import net.sf.cglib.proxy.Enhancer;import net.sf.cglib.proxy.MethodInterceptor;import net.sf.cglib.proxy.MethodProxy;import java.lang.reflect.Method;/** * CGLIB 代理类 * @author binbin.hou * date 2019/3/7 * @since 0.0.2 */public class CglibProxy implements MethodInterceptor, IResubmitProxy {    /**     * 被代理的对象     */    private final Object target;    public CglibProxy(Object target) {        this.target = target;    }    @Override    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {        //1. 增加判断        ResubmitProxy.resubmit(method, objects);        //2. 返回后果        return method.invoke(target, objects);    }    @Override    public Object proxy() {        Enhancer enhancer = new Enhancer();        //指标对象类        enhancer.setSuperclass(target.getClass());        enhancer.setCallback(this);        //通过字节码技术创立指标对象类的子类实例作为代理        return enhancer.create();    }}

最外围的办法就是 ResubmitProxy.resubmit(method, objects);

实现如下:

/** * 反复提交验证 * @param method 办法 * @param args 入参 * @since 0.0.1 */public static void resubmit(final Method method,                            final Object[] args) {    if(method.isAnnotationPresent(Resubmit.class)) {        Resubmit resubmit = method.getAnnotation(Resubmit.class);        // 构建入参        ResubmitBs.newInstance()                .cache(resubmit.cache())                .ttl(resubmit.ttl())                .keyGenerator(resubmit.keyGenerator())                .tokenGenerator(resubmit.tokenGenerator())                .method(method)                .params(args)                .resubmit();    }}

这里会依据用户指定的注解配置,进行对应的防反复提交限度。

鉴于篇幅起因,此处不再开展。

残缺的代码,参见开源地址:

https://github.com/houbb/resubmit/tree/master/resubmit-core

spring aop 整合

spring 整合的必要性

spring 作为 java 开发中根本必不可少的框架,为咱们的日常开发提供了很大的便利性。

咱们一起来看一下,当与 spring 整合之后,应用起来会变得如许简略呢?

spring 整合应用

maven 引入

<dependency>    <group>com.github.houbb</group>    <artifact>resubmit-spring</artifact>    <version>0.0.3</version></dependency>

服务类编写

通过注解 @Resubmit 指定咱们避免反复提交的办法。

@Servicepublic class UserService {    @Resubmit(ttl = 5)    public void queryInfo(final String id) {        System.out.println("query info: " + id);    }}

配置

次要指定 spring 的一些扫包配置,@EnableResubmit 注解启用避免反复提交。

@ComponentScan("com.github.houbb.resubmit.test.service")@EnableResubmit@Configurationpublic class SpringConfig {}

测试代码

@ContextConfiguration(classes = SpringConfig.class)@RunWith(SpringJUnit4ClassRunner.class)public class ResubmitSpringTest {    @Autowired    private UserService service;    @Test(expected = ResubmitException.class)    public void queryTest() {        service.queryInfo("1");        service.queryInfo("1");    }}

外围实现

注解定义

import com.github.houbb.resubmit.spring.config.ResubmitAopConfig;import org.springframework.context.annotation.EnableAspectJAutoProxy;import org.springframework.context.annotation.Import;import java.lang.annotation.*;/** * 启用注解 * @author binbin.hou * @since 0.0.2 */@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Import(ResubmitAopConfig.class)@EnableAspectJAutoProxypublic @interface EnableResubmit {}

其中 ResubmitAopConfig 的内容如下:

@Configuration@ComponentScan(basePackages = "com.github.houbb.resubmit.spring")public class ResubmitAopConfig {}

次要是一些扫包信息。

aop 实现

这里就是大家比拟常见的 aop 切面实现。

咱们验证办法有指定注解时,间接进行避免反复提交的验证。

import com.github.houbb.aop.spring.util.SpringAopUtil;import com.github.houbb.resubmit.api.annotation.Resubmit;import com.github.houbb.resubmit.core.support.proxy.ResubmitProxy;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Pointcut;import org.springframework.stereotype.Component;import java.lang.reflect.Method;/** * @author binbin.hou * @since 0.0.2 */@Aspect@Componentpublic class ResubmitAspect {    @Pointcut("@annotation(com.github.houbb.resubmit.api.annotation.Resubmit)")    public void resubmitPointcut() {    }    /**     * 执行外围办法     *     * 相当于 MethodInterceptor     * @param point 切点     * @return 后果     * @throws Throwable 异样信息     * @since 0.0.2     */    @Around("resubmitPointcut()")    public Object around(ProceedingJoinPoint point) throws Throwable {        Method method = SpringAopUtil.getCurrentMethod(point);        if(method.isAnnotationPresent(Resubmit.class)) {            // 执行代理操作            Object[] args = point.getArgs();            ResubmitProxy.resubmit(method, args);        }        // 失常办法调用        return point.proceed();    }}

spring-boot 整合

spring-boot-starter

看完了 spring 的应用,你是否感觉曾经很简略了呢?

实际上,整合 spring-boot 能够让咱们应用起来更加简略。

间接引入 jar 包,就能够应用。

这所有都要归功于 spring-boot-starter 的个性。

测试案例

maven 引入

<dependency>    <groupId>com.github.houbb</groupId>    <artifactId>resubmit-springboot-starter</artifactId>    <version>0.0.3</version></dependency>

启动入口

UserService.java 和 spring 整合中一样,此处不再赘述。

ResubmitApplication 类是一个规范的 spring-boot 启动类。

@SpringBootApplicationpublic class ResubmitApplication {    public static void main(String[] args) {        SpringApplication.run(ResubmitApplication.class, args);    }}

测试代码

@ContextConfiguration(classes = ResubmitApplication.class)@RunWith(SpringJUnit4ClassRunner.class)public class ResubmitSpringBootStarterTest {    @Autowired    private UserService service;    @Test(expected = ResubmitException.class)    public void queryTest() {        service.queryInfo("1");        service.queryInfo("1");    }}

怎么样,是不是十分的简略?

上面咱们来一下外围实现。

外围实现

代码

package com.github.houbb.resubmit.springboot.starter.config;import com.github.houbb.resubmit.spring.annotation.EnableResubmit;import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Configuration;/** * 避免反复提交主动配置 * @author binbin.hou * @since 0.0.3 */@Configuration@EnableConfigurationProperties(ResubmitProperties.class)@ConditionalOnClass(EnableResubmit.class)@EnableResubmitpublic class ResubmitAutoConfig {    private final ResubmitProperties resubmitProperties;    public ResubmitAutoConfig(ResubmitProperties resubmitProperties) {        this.resubmitProperties = resubmitProperties;    }}

配置

创立 resource/META-INFO/spring.factories 文件中,内容如下:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.github.houbb.resubmit.springboot.starter.config.ResubmitAutoConfig

这样 spring-boot 启动时,就会基于 SPI 主动配置咱们的实现。

对于 spi,咱们后续有机会一起深刻开展一下。

残缺代码地址:

https://github.com/houbb/resubmit/tree/master/resubmit-springboot-starter

小结

无论是工作还是面试,当咱们遇到相似的问题时,都应该多想一点。

而不是简略的答复基于 session 之类的,一听就是从网上看来的。

问题是怎么产生的?

有哪些形式能够解决的?各有什么利弊?

是否封装为工具,便于复用?

当然,这里还波及到幂等性,AOP,SPI 等知识点。

一道简略的面试题,如果深挖,背地还是有不少值得探讨的货色。

愿你有所播种。

开源地址

为了便于大家学习,该我的项目曾经开源,欢送 star~

https://github.com/houbb/resubmit