SpringBoot項(xiàng)目防重復(fù)提交

前言

表單提交是web項(xiàng)目的基礎(chǔ)功能,用戶點(diǎn)擊提交/保存按鈕后别洪,即會(huì)將提交的數(shù)據(jù)保存到服務(wù)端叨恨,使服務(wù)端對(duì)應(yīng)的數(shù)據(jù)發(fā)生變更。用戶在操作時(shí)挖垛,可能對(duì)一份表單數(shù)據(jù)在短時(shí)間內(nèi)進(jìn)行多次重復(fù)提交痒钝,如果是編輯數(shù)據(jù)這種情況是沒有影響的,但是如果是新增數(shù)據(jù)痢毒,如果不加以限制會(huì)導(dǎo)致同一份數(shù)據(jù)同一時(shí)間內(nèi)進(jìn)入服務(wù)端送矩,在服務(wù)端生成多條記錄,在大多數(shù)業(yè)務(wù)場(chǎng)景下哪替,是不能夠允許出現(xiàn)這種現(xiàn)象的栋荸。

防重復(fù)提交

防重復(fù)提交在服務(wù)端和客戶端都可以做,客戶端可以做提交按鈕做一下限制凭舶,在一次點(diǎn)擊請(qǐng)求未響應(yīng)之前不允許再次點(diǎn)擊晌块,但是這種限制只是操作層面的限制,如果采用postman或者curl調(diào)用仍然會(huì)出現(xiàn)重復(fù)提交的情況帅霜。服務(wù)端防重復(fù)提交最簡(jiǎn)單的方式就是加鎖使接口串行化匆背,這樣重復(fù)提交的數(shù)據(jù)就能夠得到校驗(yàn),但是接口的吞吐量下載身冀,因此要合理控制鎖的粒度钝尸。
因此這里提供了一種基于AOP實(shí)現(xiàn)的防重復(fù)提交校驗(yàn),實(shí)現(xiàn)的基本思路是采用指定的方法入?yún)⑵唇映蒶ey放在Redis中闽铐,并指定過期時(shí)間蝶怔,請(qǐng)求接口時(shí)通過key在Redis進(jìn)行查找,如果查找到了數(shù)據(jù)則表示在短時(shí)間內(nèi)已經(jīng)發(fā)起過包含當(dāng)前請(qǐng)求參數(shù)的請(qǐng)求兄墅,本次請(qǐng)求視作是重復(fù)提交,拋出重復(fù)提交錯(cuò)誤信息澳叉,本次請(qǐng)求終止隙咸;如果在Redis中沒有查找到數(shù)據(jù),則表示當(dāng)前請(qǐng)求是首次提交成洗,將請(qǐng)求放行五督。

注解

package com.cube.share.resubmit.check.aspect;

