冪等性,就是只多次操作的結(jié)果是一致的。這里可能有人會有疑問。
問:為什么要多次操作結(jié)果都一致呢?比如我查詢數(shù)據(jù),每次查出來的都一樣,即使我修改了每次查出來的也都要一樣嗎?
答:我們說的多次,是指同一次請求中的多次操作。這個多次操作可能會在如下情況發(fā)生:
前端重復(fù)提交。比如這個業(yè)務(wù)處理需要2秒鐘,我在2秒之內(nèi),提交按鈕連續(xù)點(diǎn)了3次,如果非冪等性接口,那么后端就會處理3次。如果是查詢,自然是沒有影響的,因?yàn)椴樵儽旧砭褪莾绲炔僮鳎绻切略?,本來只是新?條記錄的,連點(diǎn)3次,就增加了3條,這顯然不行。
響應(yīng)超時而導(dǎo)致請求重試:在微服務(wù)相互調(diào)用的過程中,假如訂單服務(wù)調(diào)用支付服務(wù),支付服務(wù)支付成功了,但是訂單服務(wù)接收支付服務(wù)返回的信息時超時了,于是訂單服務(wù)進(jìn)行重試,又去請求支付服務(wù),結(jié)果支付服務(wù)又扣了一遍用戶的錢。如果真這樣的話,用戶估計(jì)早就提著砍刀來了。
經(jīng)過上面的描述,相信大家已經(jīng)清楚了什么叫接口冪等性及其重要性。那么如何設(shè)計(jì)呢?大致有以下幾種方案:
數(shù)據(jù)庫記錄狀態(tài)機(jī)制:即每次操作前先查詢狀態(tài),根據(jù)數(shù)據(jù)庫記錄的狀態(tài)來判斷是否要繼續(xù)執(zhí)行操作。比如訂單服務(wù)調(diào)用支付服務(wù),每次調(diào)用之前,先查詢該筆訂單的支付狀態(tài),從而避免重復(fù)操作。
token機(jī)制:請求業(yè)務(wù)接口之前,先請求token接口(會將生成的token放入redis中)獲取一個token,然后請求業(yè)務(wù)接口時,帶上token。在進(jìn)行業(yè)務(wù)操作之前,我們先獲取請求中攜帶的token,看看在redis中是否有該token,有的話,就刪除,刪除成功說明token校驗(yàn)通過,并且繼續(xù)執(zhí)行業(yè)務(wù)操作;如果redis中沒有該token,說明已經(jīng)被刪除了,也就是已經(jīng)執(zhí)行過業(yè)務(wù)操作了,就不讓其再進(jìn)行業(yè)務(wù)操作。大致流程如下:
其他方案:接口冪等性設(shè)計(jì)還有很多其他方案,比如全局唯一id、樂觀鎖等。本文主要講token機(jī)制的使用,若感興趣可以自行研究。
1、pom.xml:主要是引入了redis相關(guān)依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- org.json/json -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20190722</version>
</dependency>
2、application.yml:主要是配置redis
server:
port: 6666
spring:
application:
name: idempotent-api
redis:
host: 192.168.2.43
port: 6379
3、業(yè)務(wù)代碼:
·新建一個枚舉,列出常用返回信息,如下:
@Getter
@AllArgsConstructor
public enum ResultEnum {
REPEATREQUEST(405, "重復(fù)請求"),
OPERATEEXCEPTION(406, "操作異常"),
HEADERNOTOKEN(407, "請求頭未攜帶token"),
ERRORTOKEN(408, "token正確")
;
private Integer code;
private String msg;
}
·新建一個JsonUtil,當(dāng)請求異常時往頁面中輸出json:
public class JsonUtil {
private JsonUtil() {}
public static void writeJsonToPage(HttpServletResponse response, String msg) {
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try {
writer = response.getWriter();
writer.print(msg);
} catch (IOException e) {
} finally {
if (writer != null)
writer.close();
}
}
}
@Component
public class RedisUtil {
private RedisUtil() {}
private static RedisTemplate redisTemplate;
@Autowired
public void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) {
redisTemplate.setKeySerializer(new StringRedisSerializer());
//設(shè)置序列化Value的實(shí)例化對象
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
RedisUtil.redisTemplate = redisTemplate;
}
/**
* 設(shè)置key-value,過期時間為timeout秒
* @param key
* @param value
* @param timeout
*/
public static void setString(String key, String value, Long timeout) {
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
}
/**
* 設(shè)置key-value
* @param key
* @param value
*/
public static void setString(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* 獲取key-value
* @param key
* @return
*/
public static String getString(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
/**
* 判斷key是否存在
* @param key
* @return
*/
public static boolean isExist(String key) {
return redisTemplate.hasKey(key);
}
/**
* 刪除key
* @param key
* @return
*/
public static boolean delKey(String key) {
return redisTemplate.delete(key);
}
}
public class TokenUtil {
private TokenUtil() {}
private static final String KEY = "token";
private static final String CODE = "code";
private static final String MSG = "msg";
private static final String JSON = "json";
private static final String RESULT = "result";
/**
* 生成token并放入redis中
* @return
*/
public static String createToken() {
String token = UUID.randomUUID().toString();
RedisUtil.setString(KEY, token, 60L);
return RedisUtil.getString(KEY);
}
/**
* 校驗(yàn)token
* @param request
* @return
* @throws JSONException
*/
public static Map<String, Object> checkToken(HttpServletRequest request) throws JSONException {
String headerToken = request.getHeader(KEY);
JSONObject json = new JSONObject();
Map<String, Object> resultMap = new HashMap<>();
// 請求頭中沒有攜帶token,直接返回false
if (StringUtils.isEmpty(headerToken)) {
json.put(CODE, ResultEnum.HEADERNOTOKEN.getCode());
json.put(MSG, ResultEnum.HEADERNOTOKEN.getMsg());
resultMap.put(RESULT, false);
resultMap.put(JSON, json.toString());
return resultMap;
}
if (StringUtils.isEmpty(RedisUtil.getString(KEY))) {
// 如果redis中沒有token,說明已經(jīng)訪問成功過了,直接返回false
json.put(CODE, ResultEnum.REPEATREQUEST.getCode());
json.put(MSG, ResultEnum.REPEATREQUEST.getMsg());
resultMap.put(RESULT, false);
resultMap.put(JSON, json.toString());
return resultMap;
} else {
// 如果redis中有token,就刪除掉,刪除成功返回true,刪除失敗返回false
String redisToken = RedisUtil.getString(KEY);
boolean result = false;
if (!redisToken.equals(headerToken)) {
json.put(CODE, ResultEnum.ERRORTOKEN.getCode());
json.put(MSG, ResultEnum.ERRORTOKEN.getMsg());
} else {
result = RedisUtil.delKey(KEY);
String msg = result ? null : ResultEnum.OPERATEEXCEPTION.getMsg();
json.put(CODE, 400);
json.put(MSG, msg);
}
resultMap.put(RESULT, result);
resultMap.put(JSON, json.toString());
return resultMap;
}
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedIdempotent {
}
public class IdempotentInterceptor implements HandlerInterceptor{
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,Object object) throws JSONException {
// 攔截的不是方法,直接放行
if (!(object instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) object;
Method method = handlerMethod.getMethod();
// 如果是方法,并且有@NeedIdempotent注解,就自動冪等
if (method.isAnnotationPresent(NeedIdempotent.class)) {
Map<String, Object> resultMap = TokenUtil.checkToken(httpServletRequest);
boolean result = (boolean) resultMap.get("result");
String json = (String) resultMap.get("json");
if (!result) {
JsonUtil.writeJsonToPage(httpServletResponse, json);
}
return result;
} else {
return true;
}
}
@Override
public void postHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse, Object o,ModelAndView modelAndView) {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object o, Exception e) {
}
}
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(idempotentInterceptor())
.addPathPatterns("/**");
}
@Bean
public IdempotentInterceptor idempotentInterceptor() {
return new IdempotentInterceptor();
}
}
·最后新建一個controller,就可以愉快地進(jìn)行測試了:
@RestController
@RequestMapping("/idempotent")
public class IdempotentApiController {
@NeedIdempotent
@GetMapping("/hello")
public String hello() {
return "are you ok?";
}
@GetMapping("/token")
public String token() {
return TokenUtil.createToken();
}
}
訪問/token
,不需要什么校驗(yàn),訪問/hello
,就會自動冪等,每一次訪問都要先獲取token,一個token不能用兩次。
聯(lián)系客服