處理介面冪等性的兩種常見方案|手把手教你

語言: CN / TW / HK

松哥最近正在錄製 TienChin 專案影片~採用 Spring Boot+Vue3 技術棧,裡邊會涉及到各種好玩的技術,小夥伴們來和松哥一起做一個完成率超 90% 的專案,戳戳戳這裡--> TienChin 專案配套影片來啦

在上週釋出的 TienChin 專案影片中,我和大家一共梳理了六種冪等性解決方案,介面冪等性處理算是一個非常常見的需求了,我們在很多專案中其實都會遇到。今天我們來看看兩種比較簡單的實現思路。

1. 介面冪等性實現方案梳理

其實介面冪等性的實現方案還是蠻多的,我這裡和小夥伴們分享兩種比較常見的方案。

1.1 基於 Token

基於 Token 這種方案的實現思路很簡單,整個流程分兩步:

  1. 客戶端傳送請求,從服務端獲取一個 Token 令牌,每次請求獲取到的都是一個全新的令牌。

  2. 客戶端傳送請求的時候,攜帶上第一步的令牌,處理請求之前,先校驗令牌是否存在,當請求處理成功,就把令牌刪除掉。

大致的思路就是上面這樣,當然具體的實現則會複雜很多,有很多細節需要注意,松哥之前也專門錄過這種方案的影片,小夥伴們可以參考下,錄了兩個影片,一個是基於攔截器處理的,還有一個是基於 AOP 切面處理的:

基於攔截器處理(影片一):

基於 AOP 切面處理(影片二):

1.2 基於請求引數校驗

最近在 TienChin 專案中使用的是另外一種方案,這種方案是基於請求引數來判斷的,如果在短時間內,同一個介面接收到的請求引數相同,那麼就認為這是重複的請求,拒絕處理,大致上就是這麼個思路。

相比於第一種方案,第二種方案相對來說省事一些,因為只有一次請求,不需要專門去服務端拿令牌。在高併發環境下這種方案優勢比較明顯。

所以今天我就來和大家聊聊第二種方案的實現,後面在 TienChin 專案影片中也會和大家細講。

2. 基於請求引數的校驗

首先我們新建一個 Spring Boot 專案,引入 Web 和 Redis 依賴,新建完成後,先來配置一下 Redis 的基本資訊,如下:

spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=123

為了後續 Redis 操作方便,我們再來對 Redis 進行一個簡單封裝,如下:

@Component
public class RedisCache {
@Autowired
public RedisTemplate redisTemplate;
public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
public <T> T getCacheObject(final String key) {
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
}

這個比較簡單,一個存資料,一個讀資料。

接下來我們自定義一個註解,在需要進行冪等性處理的介面上,新增該註解即可,將來這個介面就會自動的進行冪等性處理。

@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 間隔時間(ms),小於此時間視為重複提交
*/

public int interval() default 5000;

/**
* 提示訊息
*/

public String message() default "不允許重複提交,請稍候再試";
}

這個註解我們通過攔截器來進行解析,解析程式碼如下:

public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null) {
if (this.isRepeatSubmit(request, annotation)) {
Map<String, Object> map = new HashMap<>();
map.put("status", 500);
map.put("msg", annotation.message());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(map));
return false;
}
}
return true;
} else {
return true;
}
}

/**
* 驗證是否重複提交由子類實現具體的防重複提交的規則
*
* @param request
* @return
* @throws Exception
*/

public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}

這個攔截器是一個抽象類,將介面方法攔截下來,然後找到介面上的 @RepeatSubmit 註解,呼叫 isRepeatSubmit 方法去判斷是否是重複提交的資料,該方法在這裡是一個抽象方法,我們需要再定義一個類繼承自這個抽象類,在新的子類中,可以有不同的冪等性判斷邏輯,這裡我們就是根據 URL 地址+引數 來判斷冪等性條件是否滿足:

