面試經歷
記得剛畢業的時候,有一次去參加面試。
上來面試官問我:“你們專案中是怎麼做防重複提交的?”
一開始聽到這個問題是蒙圈的,支支吾吾半天沒回答出來。
然後面試官直接來一道演算法題,喜聞樂見地面試失敗。
多年過去,雖然很少接觸到控臺應用,但是近期對於防止重複提交卻有了一點自己的心得。
在這裡分享給大家,希望你工作或者面試中遇到類似的問題時,對你有所幫助。
本文將從以下幾個方面展開:
(1)重複提交產生的原因
(2)什麼是冪等性
(3)針對重複提交,前後端的解決方案
(4)如果實現一個防重複提交工具
產生原因
由於重複點選或者網路重發
eg:
點選提交按鈕兩次;
點選重新整理按鈕;
使用瀏覽器後退按鈕重複之前的操作,導致重複提交表單;
使用瀏覽器歷史記錄重複提交表單;
瀏覽器重複的HTTP請求;
nginx重發等情況;
分散式RPC的try重發等;
主要有 2 個部分:
(1)前端使用者操作
(2)網路請求可能存在重試
當然也不排除一些使用者的惡意操作。
java 表單避免重複提交
問題
就是同一份資訊,重複的提交給伺服器。
場景
-
點選F5重新整理頁面: 當使用者點選submit將已經寫好的表單資料提交到伺服器時,可以在瀏覽器的url看到地址和引數的變化,但因為網速等問題,使用者當前頁面並未重新整理,或者點選重新整理頁面,造成表單重複提交。
-
重複點選提交按鈕: 因為網路問題,未能及時跳轉顯示內容,部分使用者可能會出於心急重複提交提交按鈕,造成多次提交內容到伺服器。
-
前進後退操作 :有些使用者在進行某些工作操作時,可能出於需要或者某種情況,進行後退操作,瀏覽剛才填入的資訊,在進行後退和前進的操作可能也會造成表單資料重複提交到伺服器。
-
使用瀏覽器歷史記錄重複訪問: 某些使用者可能會出於好奇,利用瀏覽器的歷史記錄功能重複訪問提交頁面,同樣會造成表單重複提交問題。
解決思路
前端
方案一:禁用按鈕提交
設定標誌位,提交之後禁止按鈕。像一些簡訊驗證碼的按鈕一般都會加一個前端的按鈕禁用,畢竟發簡訊是需要鈔票滴~
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令牌防止表單重複提交的步驟:
-
在伺服器端生成一個唯一的隨機標識號,專業術語稱為Token(令牌),同時在當前使用者的Session域中儲存這個Token。
-
將Token傳送到客戶端的Form表單中,在Form表單中使用隱藏域來儲存這個Token,表單提交的時候連同這個Token一起提交到伺服器端。
-
在伺服器端判斷客戶端提交上來的Token與伺服器端生成的Token是否一致,如果不一致,那就是重複提交了,此時伺服器端就可以不處理重複提交的表單。如果相同則處理表單提交,處理完後清除當前使用者的Session域中儲存的標識號。
下面的場景將拒絕處理使用者提交的表單請求
-
儲存Session域中的Token(令牌)與表單提交的Token(令牌)不同。
-
當前使用者的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
傳入資訊。
至於鎖的釋放,則交給實現者自己實現。
屬性
- 鎖的獲取策略 = 記憶體 RAM
記憶體 ConcurrentHashMP
Guava
Encache
redis
mysql
...
可以基於 session,或者基於 鎖,
此處實現基於鎖。
- 鎖的過期時間 = 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)
@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 代理為例
- 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
指定我們防止重複提交餓方法。
@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 實現
這裡就是大家比較常見的 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-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 啟動類。
@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
擴充閱讀
冪等性防止重複提交
參考資料
- 重複提交
Java 使用Token令牌防止表單重複提交
有效防止F5重新整理重複提交
8種方案解決重複提交問題
- in action
JAVA-防止重複提交-過濾器