SpringBoot项目防重复提交

前言

表单提交是web项目的基础功能,用户点击提交/保存按钮后,即会将提交的数据保存到服务端,使服务端对应的数据发生变更。用户在操作时,可能对一份表单数据在短时间内进行多次重复提交,如果是编辑数据这种情况是没有影响的,但是如果是新增数据,如果不加以限制会导致同一份数据同一时间内进入服务端,在服务端生成多条记录,在大多数业务场景下,是不能够允许出现这种现象的。

防重复提交

防重复提交在服务端和客户端都可以做,客户端可以做提交按钮做一下限制,在一次点击请求未响应之前不允许再次点击,但是这种限制只是操作层面的限制,如果采用postman或者curl调用仍然会出现重复提交的情况。服务端防重复提交最简单的方式就是加锁使接口串行化,这样重复提交的数据就能够得到校验,但是接口的吞吐量下载,因此要合理控制锁的粒度。
因此这里提供了一种基于AOP实现的防重复提交校验,实现的基本思路是采用指定的方法入参拼接成key放在Redis中,并指定过期时间,请求接口时通过key在Redis进行查找,如果查找到了数据则表示在短时间内已经发起过包含当前请求参数的请求,本次请求视作是重复提交,抛出重复提交错误信息,本次请求终止;如果在Redis中没有查找到数据,则表示当前请求是首次提交,将请求放行。

注解

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 防重复提交注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ResubmitCheck {

    /**
     * 参数Spring EL表达式例如 #{param.name},表达式的值作为防重复校验key的一部分
     */
    String[] argExpressions();

    /**
     * 重复提交报错信息
     */
    String message() default Constant.RESUBMIT_MSG;

    /**
     * Spring EL表达式,决定是否进行重复提交校验,多个条件之间为且的关系,默认是进行校验
     */
    String[] conditionExpressions() default {"true"};

    /**
     * 是否选用当前操作用户的信息作为防重复提交校验key的一部分
     */
    boolean withUserInfoInKey() default true;

    /**
     * 是否仅在当前session内进行防重复提交校验
     */
    boolean onlyInCurrentSession() default false;

    /**
     * 防重复提交校验的时间间隔
     */
    long interval() default 1;

    /**
     * 防重复提交校验的时间间隔的单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

通过argExpressions可以将指定参数作为防重复校验key的一部分,可以通过此参数控制锁的粒度;conditionExpressions表示进行重复提交校验的条件,如果指定了多个表达式,多个表达式之间是&&的关系,只有当所有的条件都满足时才进行重复提交校验;withUserInfoInKey参数表示是否将当前操作人的信息作为key的一部分,可以通过此参数控制锁的粒度,即使是相同数据的提交,只能对同一个人进行防重复提交限制;onlyInCurrentSession参数表示是否仅在当前session内进行防重复提交校验,是对withUserInfoInKey参数的补充,如果withUserInfoInKey指定为false,可以在session粒度内对重复数据进行提交校验;interval表示同一份数据防重复提交的时间间隔,也即是key在Redis中存放的时间。

切面

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 防重复提交切面
 */
@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();

        //根据条件判断是否需要进行防重复提交检查
        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::(根据spring EL表达式对参数进行拼接)
     *
     * @param annotation 注解
     * @param args       方法入参
     */
    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中获取,可以使用当前登录的用户id作为标识
        keyBuilder.append(RESUBMIT_CHECK_KEY_PREFIX)
                //userInfo一般从token中获取,可以使用当前登录的用户id作为标识
                .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());
    }
}

在需要进行防重复提交校验的方法上加上注解ResubmitCheck并指定参数,即可对该方法进行防重复提交校验。
注解属性argExpressions,conditionExpressions采用Spring EL表达式指定,Spring EL表达式真是个好东西,能够大大增加拼接key的灵活性,精准控制防重复提交校验的粒度。有时间准备再仔细看看Spring EL,下面贴一下我这里解析Spring EL表达式的代码。

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表达式工具类
 */
@SuppressWarnings("unused")
public class ExpressionUtils {

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

    /**
     * 获取Expression对象
     *
     * @param expressionString Spring EL 表达式字符串 例如 #{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;
    }

    /**
     * 根据Spring EL表达式字符串从根对象中求值
     *
     * @param root             根对象
     * @param expressionString Spring EL表达式
     * @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              根对象
     * @param expressionStrings Spring EL表达式
     * @param <T>               泛型 这里的泛型要慎用,大多数情况下要使用Object接收避免出现转换异常
     * @return 结果集
     */
    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;
    }

    /**
     * 表达式条件求值
     * 如果为值为null则返回false,
     * 如果为布尔类型直接返回,
     * 如果为数字类型则判断是否大于0
     *
     * @param root             根对象
     * @param expressionString Spring EL表达式
     * @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;
    }

    /**
     * 表达式条件求值
     *
     * @param root              根对象
     * @param expressionStrings Spring EL表达式数组
     * @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;
    }
}

测试

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",对其求值后结果为false,所以这里并不会进行防重复提交校验,如果将该条件去掉,利用postman自测在一秒内发出多次请求会报:请勿重复提交数据!
[示例代码]https://gitee.com/li-cube/share.git

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容

  • 一款专门为SpringBoot设计的防重幂等组件 本文以下的讨论,都是假设我们数据库没有做唯一约束和乐观锁的场景下...
    Chinesszz阅读 1,518评论 0 1
  • 幂等接口就是多次调用不会影响到系统。 数据库唯一主键 数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一...
    jiahzhon阅读 2,285评论 0 13
  • 1.防范重复提交 用户的重复提交误操作会导致系统接受重复交易,主机系统多次扣账等严重 后果。为此,平台对重复提交做...
    小菜小半碟阅读 367评论 0 0
  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,471评论 28 53
  • 信任包括信任自己和信任他人 很多时候,很多事情,失败、遗憾、错过,源于不自信,不信任他人 觉得自己做不成,别人做不...
    吴氵晃阅读 6,133评论 4 8