关于什么是接口幂等性这里不再赘述,本文将使用分布式锁
来解决接口幂等性的问题。
本文接口幂等判断标准:
String name = IP + 请求方式 + URI + 参数摘要值
当相同的name
来临时,且上一个相同name
对于的接口还未正常执行完毕,则判断为重复调用接口。
举例:
IP为10.10.10.10
的客户端以Get
请求方式调用/test
接口,参数为a=1&b=2
,当第一次调用接口后,服务端还没有处理完成,客户端又重复发起相同的请求,则会被服务端认作重复提交。
本篇文章所使用的相关技术或框架:
技术或框架 | 使用原因 |
---|---|
Spring boot Aop | 对被调用的接口方法进行增强 |
Redission | 存储以及管理锁,主要是使用里面的看门狗机制 |
自定义注解 | 用于标注哪些接口方法需要实现接口幂等性 |
反射 | 在Aop中获取被调用method上面的注解,根据有没有自定义注解来判断该方法是否开启接口幂等性 |
Servlet | 获取客户端的IP、请求方式、请求URI、参数列表 |
Jackson | 将接口参数从Object转为Json格式的字符串 |
MD5 | 根据获得的Json字符串,计算其摘要值 |
前置需求
导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--用于将byte数组转为16进制字符串-->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.14</version>
</dependency>
编写Redisson配置类
@Configuration
public class RedissionConfig {
@Bean
public RedissonClient redisson(){
// 单机模式
Config config = new Config();
config.useSingleServer()
.setAddress("redis://xxx:6379")
.setPassword("xxx")
.setDatabase(0);
return Redisson.create(config);
}
}
具体实现思路
编写自定义注解,写在被调用的method上面,用于标注是否需要开启接口幂等性
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestLimit {
boolean enable() default true;
}
不写该注解,或者写成
@RequestLimit(enable = false)
. ,则对于该方法不会开启接口幂等性
编写Aop,对Controller Method进行增强
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zq.test.annotations.RequestLimit;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
@Slf4j
public class RequestAop {
//当同一用户、相等请求方法、相同URI、相同参数重复请求时,返回值为这个,自己改下
private final Object resultObject=new String("系统繁忙");
@Autowired
private RedissonClient redissonClient;
//切面路径,自己改
@Pointcut(value = "execution(* com.zq.test.controller..*.*(..))")
public void pointCut(){}
@Around(value = "pointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
//获取被调用的方法签名
MethodSignature methodSignature= (MethodSignature) point.getSignature();
//获取被调用的方法
Method method=methodSignature.getMethod();
//获取该方法上面的@RequestLimit注解
RequestLimit requestLimit = method.getAnnotation(RequestLimit.class);
Object result=null;
RLock lock=null;
//如果该方法上面有这个注解,并且没有主动关闭接口幂等性
if(requestLimit!=null&&requestLimit.enable()){
//获取当前进程的ServletRequest对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//获取请求参数名
String[] params = methodSignature.getParameterNames();
//获取请求参数值
Object[] args = point.getArgs();
//将参数名和参数值转成Map,且一一对应
Map<String, Object> maps = new HashMap<>();
for(int i=0; i<params.length; i++){
maps.put(params[i], args[i]);
}
//根据 客户端IP + http请求方式 + 请求的URI + 参数的摘要值 来计算锁名
String lockName=getIpAddr(request)+'|'+request.getMethod()+'|'+request.getRequestURI()+'|'+md5Encrypt(maps);
lock = redissonClient.getLock(lockName);
//从redis中尝试再20ms内获取该锁,若是超过20ms也没获取到,则放弃,并返回false,如果获取到,则返回true,并自动上锁
if(!lock.tryLock(20, TimeUnit.MILLISECONDS)){
log.info("系统繁忙");
//没有获取到,表名接口被重复调用
return resultObject;
}
}
//获取到锁
try {
log.info("------------------------执行开始------------------------");
result=point.proceed();
} finally {
//如果获取到锁,且处于上锁状态,已经执行完毕,释放锁
if(lock!=null&&lock.isLocked())
lock.unlock();
log.info("------------------------执行结束------------------------");
}
return result;
}
//将Map转为Json字符串,再根据Json字符串计算摘要值,并返回,用于判断是不是相同参数的请求重复提交了
public String md5Encrypt(Map<String, Object> maps) throws JsonProcessingException, NoSuchAlgorithmException {
ObjectMapper objectMapper = new ObjectMapper();
String json = null;
json = objectMapper.writeValueAsString(maps);
MessageDigest md5 = MessageDigest.getInstance("md5");
md5.update(json.getBytes(StandardCharsets.UTF_8));
byte[] bytes = md5.digest();
md5.reset();
//bytes数组转16进制字符串
return Hex.encodeHexString(bytes);
}
//获取客户端IP,实际上这里可以直接用UserId替代
public String getIpAddr(HttpServletRequest request) {
String ipAddress = request.getHeader("X-Forwarded-For");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
if (ipAddress.equals("127.0.0.1") || ipAddress.equals("0:0:0:0:0:0:0:1")) {
// 根据网卡取本机配置的IP
try {
InetAddress inet = InetAddress.getLocalHost();
ipAddress = inet.getHostAddress();
} catch (UnknownHostException e) {
log.error("根据网卡获取本机配置的IP异常", e);
}
}
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.split(",")[0];
}
return ipAddress;
}
}
以上代码还有可以继续改进的地方,例如将
IP
换成UserID
,lockName
可以继续计算其摘要值,缩短lockName
的长度
上述代码在实际使用中,需要修改的地方如下: