Spring boot冪等性約束的實現(初級版)

在分布式服務中,業務在高并發或者可能被多次調用的情況下,同一個請求會出現多次。這個時候如果執行插入的業務操作,則數據庫中出現多條數據,產生了臟數據,同時也是對資源的浪費。
此時我們需要阻止多余業務的處理操作。

實現方案

實現接口的冪等性,讓請求只成功一次。這里需要保存一個唯一標識key,在下一個相同請求(類似表的唯一索引,請求的時間戳不同但幾個核心參數相同即認為相同請求)執行時獲取是否存在標識,如果重復提交則阻止執行。

引入依賴

       <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

代碼實現

  1. 創建一個自定義異常,跳過執行接口的方法體業務代碼,拋出此異常。
package com.pay.common.exception;

/**
 * @ClassName: IdempotentException
 * @Description: 自定義冪等異常類
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/4 20:12
 * @Copyright:
 */
public class IdempotentException extends RuntimeException {

    private static final long serialVersionUID = 17721020985L;

    public IdempotentException(String message) {
        super(message);
    }

    @Override
    public String getMessage() {
        return super.getMessage();
    }

}
  1. 生成key值工具類
package com.pay.common.util;

import com.alibaba.fastjson.JSON;

import java.lang.reflect.Method;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * @ClassName: IdempotentKeyUtil
 * @Description: 冪等生成key值工具類
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/6 06:06
 * @Copyright:
 */
public class IdempotentKeyUtil {

    /**
     * 對接口的參數進行處理生成固定key
     *
     * @param method
     * @param custArgsIndex
     * @param args
     * @return
     */
    public static String generate(Method method, int[] custArgsIndex, Object... args) {
        String stringBuilder = getKeyOriginalString(method, custArgsIndex, args);
        //進行md5等長加密
        return md5(stringBuilder.toString());
    }

    /**
     * 原生的key字符串。
     *
     * @param method
     * @param custArgsIndex
     * @param args
     * @return
     */
    public static String getKeyOriginalString(Method method, int[] custArgsIndex, Object[] args) {
        StringBuilder stringBuilder = new StringBuilder(method.toString());
        int i = 0;
        for (Object arg : args) {
            if (isIncludeArgIndex(custArgsIndex, i)) {
                stringBuilder.append(toString(arg));
            }
            i++;
        }
        return stringBuilder.toString();
    }

    /**
     * 判斷當前參數是否包含在注解中的自定義序列當中。
     *
     * @param custArgsIndex
     * @param i
     * @return
     */
    private static boolean isIncludeArgIndex(int[] custArgsIndex, int i) {
        //如果沒自定義作為key的參數index序號,直接返回true,意味加入到生成key的序列
        if (custArgsIndex.length == 0) {
            return true;
        }

        boolean includeIndex = false;
        for (int argsIndex : custArgsIndex) {
            if (argsIndex == i) {
                includeIndex = true;
                break;
            }
        }
        return includeIndex;
    }

    /**
     * 使用jsonObject對數據進行toString,(保持數據一致性)
     *
     * @param obj
     * @return
     */
    public static String toString(Object obj) {
        if (obj == null) {
            return "-";
        }
        return JSON.toJSONString(obj);
    }