import com.cube.share.resubmit.check.constants.Constant;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * @author cube.li
 * @date 2021/7/9 20:45
 * @description 防重復(fù)提交注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ResubmitCheck {

    /**
     * 參數(shù)Spring EL表達(dá)式例如 #{param.name},表達(dá)式的值作為防重復(fù)校驗(yàn)key的一部分
     */
    String[] argExpressions();

    /**
     * 重復(fù)提交報(bào)錯(cuò)信息
     */
    String message() default Constant.RESUBMIT_MSG;

    /**
     * Spring EL表達(dá)式,決定是否進(jìn)行重復(fù)提交校驗(yàn),多個(gè)條件之間為且的關(guān)系,默認(rèn)是進(jìn)行校驗(yàn)
     */
    String[] conditionExpressions() default {"true"};

    /**
     * 是否選用當(dāng)前操作用戶的信息作為防重復(fù)提交校驗(yàn)key的一部分
     */
    boolean withUserInfoInKey() default true;

    /**
     * 是否僅在當(dāng)前session內(nèi)進(jìn)行防重復(fù)提交校驗(yàn)
     */
    boolean onlyInCurrentSession() default false;

    /**
     * 防重復(fù)提交校驗(yàn)的時(shí)間間隔
     */
    long interval() default 1;

    /**
     * 防重復(fù)提交校驗(yàn)的時(shí)間間隔的單位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

通過argExpressions可以將指定參數(shù)作為防重復(fù)校驗(yàn)key的一部分,可以通過此參數(shù)控制鎖的粒度瓶殃;conditionExpressions表示進(jìn)行重復(fù)提交校驗(yàn)的條件充包,如果指定了多個(gè)表達(dá)式,多個(gè)表達(dá)式之間是&&的關(guān)系,只有當(dāng)所有的條件都滿足時(shí)才進(jìn)行重復(fù)提交校驗(yàn)基矮;withUserInfoInKey參數(shù)表示是否將當(dāng)前操作人的信息作為key的一部分淆储,可以通過此參數(shù)控制鎖的粒度,即使是相同數(shù)據(jù)的提交家浇,只能對(duì)同一個(gè)人進(jìn)行防重復(fù)提交限制本砰;onlyInCurrentSession參數(shù)表示是否僅在當(dāng)前session內(nèi)進(jìn)行防重復(fù)提交校驗(yàn),是對(duì)withUserInfoInKey參數(shù)的補(bǔ)充,如果withUserInfoInKey指定為false钢悲,可以在session粒度內(nèi)對(duì)重復(fù)數(shù)據(jù)進(jìn)行提交校驗(yàn)点额;interval表示同一份數(shù)據(jù)防重復(fù)提交的時(shí)間間隔,也即是key在Redis中存放的時(shí)間莺琳。

切面

package com.cube.share.resubmit.check.aspect;

import com.cube.share.base.templates.CustomException;
import com.cube.share.base.utils.ExpressionUtils;
import com.cube.share.resubmit.check.constants.Constant;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * @author cube.li
 * @date 2021/7/9 22:17
 * @description 防重復(fù)提交切面
 */
@Component
@Aspect
@Order(-1)
@ConditionalOnProperty(name = "enabled", prefix = "resubmit-check", havingValue = "true", matchIfMissing = true)
public class ResubmitCheckAspect {


    private static final String REDIS_SEPARATOR = "::";

    private static final String RESUBMIT_CHECK_KEY_PREFIX = "resubmitCheckKey" + REDIS_SEPARATOR;

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Resource
    private HttpServletRequest request;

    @Before("@annotation(annotation)")
    public void resubmitCheck(JoinPoint joinPoint, ResubmitCheck annotation) throws Throwable {

        final Object[] args = joinPoint.getArgs();
        final String[] conditionExpressions = annotation.conditionExpressions();

        //根據(jù)條件判斷是否需要進(jìn)行防重復(fù)提交檢查
        if (!ExpressionUtils.getConditionValue(args, conditionExpressions) || ArrayUtils.isEmpty(args)) {
            //return ((ProceedingJoinPoint) joinPoint).proceed();
        }
        doCheck(annotation, args);
        //return ((ProceedingJoinPoint) joinPoint).proceed();
    }

    /**
     * key的組成為: prefix::userInfo::sessionId::uri::method::(根據(jù)spring EL表達(dá)式對(duì)參數(shù)進(jìn)行拼接)
     *
     * @param annotation 注解
     * @param args       方法入?yún)?     */
    private void doCheck(@NonNull ResubmitCheck annotation, Object[] args) {
        final String[] argExpressions = annotation.argExpressions();
        final String message = annotation.message();
        final boolean withUserInfoInKey = annotation.withUserInfoInKey();
        final boolean onlyInCurrentSession = annotation.onlyInCurrentSession();

        String methodDesc = request.getMethod();
        String uri = request.getRequestURI();

        StringBuilder stringBuilder = new StringBuilder(64);
        Object[] argsForKey = ExpressionUtils.getExpressionValue(args, argExpressions);
        for (Object obj : argsForKey) {
            stringBuilder.append(obj.toString());
        }

        StringBuilder keyBuilder = new StringBuilder();
        //userInfo一般從token中獲取,可以使用當(dāng)前登錄的用戶id作為標(biāo)識(shí)
        keyBuilder.append(RESUBMIT_CHECK_KEY_PREFIX)
                //userInfo一般從token中獲取,可以使用當(dāng)前登錄的用戶id作為標(biāo)識(shí)
                .append(withUserInfoInKey ? "userId" + REDIS_SEPARATOR : "")
                .append(onlyInCurrentSession ? request.getSession().getId() + REDIS_SEPARATOR : "")
                .append(uri)
                .append(REDIS_SEPARATOR)
                .append(methodDesc).append(REDIS_SEPARATOR)
                .append(stringBuilder.toString());
        if (redisTemplate.opsForValue().get(keyBuilder.toString()) != null) {
            throw new CustomException(StringUtils.isBlank(message) ? Constant.RESUBMIT_MSG : message);
        }
        //值為空
        redisTemplate.opsForValue().set(keyBuilder.toString(), "", annotation.interval(), annotation.timeUnit());
    }
}

在需要進(jìn)行防重復(fù)提交校驗(yàn)的方法上加上注解ResubmitCheck并指定參數(shù)还棱,即可對(duì)該方法進(jìn)行防重復(fù)提交校驗(yàn)。
注解屬性argExpressions,conditionExpressions采用Spring EL表達(dá)式指定惭等,Spring EL表達(dá)式真是個(gè)好東西珍手,能夠大大增加拼接key的靈活性,精準(zhǔn)控制防重復(fù)提交校驗(yàn)的粒度咕缎。有時(shí)間準(zhǔn)備再仔細(xì)看看Spring EL珠十,下面貼一下我這里解析Spring EL表達(dá)式的代碼。

package com.cube.share.base.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author cube.li
 * @date 2021/7/9 21:00
 * @description Spring EL表達(dá)式工具類
 */
@SuppressWarnings("unused")
public class ExpressionUtils {

    private static final Map<String, org.springframework.expression.Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>(64);

    /**
     * 獲取Expression對(duì)象
     *
     * @param expressionString Spring EL 表達(dá)式字符串 例如 #{param.id}
     * @return Expression
     */
    @Nullable
    public static Expression getExpression(@Nullable String expressionString) {

        if (StringUtils.isBlank(expressionString)) {
            return null;
        }

        if (EXPRESSION_CACHE.containsKey(expressionString)) {
            return EXPRESSION_CACHE.get(expressionString);
        }

        Expression expression = new SpelExpressionParser().parseExpression(expressionString);
        EXPRESSION_CACHE.put(expressionString, expression);
        return expression;
    }

    /**
     * 根據(jù)Spring EL表達(dá)式字符串從根對(duì)象中求值
     *
     * @param root             根對(duì)象
     * @param expressionString Spring EL表達(dá)式
     * @param clazz            值得類型
     * @param <T>              泛型
     * @return 值
     */
    @Nullable
    public static <T> T getExpressionValue(@Nullable Object root, @Nullable String expressionString, @NonNull Class<? extends T> clazz) {
        if (root == null) {
            return null;
        }
        Expression expression = getExpression(expressionString);
        if (expression == null) {
            return null;
        }

        return expression.getValue(root, clazz);
    }

    @Nullable
    public static <T> T getExpressionValue(@Nullable Object root, @Nullable String expressionString) {
        if (root == null) {
            return null;
        }
        Expression expression = getExpression(expressionString);
        if (expression == null) {
            return null;
        }

        //noinspection unchecked
        return (T) expression.getValue(root);
    }

    /**
     * 求值
     *
     * @param root              根對(duì)象
     * @param expressionStrings Spring EL表達(dá)式
     * @param <T>               泛型 這里的泛型要慎用,大多數(shù)情況下要使用Object接收避免出現(xiàn)轉(zhuǎn)換異常
     * @return 結(jié)果集
     */
    public static <T> T[] getExpressionValue(@Nullable Object root, @Nullable String... expressionStrings) {
        if (root == null) {
            return null;
        }

        if (ArrayUtils.isEmpty(expressionStrings)) {
            return null;
        }

        IAssert.notNull(expressionStrings, "Expressions cannot be null!");
        //noinspection ConstantConditions
        Object[] values = new Object[expressionStrings.length];
        for (int i = 0; i < expressionStrings.length; i++) {

            //noinspection unchecked
            values[i] = (T) getExpressionValue(root, expressionStrings[i]);
        }
        //noinspection unchecked
        return (T[]) values;
    }

    /**
     * 表達(dá)式條件求值
     * 如果為值為null則返回false,
     * 如果為布爾類型直接返回,
     * 如果為數(shù)字類型則判斷是否大于0
     *
     * @param root             根對(duì)象
     * @param expressionString Spring EL表達(dá)式
     * @return 值
     */
    @Nullable
    public static boolean getConditionValue(@Nullable Object root, @Nullable String expressionString) {
        Object value = getExpressionValue(root, expressionString);
        if (value == null) {
            return false;
        }

        if (value instanceof Boolean) {
            return (boolean) value;
        }

        if (value instanceof Number) {
            return ((Number) value).longValue() > 0;
        }

        return true;
    }

    /**
     * 表達(dá)式條件求值
     *
     * @param root              根對(duì)象
     * @param expressionStrings Spring EL表達(dá)式數(shù)組
     * @return 值
     */
    @Nullable
    public static boolean getConditionValue(@Nullable Object root, @Nullable String... expressionStrings) {

        if (root == null) {
            return false;
        }

        if (ArrayUtils.isEmpty(expressionStrings)) {
            return false;
        }

        IAssert.notNull(expressionStrings, "Expressions cannot be null!");
        //noinspection ConstantConditions
        for (String expressionString : expressionStrings) {
            if (!getConditionValue(root, expressionString)) {
                return false;
            }
        }

        return true;
    }
}

測(cè)試

package com.cube.share.resubmit.check.controller;

import com.cube.share.base.templates.ApiResult;
import com.cube.share.resubmit.check.aspect.ResubmitCheck;
import com.cube.share.resubmit.check.model.Person;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author cube.li
 * @date 2021/7/9 23:05
 * @description
 */
@RestController
public class ResubmitController {

    @PostMapping("/save")
    @ResubmitCheck(argExpressions = {"[0].id", "[0].name"}, conditionExpressions = "[0].address != null")
    public ApiResult save(@RequestBody Person person) {
        return ApiResult.success();
    }
}

隨便寫一寫吧凭豪,這里我指定了conditionExpressions = "[0].address != null"焙蹭,對(duì)其求值后結(jié)果為false,所以這里并不會(huì)進(jìn)行防重復(fù)提交校驗(yàn)嫂伞,如果將該條件去掉孔厉,利用postman自測(cè)在一秒內(nèi)發(fā)出多次請(qǐng)求會(huì)報(bào):請(qǐng)勿重復(fù)提交數(shù)據(jù)!
[示例代碼]https://gitee.com/li-cube/share.git

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市帖努,隨后出現(xiàn)的幾起案子撰豺,更是在濱河造成了極大的恐慌,老刑警劉巖拼余,帶你破解...
    沈念sama閱讀 217,185評(píng)論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件污桦,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡匙监,警方通過查閱死者的電腦和手機(jī)凡橱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評(píng)論 3 393
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來亭姥,“玉大人稼钩,你說我怎么就攤上這事〈锫蓿” “怎么了坝撑?”我有些...
    開封第一講書人閱讀 163,524評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我巡李,道長(zhǎng)抚笔,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,339評(píng)論 1 293
  • 正文 為了忘掉前任击儡,我火速辦了婚禮塔沃,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘阳谍。我一直安慰自己蛀柴,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,387評(píng)論 6 391
  • 文/花漫 我一把揭開白布矫夯。 她就那樣靜靜地躺著鸽疾,像睡著了一般。 火紅的嫁衣襯著肌膚如雪训貌。 梳的紋絲不亂的頭發(fā)上制肮,一...
    開封第一講書人閱讀 51,287評(píng)論 1 301
  • 那天,我揣著相機(jī)與錄音递沪,去河邊找鬼豺鼻。 笑死,一個(gè)胖子當(dāng)著我的面吹牛款慨,可吹牛的內(nèi)容都是我干的儒飒。 我是一名探鬼主播,決...
    沈念sama閱讀 40,130評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼檩奠,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼桩了!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起埠戳,我...
    開封第一講書人閱讀 38,985評(píng)論 0 275
  • 序言:老撾萬榮一對(duì)情侶失蹤井誉,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后整胃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體颗圣,經(jīng)...
    沈念sama閱讀 45,420評(píng)論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,617評(píng)論 3 334
  • 正文 我和宋清朗相戀三年屁使,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了欠啤。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,779評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡屋灌,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出应狱,到底是詐尸還是另有隱情共郭,我是刑警寧澤,帶...
    沈念sama閱讀 35,477評(píng)論 5 345
  • 正文 年R本政府宣布,位于F島的核電站除嘹,受9級(jí)特大地震影響写半,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜尉咕,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,088評(píng)論 3 328
  • 文/蒙蒙 一叠蝇、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧年缎,春花似錦悔捶、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至洲鸠,卻和暖如春堂淡,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背扒腕。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工绢淀, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人瘾腰。 一個(gè)月前我還...
    沈念sama閱讀 47,876評(píng)論 2 370
  • 正文 我出身青樓皆的,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親居灯。 傳聞我的和親對(duì)象是個(gè)殘疾皇子祭务,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,700評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容

  • 一款專門為SpringBoot設(shè)計(jì)的防重冪等組件 本文以下的討論,都是假設(shè)我們數(shù)據(jù)庫沒有做唯一約束和樂觀鎖的場(chǎng)景下...
    Chinesszz閱讀 1,565評(píng)論 0 1
  • 冪等接口就是多次調(diào)用不會(huì)影響到系統(tǒng)。 數(shù)據(jù)庫唯一主鍵 數(shù)據(jù)庫唯一主鍵的實(shí)現(xiàn)主要是利用數(shù)據(jù)庫中主鍵唯一約束的特性怪嫌,一...
    jiahzhon閱讀 2,430評(píng)論 0 13
  • 1.防范重復(fù)提交 用戶的重復(fù)提交誤操作會(huì)導(dǎo)致系統(tǒng)接受重復(fù)交易义锥,主機(jī)系統(tǒng)多次扣賬等嚴(yán)重 后果。為此岩灭,平臺(tái)對(duì)重復(fù)提交做...
    小菜小半碟閱讀 413評(píng)論 0 0
  • 我是黑夜里大雨紛飛的人啊 1 “又到一年六月拌倍,有人笑有人哭,有人歡樂有人憂愁噪径,有人驚喜有人失落柱恤,有的覺得收獲滿滿有...
    陌忘宇閱讀 8,536評(píng)論 28 53
  • 信任包括信任自己和信任他人 很多時(shí)候,很多事情找爱,失敗梗顺、遺憾、錯(cuò)過车摄,源于不自信寺谤,不信任他人 覺得自己做不成仑鸥,別人做不...
    吳氵晃閱讀 6,187評(píng)論 4 8