java 表單避免重複提交?

老马啸西风發表於2024-08-05

面試經歷

記得剛畢業的時候,有一次去參加面試。

上來面試官問我:“你們專案中是怎麼做防重複提交的?”

一開始聽到這個問題是蒙圈的,支支吾吾半天沒回答出來。

然後面試官直接來一道演算法題,喜聞樂見地面試失敗。

多年過去,雖然很少接觸到控臺應用,但是近期對於防止重複提交卻有了一點自己的心得。

在這裡分享給大家,希望你工作或者面試中遇到類似的問題時,對你有所幫助。

本文將從以下幾個方面展開:

(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)
@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-防止重複提交-過濾器

相關文章