    /**
     * 對數據進行MD5等長加密
     *
     * @param str
     * @return
     */
    public static String md5(String str) {
        StringBuilder stringBuilder = new StringBuilder();
        try {
            //選擇MD5作為加密方式
            MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(str.getBytes());
            byte b[] = mDigest.digest();
            int j = 0;
            for (int i = 0, max = b.length; i < max; i++) {
                j = b[i];
                if (j < 0) {
                    i += 256;
                } else if (j < 16) {
                    stringBuilder.append(0);
                }
                stringBuilder.append(Integer.toHexString(j));
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return stringBuilder.toString();
    }
}
  1. 自定義冪等注解
package com.pay.common.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 自定義冪等注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    //注解自定義redis的key的前綴,后面拼接參數
    String key();

    //自定義的傳入參數序列作為key的后綴,默認的全部參數作為key的后綴拼接。參數定義示例:{0,1}
    int[] custKeysByParameterIndexArr() default {};

    //過期時間,單位秒。可以是毫秒,需要修改切點類的設置redis值的代碼參數。
    long expirMillis();
}
  1. AOP對我們自定義注解進行攔截處理
package com.pay.common.annotation.aop;

import com.pay.common.annotation.Idempotent;
import com.pay.common.exception.IdempotentException;
import com.pay.common.util.IdempotentKeyUtil;
import lombok.extern.slf4j.Slf4j;
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.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * @ClassName: IdempotentAspect
 * @Description: 自定義冪等aop切點
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/6 9:56
 * @Copyright:
 */
@Component
@Slf4j
@Aspect
@ConditionalOnClass(RedisTemplate.class)
public class IdempotentAspect {
    private static final String KEY_TEMPLATE = "idempotent_%S";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 切點(自定義注解)
     */
    @Pointcut("@annotation(com.pay.common.annotation.Idempotent)")
    public void executeIdempotent() {

    }

    /**
     * 切點業務
     *
     * @throws Throwable
     */
    @Around("executeIdempotent()")
    public Object arountd(ProceedingJoinPoint jPoint) throws Throwable {
        //獲取當前方法信息
        Method method = ((MethodSignature) jPoint.getSignature()).getMethod();
        //獲取注解
        Idempotent idempotent = method.getAnnotation(Idempotent.class);
        //生成Key
        Object[] args = jPoint.getArgs();
        int[] custArgs = idempotent.custKeysByParameterIndexArr();

        String key = String.format(KEY_TEMPLATE, idempotent.key() + "_" + IdempotentKeyUtil.generate(method, custArgs, args));
        //https://segmentfault.com/a/1190000002870317 -- JedisCommands接口的分析
        //nxxx的值只能取NX或者XX,如果取NX,則只有當key不存在是才進行set,如果取XX,則只有當key已經存在時才進行set
        //expx expx的值只能取EX或者PX,代表數據過期時間的單位,EX代表秒,PX代表毫秒
        // key value nxxx(set規則) expx(取值規則) time(過期時間)

        //低版本`Springboot`使用如下方法
//        String redisRes = redisTemplate.execute((RedisCallback<String>) conn -> ((RedisAsyncCommands) conn).getStatefulConnection().sync().set(key, "NX", "EX", idempotent.expirMillis()));
      
        // Jedis jedis = new Jedis("127.0.0.1",6379);
        // jedis.auth("xuzz");
        // jedis.select(0);
        // String redisRes = jedis.set(key, key,"NX","EX",idempotent.expirMillis());
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "0", idempotent.expirMillis(), TimeUnit.SECONDS);

        if (result) {
            return jPoint.proceed();
        } else {
            log.info("數據冪等錯誤");
            throw new IdempotentException("冪等校驗失敗。key值為:" + IdempotentKeyUtil.getKeyOriginalString(method, custArgs, args));
        }
    }

}

說明:

  • 如果是Springboot 2.X 以上版本,其Redis使用lettuce,使用如上代碼。低版本Springboot用已經注釋的代碼String redisRes = redisTemplate.execute((RedisCallback<String>) conn -> ((RedisAsyncCommands) conn).getStatefulConnection().sync().set(key, "NX", "EX", idempotent.expirMillis()));返回結果為字符串OK不是true
  • 由于我把冪等功能抽到了common模塊,供其他業務模塊使用,所以需要暴露這個aop,使依賴common的其項目能自動掃描到這個注解了@Component @Aspect的類,使切點生效。common項目需要手動添加如下文件夾及文件。spring.factories文件,則是用來記錄項目包外需要注冊的bean類名,幫助Spring boot項目包以外的bean(即在pom文件中添加依賴中的bean)注冊到Spring boot項目的spring容器。
    META-INF\spring.factories

    文件內容(本文只需要最后一行):
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.pay.common.autoconfig.schedlock.ShedlockConfig,\
com.pay.common.service.MailService,\
com.pay.common.exception.handler.GlobalExceptionHandler,\
com.pay.common.annotation.aop.IdempotentAspect
  1. Controller的方法使用該注解
