幂等性,就是只多次操作的结果是一致的。这里可能有人会有疑问。 问:为什么要多次操作结果都一致呢?比如我查询数据,每次查出来的都一样,即使我修改了每次查出来的也都要一样吗? 答:我们说的多次,是指同一次请求中的多次操作。这个多次操作可能会在如下情况发生: 前端重复提交。比如这个业务处理需要2秒钟,我在2秒之内,提交按钮连续点了3次,如果非幂等性接口,那么后端就会处理3次。如果是查询,自然是没有影响的,因为查询本身就是幂等操作,但如果是新增,本来只是新增1条记录的,连点3次,就增加了3条,这显然不行。 响应超时而导致请求重试:在微服务相互调用的过程中,假如订单服务调用支付服务,支付服务支付成功了,但是订单服务接收支付服务返回的信息时超时了,于是订单服务进行重试,又去请求支付服务,结果支付服务又扣了一遍用户的钱。如果真这样的话,用户估计早就提着砍刀来了。
经过上面的描述,相信大家已经清楚了什么叫接口幂等性及其重要性。那么如何设计呢?大致有以下几种方案: 数据库记录状态机制:即每次操作前先查询状态,根据数据库记录的状态来判断是否要继续执行操作。比如订单服务调用支付服务,每次调用之前,先查询该笔订单的支付状态,从而避免重复操作。 token机制:请求业务接口之前,先请求token接口(会将生成的token放入redis中)获取一个token,然后请求业务接口时,带上token。在进行业务操作之前,我们先获取请求中携带的token,看看在redis中是否有该token,有的话,就删除,删除成功说明token校验通过,并且继续执行业务操作;如果redis中没有该token,说明已经被删除了,也就是已经执行过业务操作了,就不让其再进行业务操作。大致流程如下:

 1、pom.xml:主要是引入了redis相关依赖 <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、业务代码: ·新建一个枚举,列出常用返回信息,如下: @Getter @AllArgsConstructor public enum ResultEnum { REPEATREQUEST(405, "重复请求"), OPERATEEXCEPTION(406, "操作异常"), HEADERNOTOKEN(407, "请求头未携带token"), ERRORTOKEN(408, "token正确") ; private Integer code; private String msg; }
·新建一个JsonUtil,当请求异常时往页面中输出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(); } } } ·新建一个RedisUtil,用来操作redis:@Component public class RedisUtil {
private RedisUtil() {}
private static RedisTemplate redisTemplate;
@Autowired public void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) { redisTemplate.setKeySerializer(new StringRedisSerializer()); //设置序列化Value的实例化对象 redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); RedisUtil.redisTemplate = redisTemplate; }
/** * 设置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); }
/** * 设置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); } } ·新建一个TokenUtil,用来生成和校验token:生成token没什么好说的,这里为了简单直接用uuid生成,然后放入redis中。校验token,如果用户没有携带token,直接返回false;如果携带了token,但是redis中没有这个token,说明已经被删除了,即已经访问了,返回false;如果redis中有,但是redis中的token和用户携带的token不一致,也返回false;有且一致,说明是第一次访问,就将redis中的token删除,然后返回true。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); }
/** * 校验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,说明已经访问成功过了,直接返回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 { } 接着要新建一个拦截器,对有@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) { } } 然后将这个拦截器配置到spring中去:@Configuration public class InterceptorConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(idempotentInterceptor()) .addPathPatterns("/**"); } @Bean public IdempotentInterceptor idempotentInterceptor() { return new IdempotentInterceptor(); }
}
·最后新建一个controller,就可以愉快地进行测试了: @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 ,不需要什么校验,访问/hello ,就会自动幂等,每一次访问都要先获取token,一个token不能用两次。
|