SpringCloud微服務實戰——搭建企業級開發框架(三十九):使用Redis分散式鎖(Redisson)+自定義註解+AOP實現微服務重複請求控制

全棧程式猿發表於2022-04-13

  通常我們可以在前端通過防抖和節流來解決短時間內請求重複提交的問題,如果因網路問題、Nginx重試機制、微服務Feign重試機制或者使用者故意繞過前端防抖和節流設定,直接頻繁發起請求,都會導致系統防重請求失敗,甚至導致後臺產生多條重複記錄,此時我們需要考慮在後臺增加防重設定。
  考慮到微服務分散式的場景,這裡通過使用Redisson分散式鎖+自定義註解+AOP的方式來實現後臺防止重複請求的功能,基本實現思路:通過在需要防重的介面新增自定義防重註解,設定防重引數,通過AOP攔截請求引數,根據註解配置,生成分散式鎖的Key,並設定有效時間。每次請求訪問時,都會嘗試獲取鎖,如果獲取到,則執行,如果獲取不到,那麼說明請求在設定的重複請求間隔內,返回請勿頻繁請求提示資訊。

1、自定義防止重複請求註解,根據業務場景設定了以下引數:
  • interval: 防止重複提交的時間間隔。
  • timeUnit: 防止重複提交的時間間隔的單位。
  • currentSession: 是否將sessionId作為防重引數(微服務及跨域前後端分離時,無法使用,Chrome等瀏覽器跨域時禁止攜帶cookie,每次sessionId都是新的)。
  • currentUser: 是否將使用者id作為防重引數。
  • keys: 可以作為防重引數的欄位(通過Spring Expression表示式,可以做到多引數時,具體取哪個引數的值)。
  • ignoreKeys: 需要忽略的防重引數欄位,例如有些引數中的時間戳,此和keys互斥,當keys配置了之後,ignoreKeys失效。
  • conditions:當引數中的某個欄位達到條件時,執行防重配置,預設不需要配置。
  • argsIndex: 當沒有配置keys引數時,防重攔截後會對所有引數取值作為分散式鎖的key,這裡時,當多引數時,配置取哪一個引數作為key,可以多個。此和keys互斥,當keys配置了之後,argsIndex配置失效。
package com.gitegg.platform.base.annotation.resubmit;

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

/**
 * 防止重複提交註解
 * 1、當設定了keys時,通過表示式確定取哪幾個引數作為防重key
 * 2、當未設定keys時,可以設定argsIndex設定取哪幾個引數作為防重key
 * 3、argsIndex和ignoreKeys是未設定keys時生效,排除不需要防重的引數
 * 4、因部分瀏覽器在跨域請求時,不允許request請求攜帶cookie,導致每次sessionId都是新的,所以這裡預設使用使用者id作為key的一部分,不使用sessionId
 * @author GitEgg
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ResubmitLock {

    /**
     * 防重複提交校驗的時間間隔
     */
    long interval() default 5;

    /**
     * 防重複提交校驗的時間間隔的單位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 是否僅在當前session內進行防重複提交校驗
     */
    boolean currentSession() default false;

    /**
     * 是否選用當前操作使用者的資訊作為防重複提交校驗key的一部分
     */
    boolean currentUser() default true;

    /**
     * keys和ignoreKeys不能同時使用
     * 引數Spring EL表示式例如 #{param.name},表示式的值作為防重複校驗key的一部分
     */
    String[] keys() default {};

    /**
     * keys和ignoreKeys不能同時使用
     * ignoreKeys不區分入參,所有入參擁有相同的欄位時,都將過濾掉
     */
    String[] ignoreKeys() default {};

    /**
     * Spring EL表示式,決定是否進行重複提交校驗,多個條件之間為且的關係,預設是進行校驗
     */
    String[] conditions() default {"true"};

    /**
     * 當未配置key時,設定哪幾個引數作為防重物件,預設取所有引數
     *
     * @return
     */
    int[] argsIndex() default {};

}
2、自定義AOP攔截防重請求的業務邏輯處理,詳細邏輯處理請看程式碼註釋。可以在Nacos中增加配置resubmit-lock: enable: false 使防重配置失效,預設不配置為生效狀態。因為是ResubmitLockAspect是否初始化的ConditionalOnProperty配置,此配置修改需要重啟服務生效。
package com.gitegg.platform.boot.aspect;

