記得剛畢業的時候,有一次去參加面試。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