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;
@Autowired
public 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]是value
String 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;
@Autowired
public void setRedisUtil(RedisUtil redisUtil) {
this.redisUtil = redisUtil;
}
/**
* 创建token
*
* @return
*/
@Override
public 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
*/
@Override
public boolean validToken(HttpServletRequest request) {
String token = request.getHeader(Constant.IDEMPOTENT_TOKEN_HEADER);
// header中不存在token
if (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: false
host: 127.0.0.1
port: 6379
database: 0
timeout: 1000
password:
lettuce:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-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;
@Autowired
public void setTokenService(TokenService tokenService) {
this.tokenService = tokenService;
}
/**
* 预处理
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public 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;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public 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 {
@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;
/**
* 添加拦截器
*
* @param registry
*/
@Override
public 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 再次请求
返回重复请求。