import com.gitegg.platform.base.annotation.resubmit.ResubmitLock;
import com.gitegg.platform.base.enums.ResultCodeEnum;
import com.gitegg.platform.base.exception.SystemException;
import com.gitegg.platform.base.util.JsonUtils;
import com.gitegg.platform.boot.util.ExpressionUtils;
import com.gitegg.platform.boot.util.GitEggAuthUtils;
import com.gitegg.platform.boot.util.GitEggWebUtils;
import com.gitegg.platform.redis.lock.IDistributedLockService;
import com.google.common.collect.Maps;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.ArrayUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;

import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;


/**
 * @author GitEgg
 * @date 2022-4-10
 */
@Log4j2
@Component
@Aspect
@RequiredArgsConstructor(onConstructor_ = @Autowired)
@ConditionalOnProperty(name = "enabled", prefix = "resubmit-lock", havingValue = "true", matchIfMissing = true)
public class ResubmitLockAspect {

    private static final String REDIS_SEPARATOR = ":";

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

    private final IDistributedLockService distributedLockService;

    /**
     * Before切點
     */
    @Pointcut("@annotation(com.gitegg.platform.base.annotation.resubmit.ResubmitLock)")
    public void resubmitLock() {
    }

    /**
     * 前置通知 防止重複提交
     *
     * @param joinPoint 切點
     * @param resubmitLock 註解配置
     */
    @Before("@annotation(resubmitLock)")
    public Object resubmitCheck(JoinPoint joinPoint, ResubmitLock resubmitLock) throws Throwable {

        final Object[] args = joinPoint.getArgs();
        final String[] conditions = resubmitLock.conditions();

        //根據條件判斷是否需要進行防重複提交檢查
        if (!ExpressionUtils.getConditionValue(args, conditions) || ArrayUtils.isEmpty(args)) {
            return ((ProceedingJoinPoint) joinPoint).proceed();
        }
        doCheck(resubmitLock, args);
        return ((ProceedingJoinPoint) joinPoint).proceed();
    }

    /**
     * key的組成為: resubmit_lock:userId:sessionId:uri:method:(根據spring EL表示式對引數進行拼接)
     *
     * @param resubmitLock 註解
     * @param args       方法入參
     */
    private void doCheck(@NonNull ResubmitLock resubmitLock, Object[] args) {

        final String[] keys = resubmitLock.keys();
        final boolean currentUser = resubmitLock.currentUser();
        final boolean currentSession = resubmitLock.currentSession();

        String method = GitEggWebUtils.getRequest().getMethod();
        String uri = GitEggWebUtils.getRequest().getRequestURI();

        StringBuffer lockKeyBuffer = new StringBuffer(RESUBMIT_CHECK_KEY_PREFIX);

        if (null != GitEggAuthUtils.getTenantId())
        {
            lockKeyBuffer.append( GitEggAuthUtils.getTenantId()).append(REDIS_SEPARATOR);
        }

        // 此判斷暫時預留,適配後續無使用者登入場景,因部分瀏覽器在跨域請求時,不允許request請求攜帶cookie,導致每次sessionId都是新的,所以這裡預設使用使用者id作為key的一部分,不使用sessionId
        if (currentSession)
        {
            lockKeyBuffer.append( GitEggWebUtils.getSessionId()).append(REDIS_SEPARATOR);
        }

        // 預設沒有將user資料作為防重key
        if (currentUser && null != GitEggAuthUtils.getCurrentUser())
        {
            lockKeyBuffer.append( GitEggAuthUtils.getCurrentUser().getId() ).append(REDIS_SEPARATOR);
        }

        lockKeyBuffer.append(uri).append(REDIS_SEPARATOR).append(method);


        StringBuffer parametersBuffer = new StringBuffer();
        // 優先判斷是否設定防重欄位,因keys試陣列,取值時是按照順序排列的,這裡不需要重新排序
        if (ArrayUtils.isNotEmpty(keys))
        {
            Object[] argsForKey = ExpressionUtils.getExpressionValue(args, keys);
            for (Object obj : argsForKey) {
                parametersBuffer.append(REDIS_SEPARATOR).append(String.valueOf(obj));
            }
        }
        // 如果沒有設定防重的欄位,那麼需要把所有的欄位和值作為key,因通過反射獲取欄位時,順序時不確定的,這裡取出來之後需要進行排序
        else{
            // 只有當keys為空時,ignoreKeys和argsIndex生效
            final String[] ignoreKeys = resubmitLock.ignoreKeys();
            final int[] argsIndex = resubmitLock.argsIndex();
            if (ArrayUtils.isNotEmpty(argsIndex))
            {
                for(int index : argsIndex){
                    parametersBuffer.append(REDIS_SEPARATOR).append( getKeyAndValueJsonStr(args[index], ignoreKeys));
                }
            }
            else
            {
                for(Object obj : args){
                    parametersBuffer.append(REDIS_SEPARATOR).append( getKeyAndValueJsonStr(obj, ignoreKeys) );
                }
            }
        }

        // 將請求引數取md5值作為key的一部分,MD5理論上會重複,但是key中還包含session或者使用者id,所以同使用者在極端時間內請引數不同生成的相同md5值的概率極低
        String parametersKey = DigestUtils.md5DigestAsHex(parametersBuffer.toString().getBytes());
        lockKeyBuffer.append(parametersKey);

        try {
            boolean isLock = distributedLockService.tryLock(lockKeyBuffer.toString(), 0, resubmitLock.interval(), resubmitLock.timeUnit());
            if (!isLock)
            {
                throw new SystemException(ResultCodeEnum.RESUBMIT_LOCK.code, ResultCodeEnum.RESUBMIT_LOCK.msg);
            }
        } catch (InterruptedException e) {
            throw new SystemException(ResultCodeEnum.RESUBMIT_LOCK.code, ResultCodeEnum.RESUBMIT_LOCK.msg);
        }
    }

