在实际的项目开发中,对于支持对外访问的接口,很多时候会出现被多次请求的情况,而这些多余的请求可能会对数据库中的数据产生多次影响,导致产异常数据,我们是不希望发生的,因此提出了幂等的概念,所谓幂等,即任意多次执行所产生的影响均与一次执行产生的影响相同。换言之,多次的请求对数据库的影响只能是一次性的,不能重复处理。关于如何保证接口幂等性,通常情况有如下几种方式:
暂以token机制的方式描述接口幂等性,如图,通过Redis实现幂等的简单原理图:
演示环境不再做详细说明:以Java语言为例,采用SpringBoot和Redis
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
server.port=12001
spring.redis.host=192.168.56.10
package com.ideax.idempotence.utils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* redis工具类
*
* @author zhangxs
**/
@Component
public class RedisUtils {
/** @ Autowired默认按照类型装配的。也就是说,要获取RedisTemplate的Bean,要根据名字装配。那么就使用@Resource,它默认按照名字装配 */
@Resource
public RedisTemplate<String, Object> redisTemplate;
/** 如果有需要,把StringRedisTemplate也可以注入 */
private final StringRedisTemplate stringRedisTemplate;
public RedisUtils(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 根据key取value
* @param key k
* @return java.lang.Object
* @author zhangxs
* @date 2021-11-26 16:10
*/
public Object get(final String key) {
return redisTemplate.opsForValue().get(key);
}
/**
* 写入缓存时设置过期时间
* @param key k
* @param value v
* @param timeout 超时时间
* @return boolean
* @author zhangxs
* @date 2021-11-26 15:56
*/
public boolean setExpire(final String key, Object value, Long timeout) {
boolean result = false;
try {
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入缓存
* @param key k
* @param value v
* @return boolean
* @author zhangxs
* @date 2021-11-26 15:57
*/
public boolean set(final String key, Object value) {
boolean result = false;
try {
redisTemplate.opsForValue().set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 根据key取value
* @param key k
* @return boolean
* @author zhangxs
* @date 2021-11-26 15:58
*/
public boolean getStringValue(final String key, String value) {
boolean result = false;
try {
stringRedisTemplate.opsForValue().get(key);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 将String类型的value写入缓存
* @param key k
* @param value String类型的v
* @return boolean
* @author zhangxs
* @date 2021-11-26 15:58
*/
public boolean setStringValue(final String key, String value) {
boolean result = false;
try {
stringRedisTemplate.opsForValue().set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 将String类型的value写入缓存,并设置过期时间
* @param key k
* @param value String类型的v
* @param timeout 超时时间
* @return boolean
* @author zhangxs
* @date 2021-11-26 15:58
*/
public boolean setStringValueExpire(final String key, String value, Long timeout) {
boolean result = false;
try {
stringRedisTemplate.opsForValue().set(key, value, timeout);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 判断是否存在key对应的value
* @param key k
* @return boolean
* @author zhangxs
* @date 2021-11-26 16:08
*/
public boolean exists(final String key) {
// 不能直接通过redisTemplate.hasKey(key)获取结果去判断,拆箱时有可能空指针异常
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
}
/**
* 根据key删除key-value
* @param key k
* @return boolean
* @author zhangxs
* @date 2021-11-26 16:13
*/
public boolean remove(final String key) {
if (exists(key)) {
// 不能直接通过redisTemplate.delete(key)获取结果去判断,拆箱时有可能空指针异常
return Boolean.TRUE.equals(stringRedisTemplate.delete(key));
}
return false;
}
}
自定义一个注解,该注解将会在有幂等性要求的接口上标注,为啥叫@Idempotent这个名字,是由于可读性考虑,采用了幂等俩字的英文单词,您随意。后续将通过反射扫描到这个注解,处理对应请求,实现幂等效果,注解定义如下:
package com.ideax.idempotence.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义幂等注解
*
* @author zhangxs
**/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}
模拟一个token服务,包含两个接口及其实现类,一个用来创建token,一个用来验证token是否正确。创建token暂且简单用一个字符串作为token,并设置一个过期时间。验证token时,我们需要通过请求对象获取请求头信息,进而获取token信息,代码如下:
package com.ideax.idempotence.service;
import javax.servlet.http.HttpServletRequest;
/**
* Token 服务接口
*
* @author zhangxs
**/
public interface TokenService {
/**
* 创建token
* @return java.lang.String
* @author zhangxs
* @date 2021-11-26 16:19
*/
String createToken();
/**
* 校验token
* @param request 请求
* @return boolean
* @author zhangxs
* @date 2021-11-26 16:19
*/
boolean checkToken(HttpServletRequest request);
}
package com.ideax.idempotence.service.impl;
import com.ideax.idempotence.service.TokenService;
import com.ideax.idempotence.utils.RedisUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
/**
* Token 服务接口实现类
*
* @author zhangxs
**/
@Service
public class TokenServiceImpl implements TokenService {
private final RedisUtils redisUtils;
public TokenServiceImpl(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Override
public String createToken() {
String token = UUID.randomUUID().toString().replaceAll("-", "");
redisUtils.setStringValueExpire("idempotent:cache:token:" + token, token, 20000L);
return token;
}
@Override
public boolean checkToken(HttpServletRequest request) {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
token = request.getParameter("token");
if (StringUtils.isEmpty(token)) {
throw new RuntimeException("不正确的token参数!");
}
}
final String key = "idempotent:cache:token:" + token;
if (!redisUtils.exists(key)) {
throw new RuntimeException("重复性操作!");
}
boolean tag = redisUtils.remove(key);
if (!tag) {
throw new RuntimeException("重复性操作!");
}
return true;
}
}
package com.ideax.idempotence.interceptor;
import com.ideax.idempotence.annotation.Idempotent;
import com.ideax.idempotence.service.TokenService;
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 zhangxs
**/
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
private final TokenService tokenService;
public IdempotentInterceptor(TokenService tokenService) {
this.tokenService = tokenService;
}
@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();
// 扫描被@Idempotent标记的方法
Idempotent annotation = method.getAnnotation(Idempotent.class);
if (annotation != null) {
try {
return tokenService.checkToken(request);
} catch (Exception e) {
printResult(response, e.getMessage());
return false;
}
}
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 printResult(HttpServletResponse response, String message) {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try (PrintWriter writer = response.getWriter()) {
writer.print(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
package com.ideax.idempotence.config;
import com.ideax.idempotence.interceptor.IdempotentInterceptor;
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;
/**
* web配置类
*
* @author zhangxs
**/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Resource
private IdempotentInterceptor idempotentInterceptor;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(idempotentInterceptor);
super.addInterceptors(registry);
}
}
这次不用postman了,用一个更牛逼的apipost测试,先模拟一个业务场景,比如商品添加操作,我们并不希望同一个商品添加进来多个请求,因此需要对商品添加接口保证幂等性,代码如下:
package com.ideax.idempotence.controller;
import com.ideax.idempotence.annotation.Idempotent;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 商品 前端控制器
*
* @author zhangxs
**/
@RestController
@RequestMapping("/product")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable("id") int id) {
Map<String,Object> map = new HashMap<>(10);
map.put("id", id);
map.put("serial", UUID.randomUUID().toString().replaceAll("-", ""));
map.put("name", id + "手机");
return ResponseEntity.ok(map);
}
@Idempotent
@PostMapping
public ResponseEntity<Map<String, Object>> save(@RequestBody Map<String, Object> map) {
return ResponseEntity.ok(map);
}
}
上面说到了,在请求时,需要在请求头中携带token,所以在此之前,先获取一个token,代码如下:
package com.ideax.idempotence.controller;
import com.ideax.idempotence.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* token 前端控制器
*
* @author zhangxs
**/
@RestController
@RequestMapping("token")
public class TokenController {
@Autowired
private TokenService tokenService;
@GetMapping
public ResponseEntity<String> getToken() {
return ResponseEntity.ok(tokenService.createToken());
}
}
接口幂等性的保证在实际开发中是非常重要的环节,一个接口被无数客户端访问时,保证幂等性,将保证其操作不影响后台业务处理,数据只影响一次,防止产生脏乱数据,同时在一定程度上还可以减少并发量。