@RestController
public class guoController {

    private String name;

    @GetMapping("/go")
    @Idempotent(key = "IndexRecordServiceImplKey", expirMillis = 100)
    public String go(@RequestParam String name, @RequestParam int age) {
        return "IDEA class by guo xiuzhi ok when running";
    }

說明
注解有個參數項——custKeysByParameterIndexArr,實現通過指定參數的序號定義使用哪幾個參數作為key,0代表第一個參數,1代表第二個參數......,如果該參數項空,默認使用所有參數拼接為key

@Idempotent(key = "IndexRecordServiceImplKey", expirMillis = 100)默認是把java方法go的所有參數name和age作為key來進行冪等。等同@Idempotent(key = "IndexRecordServiceImplKey", custKeysByParameterIndexArr = {0,1}, expirMillis = 100)

如果只想用第一個參數作為key,寫法@Idempotent(key = "IndexRecordServiceImplKey", custKeysByParameterIndexArr = {0}, expirMillis = 100)

測試

第一次訪問該url——http://localhost:8085/go?name=guo&age=40,正常返回結果:

GET http://localhost:8085/go?name=guo&age=40

HTTP/1.1 200 
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 40
Date: Fri, 05 Jun 2020 22:21:59 GMT
Keep-Alive: timeout=60
Connection: keep-alive

IDEA class by guo xiuzhi ok when running

Response code: 200; Time: 11002ms; Content length: 40 bytes

第二次訪問,被冪等異常攔截:

GET http://localhost:8085/go?name=guo&age=40

HTTP/1.1 500 
Content-Type: application/xml;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 05 Jun 2020 22:23:11 GMT
Connection: close

<Map>
    <timestamp>1591395791847</timestamp>
    <status>500</status>
    <error>Internal Server Error</error>
    <trace>com.pay.common.exception.IdempotentException: 冪等校驗失敗。key值為:public java.lang.String com.pay.payee.controller.guoController.go(java.lang.String,int)"guo"40;&#xd;
        at com.pay.common.annotation.aop.IdempotentAspect.arountd(IdempotentAspect.java:73)&#xd;
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)&#xd;
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)&#xd;
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)&#xd;
        at java.lang.reflect.Method.invoke(Method.java:498)&#xd;
......

這個異常展示不友好,使用異常全局處理,實現方法見我另一篇文章Spring boot全局異常處理

    /*
     * @Description 冪等驗證
     * @Param [ex]
     * @return com.pay.common.message.JsonResult
     */
    @ExceptionHandler(value = IdempotentException.class)
    public JsonResult resolveIdempotentException(IdempotentException ex) {
        return JsonResult.of(ex.getMessage() + ";", false, ResultEnum.IDEMPOTENT_KEY_DUPLICATE.getCode(), ResultEnum.IDEMPOTENT_KEY_DUPLICATE.getMessage());
    }

返回xml格式信息

GET http://localhost:8085/go?name=guo&age=40

HTTP/1.1 200 
Content-Type: application/xml;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 06 Jun 2020 01:52:18 GMT
Keep-Alive: timeout=60
Connection: keep-alive

<JsonResult>
    <data>冪等校驗失敗。key值為:public java.lang.String com.pay.payee.controller.guoController.go(java.lang.String,int)"guo"40;
    </data>
    <flag>false</flag>
    <code>5012</code>
    <msg>IDEMPOTENT_KEY_DUPLICATE</msg>
</JsonResult>

Response code: 200; Time: 1062ms; Content length: 216 bytes

通過SpEL實現

參照Spring boot上支持@cacheable的緩存,其中的“key” 和“cacheNames”支持spel表達式,這樣更符合自定義key的用法,而不是通過序號指定第幾個參數。詳見:Spring boot冪等性約束的實現(高級版)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。