记得刚毕业的时候,有一次去参加面试。html
上来面试官问我:“大家项目中是怎么作防重复提交的?”前端
一开始听到这个问题是蒙圈的,支支吾吾半天没回答出来。java
而后面试官直接来一道算法题,喜闻乐见地面试失败。mysql
多年过去,虽然不多接触到控台应用,可是近期对于防止重复提交却有了一点本身的心得。nginx
在这里分享给你们,但愿你工做或者面试中遇到相似的问题时,对你有所帮助。git
本文将从如下几个方面展开:github
(1)重复提交产生的缘由面试
(2)什么是幂等性redis
(3)针对重复提交,先后端的解决方案算法
(4)若是实现一个防重复提交工具
因为重复点击或者网络重发
eg:
点击提交按钮两次;
点击刷新按钮;
使用浏览器后退按钮重复以前的操做,致使重复提交表单;
使用浏览器历史记录重复提交表单;
浏览器重复的HTTP请求;
nginx重发等状况;
分布式RPC的try重发等;
主要有 2 个部分:
(1)前端用户操做
(2)网络请求可能存在重试
固然也不排除一些用户的恶意操做。
就是同一份信息,重复的提交给服务器。
设置标志位,提交以后禁止按钮。像一些短信验证码的按钮通常都会加一个前端的按钮禁用,毕竟发短信是须要钞票滴~
ps: 之前写前端就用过这种方式。
简单。基本能够防止重复点击提交按钮形成的重复提交问题。
前进后退操做,或者F5刷新页面等问题并不能获得解决。
最重要的一点,前端的代码只能防止不懂js的用户,若是碰到懂得js的编程人员,那js方法就没用了。
设置HTTP报头,控制表单缓存,使得所控制的表单不缓存信息,这样用户就没法经过重复点击按钮去重复提交表单。
<meta http-equiv="Cache-Control" content="no-cache, must-revalidate">
可是这样作也有局限性,用户在提交页面点击刷新也会形成表单的重复提交。
用来防止F5刷新重复提交表单。
PRG模式经过响应页面Header返回HTTP状态码进行页面跳转替代响应页面跳转过程。
具体过程以下:
客户端用POST方法请求服务器端数据变动,服务器对客户端发来的请求进行处理重定向到另外一个结果页面上,客户端全部对页面的显示请求都用get方法告知服务器端,这样作,后退再前进或刷新的行为都发出的是get请求,不会对server产生任何数据更改的影响。
这种方法实现起来相对比较简单,但此方法也不能防止全部状况。例如用户屡次点击提交按钮;恶意用户避开客户端预防屡次提交手段,进行重复提交请求;
下面谈一谈后端的防止重复提交。
若是是注册或存入数据库的操做,能够经过在数据库中字段设立惟一标识来解决,这样在进行数据库插入操做时,由于每次插入的数据都相同,数据库会拒绝写入。
这样也避免了向数据库中写入垃圾数据的状况,同时也解决了表单重复提交问题。
可是这种方法在业务逻辑上感受是说不过去的,原本该有的逻辑,却由于数据库该有的设计隐藏了。
并且这种方法也有必定的功能局限性,只适用于某系特定的插入操做。
这种操做,都须要有一个惟一标识。数据库中作惟一索引约束,重复插入直接报错。
有很大的约束性。
通常都是最后的一道防线,当请求走到数据库层的时候,通常已经消耗了较多的资源。
Java 使用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 的获取 token 让用户本身处理,好比打开页面,放在隐藏域。实际上这是一个释放锁的过程。
当操做的时候,只有 token 信息和后台一致,才认为是获取到了锁。用完这个锁就一直被锁住了,须要从新获取 token,才能释放锁。
全部的 session 都应该有 token 的失效时间,避免累计一堆无用的脏数据。
当请求的时候,直接根据 user_id(或者其余标识)+请求信息(自定义)=惟一的 key
而后把这个 key 存储在 cache 中。
若是是本地 map,能够本身实现 key 的清空。
或者借助 guava 的 key 过时,redis 的自动过时,乃至数据库的过时均可以。
原理是相似的,就是限制必定时间内,没法重复操做。
固定时间后,key 被清空后就释放了锁。
只有一个针对锁的获取:
传入信息。
至于锁的释放,则交给实现者本身实现。
内存 ConcurrentHashMP
Guava
Encache
redis
mysql
...
能够基于 session,或者基于锁,
此处实现基于锁。
不管基于什么方式,这个值都须要。
只不过基于 session 的交给实现者处理,此处只是为了统一属性。
<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"); }
首先,咱们定义一个注解。
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) @Documented public @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 代理为例
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 做为 java 开发中基本必不可少的框架,为咱们的平常开发提供了很大的便利性。
咱们一块儿来看一下,当与 spring 整合以后,使用起来会变得多么简单呢?
<dependency> <group>com.github.houbb</group> <artifact>resubmit-spring</artifact> <version>0.0.3</version> </dependency>
经过注解 @Resubmit
指定咱们防止重复提交的方法。
@Service public 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 @Configuration public 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) @EnableAspectJAutoProxy public @interface EnableResubmit { }
其中 ResubmitAopConfig 的内容以下:
@Configuration @ComponentScan(basePackages = "com.github.houbb.resubmit.spring") public class ResubmitAopConfig { }
主要是一些扫包信息。
这里就是你们比较常见的 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 @Component public 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 的使用,你是否以为已经很简单了呢?
实际上,整合 spring-boot 可让咱们使用起来更加简单。
直接引入 jar 包,就可使用。
这一切都要归功于 spring-boot-starter 的特性。
<dependency> <groupId>com.github.houbb</groupId> <artifactId>resubmit-springboot-starter</artifactId> <version>0.0.3</version> </dependency>
UserService.java 和 spring 整合中同样,此处再也不赘述。
ResubmitApplication 类是一个标准的 spring-boot 启动类。
@SpringBootApplication public 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) @EnableResubmit public 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