概念:
幂等性,通俗的说就是一个接口,多次发起同一个请求,必须保证操作只能执行一次
比如:
- 订单接口,不能多次创建订单
- 支付接口,重复支付同一笔订单只能扣一次钱
- 支付宝回调接口,可能会多次回调,必须处理重复回调
- 普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次
等等…
常见解决方案有:
-
唯一索引 – 防止新增脏数据
-
token机制 – 防止页面重复提交
-
悲观锁 – 获取数据的时候加锁(锁表或锁行)
-
乐观锁 – 基于版本号version实现,在更新数据那一刻校验数据
-
分布式锁 – redis(jedis、redisson)或zookeeper实现
等等
流程:
- 当页面加载的时候通过接口获取token(UUID)
- 当访问接口时,会经过拦截器,如果发现该接口有自定义的幂等检查注解,说明该接口需要验证幂等性
- 查看请求头里是否有key=token的值,如果有,并且删除成功,那么接口就访问成功,否则为重复提交
- 如果发现该接口没有自定义的幂等检查注解,则直接放行
代码
自定义注解 MidengCheck
- 自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。
- 后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等
- 使用元注解ElementType.METHOD表示它只能放在方法上,RetentionPolicy.RUNTIME表示它在运行时。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义注解做接口的幂等校验 annotation
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MidengCheck {
}
service层 redis工具类 RedisService
/**
* Redis工具服务类
*/
@Component
public class RedisService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 写入缓存
* @param key
* @param value
* @return
*/
public boolean set(final String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入缓存设置时效时间
* @param key
* @param value
* @param expireTime
* @return
*/
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 根据key判断缓存中是否有对应的value
* @param key
* @return
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
/**
* 根据key读取缓存
* @param key
* @return
*/
public Object get(final String key) {
Object result = null;
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
result = operations.get(key);
return result;
}
/**
* 根据key删除对应的value
* @param key
* @return
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}
token创建和检验 TokenService
- token引用了redis服务,创建token采用随机算法工具类生成随机uuid字符串,然后放入到redis中(为了防止数据的冗余保留,这里设置过期时间为10000秒,具体可视业务而定),如果放入成功,最后返回这个token值。
- checkToken方法就是从header中获取token到值,如若不存在,直接抛出异常(这个异常信息可以被拦截器捕捉到,然后返回给前端),如存在,则查询Redis看是否存在,不存在也抛出异常,Redis存在,那么token校验通过。
/**
* 获取和校验Token
*/
@Component
public class TokenService {
@Autowired
private RedisService redisService;
//获取token
public String getToken() {
String uuid = UUID.randomUUID().toString();
//存入Redis的key添加一个统一前缀字符串
String token = "mideng_check_prefix:" + uuid;
//存入Redis
boolean result = redisService.setEx(token, uuid, 10000L);
if(result){
return token;
}
return null;
}
public boolean checkToken(HttpServletRequest request) throws Exception {
//从请求头中获取token的值
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
//请求头中不存在token,那就是非法请求,直接抛出异常
throw new Exception("Illegal request");
}
if (!redisService.exists(token)) {
//请求头中存在token,但是Redis中不存在的话,也抛出异常
throw new Exception("token error");
}
//代码执行到这里,说明token校验成功了,那么就需要删除Redis中的值
boolean remove = redisService.remove(token);
if (!remove) {
//删除失败了
throw new Exception("token delete error");
}
return true;
}
}
拦截器的配置
web配置类,实现WebMvcConfigurationSupport,主要作用就是添加CheckIdempotentInterceptor拦截器到配置类中,这样我们写的拦截器才能生效,注意使用@Configuration注解,这样在容器启动是时候就可以添加进入context中
import org.colin.interceptor.CheckIdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import javax.annotation.Resource;
/**
* @ClassName: WebConfiguration
* @description: 统一拦截器配置类
* @Version 1.1.0.1
*/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Resource
private CheckIdempotentInterceptor checkIdempotentInterceptor;
//添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
//checkIdempotentInterceptor拦截器只对 /saveUser 请求拦截
registry.addInterceptor(checkIdempotentInterceptor).addPathPatterns("/saveUser");
super.addInterceptors(registry);
}
}
下面我们就开始写拦截处理器,主要的功能是拦截到被CheckIdempotent注解的方法,然后调用tokenService的checkToken()方法校验token是否正确,如果捕捉到异常就将异常信息返回给前端。
/**
* 幂等校验的拦截器
*/
@Component
public class MidengCheckInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
/**
* 前置处理
* 该方法将在请求处理之前进行调用
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//handler instanceof HandlerMethod:作用:判断请求是否是请求的方法(有些请求是请求的静态资源)
if (!(handler instanceof HandlerMethod)) {
//直接放行:放行非方法的请求
return true;
}
//代码运行到此处,说明请求的资源就是方法了
HandlerMethod handlerMethod = (HandlerMethod) handler;
//handlerMethod.getMethod() 获取到请求的方法对象
Method method = handlerMethod.getMethod();
//获取请求方法上面的 MidengCheck 注解
MidengCheck methodAnnotation = method.getAnnotation(MidengCheck.class);
if (methodAnnotation != null) {
//进到此处,表示请求的方法上面打了 MidengCheck 注解
//此时我就需要做接口幂等性校验了
try {
return tokenService.checkToken(request);
}catch (Exception ex){
writeJson(response, ex.getMessage());
return false;
}
}
//必须返回true,否则会拦截掉所有请求,不会执行controller方法中的内容了
return true;
}
/**
* 返回提示信息给前端
*/
private void writeJson(HttpServletResponse response, String message){
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
response.setStatus(404);
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.print(message);
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (outputStream != null){
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
启动类
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class);
}
}
yml配置文件
server:
port: 1010
spring:
#redis配置
redis:
#使用第几个数据库(0-15)
database: 0
host: 127.0.0.1
port: 6379
#password: 123456 #密码
timeout: 5000
测试类
@RestController
@Slf4j
public class TestController {
@Autowired
private TokenService tokenService;
//获取token值
@GetMapping("/getToken")
public String getToken(){
return tokenService.getToken();
}
//保存用户信息
@PostMapping("/saveUser")
@MidengCheck
public String saveUser(){
//我们希望的情况是:打印一次
log.info("----------------------------保存用户信息成功----------------------------");
//保存用户信息的业务逻辑代码-省略....
return "add user success";
}
}
测试
1.浏览器输入 http://localhost:1100/getToken 获取token:mideng_check_prefix:7c29f136-9b39-4786-a15d-509428681364
2.使用Apache JMeter 测压工具 请求一百次
发现只会成功一次
总结:
- 编写自定义注解,不需要参数
- 编写拦截器,作用:做接口幂等性校验
- 不是请求方法的请求,全部放行
- 请求方法的这些请求中,需要做过滤,过滤出包含自定义注解的方法,其余的方法全部放行
- 获取请求头中的token值,然后做非空判断、redis是否存在判断,根据该token删除Redis的值:如果删除成功,放行,如果删除失败,那么就抛出异常,不放行。