    /**
     * 將欄位轉換為json字串
     * @param obj
     * @return
     */
    public static String getKeyAndValueJsonStr(Object obj, String[] ignoreKeys) {
        Map<String, Object> map = Maps.newHashMap();
        // 得到類物件
        Class objCla = (Class) obj.getClass();
        /* 得到類中的所有屬性集合 */
        Field[] fs = objCla.getDeclaredFields();
        for (int i = 0; i < fs.length; i++) {
            Field f = fs[i];
            // 設定些屬性是可以訪問的
            f.setAccessible(true);
            Object val = new Object();
            try {
                String filedName = f.getName();
                // 如果欄位在排除列表,那麼不將欄位放入map
                if (null != ignoreKeys && Arrays.asList(ignoreKeys).contains(filedName))
                {
                    continue;
                }
                val = f.get(obj);
                // 得到此屬性的值
                // 設定鍵值
                map.put(filedName, val);
            } catch (IllegalArgumentException e) {
                log.error("getKeyAndValue IllegalArgumentException", e);
                throw new RuntimeException("您的操作太頻繁,請稍後再試");
            } catch (IllegalAccessException e) {
                log.error("getKeyAndValue IllegalAccessException", e);
                throw new RuntimeException("您的操作太頻繁,請稍後再試");
            }
        }
        Map<String, Object> sortMap = sortMapByKey(map);
        String mapStr = JsonUtils.mapToJson(sortMap);
        return mapStr;
    }

    private static Map<String, Object> sortMapByKey(Map<String, Object> map) {
        if (map == null || map.isEmpty()) {
            return null;
        }
        Map<String, Object> sortMap = new TreeMap<String, Object>(new Comparator<String>() {
            @Override
            public int compare(String o1,String o2) {
                return ((String)o1).compareTo((String) o2);
            }
        });
        sortMap.putAll(map);
        return sortMap;
    }

}

3、Redisson分散式鎖自定義介面
package com.gitegg.platform.redis.lock;

import java.util.concurrent.TimeUnit;

/**
 * 分散式鎖介面
 * @author GitEgg
 * @date 2022-4-10
 */
public interface IDistributedLockService {

    /**
     * 加鎖
     * @param lockKey key
     */
    void lock(String lockKey);

    /**
     * 釋放鎖
     *
     * @param lockKey key
     */
    void unlock(String lockKey);

    /**
     * 加鎖並設定有效期
     *
     * @param lockKey key
     * @param timeout 有效時間,預設時間單位在實現類傳入
     */
    void lock(String lockKey, int timeout);

