面試官:大家項目中是怎麼作防重複提交的?

面試經歷

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

上來面試官問我:「大家項目中是怎麼作防重複提交的?」前端

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

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

多年過去,雖然不多接觸到控臺應用,可是近期對於防止重複提交卻有了一點本身的心得。nginx

在這裏分享給你們,但願你工做或者面試中遇到相似的問題時,對你有所幫助。git

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

(1)重複提交產生的緣由面試

(2)什麼是冪等性redis

(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

image

相關文章
相關標籤/搜索