概念
在我们开发的项目中,对外暴露的接口往往面临多次请求。
我们来解释一下幂等的概念:任意多次请求产生的结果与一次执行的结果相同。
按照这个含义就是对数据库的影响只能是一次性的,不能重复处理。
那么我们如何保证幂等性呢,通常有以下手段:
1、数据库建议唯一性索引,可以保证最终插入数据库的只有一条数据
2、token机制,每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token,由于token被删除了,认定此次请求为重复请求。
3、悲观锁或者乐观锁,悲观锁可以保证每次for update的时候其他sql无法update数据(在数据库引擎是innodb的时候,select的条件必须是唯一索引,防止锁全表)
4、先查询后判断,首先通过查询数据库是否存在数据,如果存在证明已经请求过了,直接拒绝该请求,如果没有存在,就证明是第一次进来,直接放行。
实现
1、首先搭建Redis服务,直接启动Redis服务即可。
2、创建SpringBoot项目,Pom文件加入依赖:
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.1</version>
</dependency>
3、封装Redis工具
package com.itjing.redis.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;
/**
* @author lijing
* @date 2022年05月26日 10:19
* @description 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
* @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;
}
/**
* 判断缓存中是否有对应的value
*
* @param key
* @return
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(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;
}
/**
* 删除对应的value
*
* @param key
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}
4、自定义注解
package com.itjing.redis.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author lijing
* @date 2022年05月26日 10:16
* @description 幂等注解
* 定义此注解的主要目的是把它添加在需要实现幂等的方法上
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}
5、token创建和检验
package com.itjing.redis.service;
import javax.servlet.http.HttpServletRequest;
/**
* @author lijing
* @date 2022年05月26日 10:17
* @description
*/
public interface TokenService {
/**
* 创建token
*
* @return
*/
String createToken();
/**
* 检验token
*
* @param request
* @return
*/
boolean checkToken(HttpServletRequest request) throws Exception;
}
package com.itjing.redis.service.impl;
import cn.hutool.core.text.StrBuilder;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import com.itjing.redis.common.Constant;
import com.itjing.redis.common.ResponseCode;
import com.itjing.redis.exception.ServiceException;
import com.itjing.redis.service.TokenService;
import com.itjing.redis.utils.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
/**
* @author lijing
* @date 2022年05月26日 10:18
* @description
*/
@Service
public class TokenServiceImpl implements TokenService {
@Autowired
private RedisService redisService;
/**
* 创建token
*
* @return
*/
@Override
public String createToken() {
String str = RandomUtil.randomString(16);
StrBuilder token = new StrBuilder();
try {
token.append(Constant.Redis.TOKEN_PREFIX).append(str);
redisService.setEx(token.toString(), token.toString(), 10000L);
boolean notEmpty = StrUtil.isNotEmpty(token.toString());
if (notEmpty) {
return token.toString();
}
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
/**
* 检验token
*
* @param request
* @return
*/
@Override
public boolean checkToken(HttpServletRequest request) throws Exception {
String token = request.getHeader(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {// header中不存在token
token = request.getParameter(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {// parameter中也不存在token
throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg(), ResponseCode.ILLEGAL_ARGUMENT.getCode());
}
}
if (!redisService.exists(token)) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg(), ResponseCode.REPETITIVE_OPERATION.getCode());
}
boolean remove = redisService.remove(token);
// 注意删除后一定要校验是否删除
// 不能单纯的直接删除token而不校验是否删除成功,
// 会出现并发安全性问题, 因为, 有可能多个线程同时走到此行,
// 此时token还未被删除, 所以继续往下执行, 如果不校验删除结果而直接放行,
// 那么还是会出现重复提交问题, 即使实际上只有一次真正的删除操作
if (!remove) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg(), ResponseCode.REPETITIVE_OPERATION.getCode());
}
return true;
}
}
6、拦截器配置
package com.itjing.redis.interceptor;
import com.itjing.redis.annotation.AutoIdempotent;
import com.itjing.redis.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
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 lijing
* @date 2022年05月26日 10:52
* @description 接口幂等性拦截器
*/
public class AutoIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private 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) {
// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
return tokenService.checkToken(request);
}
// 必须返回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 {
}
/**
* 返回的json值
*
* @param response
* @param json
* @throws Exception
*/
private void writeReturnJson(HttpServletResponse response, String json) throws Exception {
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try {
writer = response.getWriter();
writer.print(json);
} catch (IOException e) {
} finally {
if (writer != null)
writer.close();
}
}
}
package com.itjing.redis.config;
import com.itjing.redis.interceptor.AutoIdempotentInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
/**
* @author lijing
* @date 2022年05月26日 10:57
* @description
*/
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
/**
* 添加拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 接口幂等性拦截器
registry.addInterceptor(autoIdempotentInterceptor());
}
@Bean
public AutoIdempotentInterceptor autoIdempotentInterceptor() {
return new AutoIdempotentInterceptor();
}
}
7、测试用例
package com.itjing.redis.controller;
import com.itjing.redis.annotation.AutoIdempotent;
import com.itjing.redis.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author lijing
* @date 2022年05月26日 11:01
* @description
*/
@RestController
@RequestMapping("/autoIdempotent")
public class AutoIdempotentController {
@Autowired
private TokenService tokenService;
@GetMapping("/token")
public String token() {
return tokenService.createToken();
}
@AutoIdempotent
@PostMapping("/dealSomething")
public String dealSomething() {
return "success";
}
}
另附相关常量类与枚举类
package com.itjing.redis.common;
/**
* @author lijing
* @date 2022年05月26日 10:33
* @description 常量类
*/
public class Constant {
public static final String TOKEN_NAME = "Authorization";
public interface Redis {
String TOKEN_PREFIX = "token:";
}
}
package com.itjing.redis.common;
/**
* @author lijing
* @date 2022年05月26日 10:35
* @description 响应状态码
*/
public enum ResponseCode {
// 系统模块
SUCCESS(0, "操作成功"),
ERROR(1, "操作失败"),
SERVER_ERROR(500, "服务器异常"),
NO_LOGIN_ERROR(1000, "未登录"),
// 通用模块 1xxxx
ILLEGAL_ARGUMENT(10000, "参数不合法"),
REPETITIVE_OPERATION(10001, "请勿重复操作"),
ACCESS_LIMIT(10002, "请求太频繁, 请稍后再试"),
// 用户模块 2xxxx
NEED_LOGIN(20001, "登录失效"),
USERNAME_OR_PASSWORD_EMPTY(20002, "用户名或密码不能为空"),
USERNAME_OR_PASSWORD_WRONG(20003, "用户名或密码错误"),
USER_NOT_EXISTS(20004, "用户不存在"),
WRONG_PASSWORD(20005, "密码错误"),
// 订单模块
;
ResponseCode(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
private Integer code;
private String msg;
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;
}
}
测试
利用jmeter测试工具模拟50个并发请求, 将获取到的token作为参数。
header或参数均不传token,或者token值为空,或者token值乱填,均无法通过校验。
注意点:在
checkToken
方法最后,不能单纯的直接删除token而不校验是否删除成功,会出现并发安全性问题,因为,有可能多个线程同时走到那块,此时token还未被删除,所以继续往下执行,如果不校验删除结果而直接放行,那么还是会出现重复提交问题,即使实际上只有一次真正的删除操作。
总结
其实思路很简单,就是每次请求保证唯一性,从而保证幂等性,通过拦截器+注解,就不用每次请求都写重复代码,其实也可以利用spring aop实现。
原文始发于微信公众号(程序员阿晶):SpringBoot+Redis实现接口幂等校验
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/19632.html