@Component
public class SameUrlDataInterceptor extends RepeatSubmitInterceptor {
public final String REPEAT_PARAMS = "repeatParams";

public final String REPEAT_TIME = "repeatTime";
public final static String REPEAT_SUBMIT_KEY = "REPEAT_SUBMIT_KEY";

private String header = "Authorization";

@Autowired
private RedisCache redisCache;

@SuppressWarnings("unchecked")
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation) {
String nowParams = "";
if (request instanceof RepeatedlyRequestWrapper) {
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
try {
nowParams = repeatedlyRequest.getReader().readLine();
} catch (IOException e) {
e.printStackTrace();
}
}

// body引數為空,獲取Parameter的資料
if (StringUtils.isEmpty(nowParams)) {
try {
nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap());
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
Map<String, Object> nowDataMap = new HashMap<String, Object>();
nowDataMap.put(REPEAT_PARAMS, nowParams);
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());

// 請求地址(作為存放cache的key值)
String url = request.getRequestURI();

// 唯一值(沒有訊息頭則使用請求地址)
String submitKey = request.getHeader(header);

// 唯一標識(指定key + url + 訊息頭)
String cacheRepeatKey = REPEAT_SUBMIT_KEY + url + submitKey;

Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null) {
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
if (compareParams(nowDataMap, sessionMap) && compareTime(nowDataMap, sessionMap, annotation.interval())) {
return true;
}
}
redisCache.setCacheObject(cacheRepeatKey, nowDataMap, annotation.interval(), TimeUnit.MILLISECONDS);
return false;
}

/**
* 判斷引數是否相同
*/

private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap) {
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}

/**
* 判斷兩次間隔時間
*/

private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval) {
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) < interval) {
return true;
}
return false;
}
}

我們來看下具體的實現邏輯:

  1. 首先判斷當前的請求物件是不是 RepeatedlyRequestWrapper,如果是,說明當前的請求引數是 JSON,那麼就通過 IO 流將引數讀取出來,這塊小夥伴們要結合上篇文章共同來理解,否則可能會覺得雲裡霧裡的,傳送門JSON 資料讀一次就沒了,怎麼辦?。

  2. 如果在第一步中,並沒有拿到引數,那麼說明引數可能並不是 JSON 格式,而是 key-value 格式,那麼就以 key-value 的方式讀取出來引數,並將之轉為一個 JSON 字串。

  3. 接下來構造一個 Map,將前面讀取到的引數和當前時間存入到 Map 中。

  4. 接下來構造存到 Redis 中的資料的 key,這個 key 由固定字首 + 請求 URL 地址 + 請求頭的認證令牌組成,這塊請求頭的令牌還是非常重要需要有的,只有這樣才能區分出來當前使用者提交的資料(如果是 RESTful 風格的介面,那麼為了區分,也可以將介面的請求方法作為引數拼接到 key 中)。

  5. 接下來就去 Redis 中獲取資料,獲取到之後,分別去比較引數是否相同以及時間是否過期。

  6. 如果判斷都沒問題,返回 true,表示這個請求重複了。

  7. 否則返回說明這是使用者對這個介面第一次提交資料或者是已經過了時間視窗了,那麼就把引數字串重新快取到 Redis 中,並返回 false,表示請求沒問題。

好啦,做完這一切,最後我們再來配置一下攔截器即可:

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Autowired
RepeatSubmitInterceptor repeatSubmitInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor)
.addPathPatterns("/**");
}
}

如此,我們的介面冪等性就處理好啦~在需要的時候,就可以直接在介面上使用啦:

@RestController
public class HelloController {

@PostMapping("/hello")
@RepeatSubmit(interval = 100000)
public String hello(@RequestBody String msg) {
System.out.println("msg = " + msg);
return "hello";
}
}

好啦,公眾號後臺回覆 RepeatSubmit 可以下載本文原始碼哦。

松哥最近正在錄製 TienChin 專案影片~採用 Spring Boot+Vue3 技術棧,裡邊會涉及到各種好玩的技術,小夥伴們來和松哥一起做一個完成率超 90% 的專案,戳戳戳這裡--> TienChin 專案配套影片來啦