    /**
     * 加鎖並設定有效期指定時間單位
     * @param lockKey key
     * @param timeout 有效時間
     * @param unit    時間單位
     */
    void lock(String lockKey, int timeout, TimeUnit unit);

    /**
     * 嘗試獲取鎖,獲取到則持有該鎖返回true,未獲取到立即返回false
     * @param lockKey
     * @return true-獲取鎖成功 false-獲取鎖失敗
     */
    boolean tryLock(String lockKey);

    /**
     * 嘗試獲取鎖,獲取到則持有該鎖leaseTime時間.
     * 若未獲取到,在waitTime時間內一直嘗試獲取,超過watiTime還未獲取到則返回false
     * @param lockKey   key
     * @param waitTime  嘗試獲取時間
     * @param leaseTime 鎖持有時間
     * @param unit      時間單位
     * @return true-獲取鎖成功 false-獲取鎖失敗
     * @throws InterruptedException
     */
    boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit)
            throws InterruptedException;

    /**
     * 鎖是否被任意一個執行緒鎖持有
     * @param lockKey
     * @return true-被鎖 false-未被鎖
     */
    boolean isLocked(String lockKey);
}

4、Redisson分散式鎖自定義介面實現類
package com.gitegg.platform.redis.lock.impl;

import com.gitegg.platform.redis.lock.IDistributedLockService;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
 * 分散式鎖的 Redisson 介面實現
 * @author GitEgg
 * @date 2022-4-10
 */
@Service
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class DistributedLockServiceImpl implements IDistributedLockService {

    private final RedissonClient redissonClient;


    @Override
    public void lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
    }

    @Override
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    @Override
    public void lock(String lockKey, int timeout) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, TimeUnit.MILLISECONDS);
    }

    @Override
    public void lock(String lockKey, int timeout, TimeUnit unit) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
    }

    @Override
    public boolean tryLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock();
    }

    @Override
    public boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock(waitTime, leaseTime, unit);
    }

    @Override
    public boolean isLocked(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.isLocked();
    }
}

5、Spring Expression自定義工具類,通過此工具類獲取註解上的Expression表示式,以獲取相應請求物件的值,如果請求物件有多個,可以通過Expression表示式精準獲取。
package com.gitegg.platform.boot.util;

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.NonNull;
import org.springframework.lang.Nullable;

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

/**
 * Spring Expression 工具類
 * @author GitEgg
 * @date 2022-4-11
 */
public class ExpressionUtils {

    private static final Map<String, 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;
        }
        //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;
        }
        //noinspection ConstantConditions
        for (String expressionString : expressionStrings) {
            if (!getConditionValue(root, expressionString)) {
                return false;
            }
        }
        return true;
    }
}
5、防重測試,我們在系統的使用者介面(GitEgg-Cloud工程的UserController類)上進行測試,通過多引數介面以及配置keys,不配置keys等各種場景進行測試,在測試時為了達到效果,可以將interval 時間設定為30秒。
  • 設定user引數的realName,mobile和page引數的size為key進行防重測試
    @ResubmitLock(interval = 30, keys = {"[0].realName","[0].mobile","[1].size"})
    public PageResult<UserInfo> list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page<UserInfo> page) {
        Page<UserInfo> pageUser = userService.selectUserList(page, user);
        PageResult<UserInfo> pageResult = new PageResult<>(pageUser.getTotal(), pageUser.getRecords());
        return pageResult;
    }
  • 不設定防重引數的key,只取第一個引數user,配置排除的引數,不參與放重key的生成
    @ResubmitLock(interval = 30, argsIndex = {0}, ignoreKeys = {"email","status"})
    public PageResult<UserInfo> list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page<UserInfo> page) {
        Page<UserInfo> pageUser = userService.selectUserList(page, user);
        PageResult<UserInfo> pageResult = new PageResult<>(pageUser.getTotal(), pageUser.getRecords());
        return pageResult;
    }
  • 測試結果
    測試結果

相關引用:
1、防重配置項及通過SpringExpression獲取相應引數:https://www.jianshu.com/p/77895a822237
2、Redisson分散式鎖及相關工具類:https://blog.csdn.net/wsh_ningjing/article/details/115326052

原始碼地址:

Gitee: https://gitee.com/wmz1930/GitEgg

GitHub: https://github.com/wmz1930/GitEgg

相關文章