Spring Boot實現接口冪等
1、pom依賴
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.5.6</version><relativePath/></parent><groupId>com.example</groupId><artifactId>idempotent_demo</artifactId><version>0.0.1-SNAPSHOT</version><name>idempotent_demo</name><description>idempotent_demo</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><!--springboot data redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><!-- StringUtils工具類 --><dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.5</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><!--fastjson--><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>2.0.25</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
2、Redis工具類
package com.example.idempotent_demo.util;import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;import java.util.Arrays;
import java.util.concurrent.TimeUnit;/*** @author tom* Redis工具類*/
@Slf4j
@Component
public class RedisUtil {private StringRedisTemplate stringRedisTemplate;@Autowiredpublic void setRedisTemplate(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}/*** 將key和value存入redis** @param key redis的key* @param value redis的value* @param expireTime key過期時間* @return 保存進redis是否成功*/public boolean save(String key, String value, Long expireTime) {try {// 存儲Token到Redis,且設置過期時間為5分鐘stringRedisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);} catch (Exception e) {e.printStackTrace();return false;}return true;}/*** 驗證key和value并刪除key** @param key redis的key* @param value redis的value* @return 驗證是否成功*/public boolean valid(String key, String value) {// 設置Lua腳本,其中KEYS[1]是key,KEYS[2]是valueString script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);// 執行Lua腳本Long result = stringRedisTemplate.execute(redisScript, Arrays.asList(key, value));// 根據返回結果判斷是否成功成功匹配并刪除Redis鍵值對,若果結果不為空和0,則驗證通過if (null != result && result != 0L) {log.info("驗證 key={},value={} 成功", key, value);return true;}log.error("驗證 key={},value={} 失敗", key, value);return false;}
}
3、Token服務類
token 服務,里面主要是兩個方法,一個用來創建 token,一個用來驗證 token。
package com.example.idempotent_demo.service;import javax.servlet.http.HttpServletRequest;/*** @author tom*/
public interface TokenService {/*** 創建token** @return*/String generateToken();/*** 檢驗token** @param request* @return*/boolean validToken(HttpServletRequest request);}
package com.example.idempotent_demo.service.impl;import com.example.idempotent_demo.constant.Constant;
import com.example.idempotent_demo.exception.NoTokenException;
import com.example.idempotent_demo.exception.ValidTokenException;
import com.example.idempotent_demo.service.TokenService;
import com.example.idempotent_demo.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;import javax.servlet.http.HttpServletRequest;
import java.util.UUID;/*** @author tom*/
@Service
@Slf4j
public class TokenServiceImpl implements TokenService {private RedisUtil redisUtil;@Autowiredpublic void setRedisUtil(RedisUtil redisUtil) {this.redisUtil = redisUtil;}/*** 創建token** @return*/@Overridepublic String generateToken() {// 實例化生成ID工具對象String uuid = UUID.randomUUID().toString();String token = Constant.IDEMPOTENT_TOKEN_PREFIX + uuid;boolean success = redisUtil.save(token, token, 5L);if (success) {log.info("save token {} to redis success", token);return token;}log.error("save token {} to redis fail", token);return null;}/*** 檢驗token** @param request* @return*/@Overridepublic boolean validToken(HttpServletRequest request) {String token = request.getHeader(Constant.IDEMPOTENT_TOKEN_HEADER);// header中不存在tokenif (StringUtils.isBlank(token)) {log.error("用戶未攜帶token!");throw new NoTokenException();}// 驗證token失敗if (!redisUtil.valid(token, token)) {log.error("重復提交!");throw new ValidTokenException();}return true;}
}
redis.get(token) 、token.equals 、redis.del(token) 如果這幾個操作不是原子,可能導致,高并發下,都get到同
樣的數據,判斷都成功,繼續業務并發執行。這里 redis 使用 lua 腳本完成這個操作:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
package com.example.idempotent_demo.exception;/*** 用戶為攜帶token* @author tom*/
public class NoTokenException extends RuntimeException {public NoTokenException() {super();}
}
package com.example.idempotent_demo.exception;/*** @author* 驗證token失敗*/
public class ValidTokenException extends RuntimeException{public ValidTokenException(){super();}
}
package com.example.idempotent_demo.util;/*** @author 結果集返回封裝*/
public class ResponseResult {/*** 響應業務狀態*/private Integer code;/*** 響應消息*/private String msg;/*** 響應中的數據*/private Object data;public Integer getCode() {return code;}public void setCode(Integer code) {this.code = code;}public String getMsg() {return msg;}public void setMsg(String msg) {this.msg = msg;}public Object getData() {return data;}public void setData(Object data) {this.data = data;}/*** 無參構造方法*/public ResponseResult() {}/*** 全參構造方法** @param code* @param msg* @param data*/public ResponseResult(Integer code, String msg, Object data) {this.code = code;this.msg = msg;this.data = data;}}
package com.example.idempotent_demo.constant;/*** @author tom*/
public class Constant {/*** 存入Redis的Token鍵的前綴*/public static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";/*** 請求頭的token名稱*/public static final String IDEMPOTENT_TOKEN_HEADER = "idempotent_token";
}
4、Redis配置
spring:redis:ssl: falsehost: 127.0.0.1port: 6379database: 0timeout: 1000password:lettuce:pool:max-active: 100max-wait: -1min-idle: 0max-idle: 20
server:servlet:encoding:charset: UTF-8
5、自定義注解
自定義一個注解,定義此注解的主要目的是把它添加在需要實現冪等的方法上,凡是某個方法注解了它,都會實現
自動冪等。
后臺利用反射如果掃描到這個注解,就會處理這個方法實現自動冪等,使用元注解 ElementType.METHOD
表示它
只能放在方法上,EetentionPolicy.RUNTIME
表示它在運行時。
package com.example.idempotent_demo.annotation;import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;/*** @author tom*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}
6、攔截器配置
主要的功能是攔截掃描到 AutoIdempotent 注解的方法,然后調用 TokenService 的 validToken方法校驗 token
是否正確,如果捕捉到異常就將異常信息渲染成json返回給前端。
package com.example.idempotent_demo.config;import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.example.idempotent_demo.annotation.AutoIdempotent;
import com.example.idempotent_demo.exception.NoTokenException;
import com.example.idempotent_demo.exception.ValidTokenException;
import com.example.idempotent_demo.service.TokenService;
import com.example.idempotent_demo.util.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;/*** @author tom*/
@Slf4j
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {private TokenService tokenService;@Autowiredpublic void setTokenService(TokenService tokenService) {this.tokenService = tokenService;}/*** 預處理** @param request* @param response* @param handler* @return* @throws Exception*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (!(handler instanceof HandlerMethod)) {return true;}HandlerMethod handlerMethod = (HandlerMethod) handler;Method method = handlerMethod.getMethod();// 被AutoIdempotent注解標記的方法AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);if (methodAnnotation != null) {try {// 冪等性校驗,校驗通過則放行,校驗失敗則拋出異常,并通過統一異常處理返回友好提示return tokenService.validToken(request);} catch (NoTokenException ex) {log.error("用戶未攜帶token!");returnJson(response, JSON.toJSONString(new ResponseResult(10001, "用戶未攜帶token!", null), SerializerFeature.WriteMapNullValue));return false;} catch (ValidTokenException ex) {log.error("重復提交!");returnJson(response, JSON.toJSONString(new ResponseResult(10002, "重復提交!", null), SerializerFeature.WriteMapNullValue));return false;}}//必須返回true,否則會被攔截一切請求return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {}private void returnJson(HttpServletResponse response, String json) {PrintWriter writer = null;response.setCharacterEncoding("UTF-8");response.setContentType("application/json; charset=utf-8");try {writer = response.getWriter();writer.print(json);} catch (IOException e) {e.printStackTrace();} finally {if (writer != null) {writer.close();}}}
}
7、注冊攔截器
添加autoIdempotentInterceptor
到配置類中,這樣我們到攔截器才能生效,注意使用@Configuration
注解,
這樣在容器啟動是時候就可以添加進入context中。
package com.example.idempotent_demo.config;import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import javax.annotation.Resource;/*** @author*/
@Configuration
public class WebConfiguration implements WebMvcConfigurer {@Resourceprivate AutoIdempotentInterceptor autoIdempotentInterceptor;/*** 添加攔截器** @param registry*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(autoIdempotentInterceptor);}
}
8、啟動類
package com.example.idempotent_demo;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;/*** @author tom*/
@SpringBootApplication
public class IdempotentDemoApplication {public static void main(String[] args) {SpringApplication.run(IdempotentDemoApplication.class, args);}}
9、測試
9.1 生成token
請求生成了 token。
9.2 redis查看生成的token
redis 中生成了 token。
9.3 無header請求
請求需要攜帶token。
9.4 正常請求
請求成功。
9.5 再次查看redis
發現該 token 已經被刪除了。
9.5 再次請求
返回重復請求。