纯手写API互联网幂等框架
- 表单重复提交
- rpc远程调用的时候网络发生延迟的时候,可能会有重试机制
针对上面的情况,我们就要设计程序的幂等性(幂等性:保证接口唯一,数据的唯一性,不重复)
幂等性
- 幂等性就是保证数据唯一的意思,保证幂等性就是防止了接口不能重复提交
- API接口幂等性的问题解决的核心在于服务器的防御,而不是客户端的防御
- 如何保证API接口的幂等性:
(1)使用Token(令牌),该Token是惟一的,且并不是持久化的,一般是临时的,是15分钟-120分钟(token的生成只用保证临时且唯一就可以了)
注意:分布式情况下,如果使用时间戳作为token,是有一定问题的,分布式情况下的时间戳可能会相同。可以加上锁去解决,不是很推荐使用时间戳作为token(即使加锁了也有可能重复)
(2)token+redis,将生成的token放到redis中,设置其在redis中的过期时间,就可以做到临时性(一般设置为3分钟左右即可)
如何调用Token解决幂等性
- 在调用接口之前生成对应的令牌(Token)
- 将生成的令牌存放在redis中(redis天生线程安全)
- 调用接口的时候将Token放在请求头中
- 接口从redis中获取对应的令牌
- 如果可以获取该令牌执行业务逻辑,并将令牌从redis中删除
- 如果获取不到该令牌就直接返回错误提示
如果有人恶意模拟token调用接口访问,可以使用验证码进行校验
自定义注解实现幂等性
- 防止API接口封装
- 防止表单重复提交
(1)表单的防御重复提交也是采用token的方式(也可以做重定向进行解决,这里是用token的方式解决)
(2)表单提交其实是A页面提交数据到B页面展示的过程
(3)在第一次访问A页面的时候添加一个隐藏域,该隐藏域存后台生成的token(第一次进入到A页面的时候生成Token,可以放到Aop的前置通知),当我们A页面点击提交的时候,后台校验该token是否存在于redis中,如果存在继续执行正常逻辑,否则就是重复提交。 - 代码实现:
- 架构演示
- pom依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xiyou</groupId>
<artifactId>extannotation</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.1.1</version>
</dependency>
<!-- mysql 依赖 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- SpringBoot 对lombok 支持 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- SpringBoot web 核心组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<!-- SpringBoot 外部tomcat支持 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<!-- springboot-log4j -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j</artifactId>
<version>1.3.8.RELEASE</version>
</dependency>
<!-- springboot-aop 技术 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-lang/commons-lang -->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>
</dependencies>
</project>
- 配置文件yml
spring:
datasource:
url: jdbc:mysql://localhost:3307/test?useUnicode=true&characterEncoding=UTF-8
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
test-while-idle: true
test-on-borrow: true
validation-query: SELECT 1 FROM DUAL
time-between-eviction-runs-millis: 300000
min-evictable-idle-time-millis: 1800000
redis:
database: 1
host: 127.0.0.1
port: 6379
password:
jedis:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
timeout: 10000
domain:
name: www.xiyou.com
server:
port: 8889
- 启动类
package com.xiyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExtApplication {
public static void main(String[] args) {
SpringApplication.run(ExtApplication.class, args);
}
}
- 自定义注解(实现幂等性接口)
package com.xiyou.ext;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义注解,目的是放置接口的重复提交,接口的幂等性和表单的重复提交
* @author
*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtApiIdempotent {
/**
* 定义类型,表示该token是来自请求头还是来自表单
* @return
*/
String type();
}
- 自定义注解(实现自动生成token放到请求中)
package com.xiyou.ext;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 该注解的作用是生成token,转发到页面进行展示
* @author
*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtApiToken {
}
- 工具类
(1)注解常量
package com.xiyou.utils;
/**
* 规定常量
*/
public interface ConstantUtils {
/**
* 表示该token是来自请求头
*/
public static String EXTAPIHEAD = "head";
/**
* 表示该token是来自form表单
*/
public static String EXTAPIFROM = "form";
}
(2)redis的相关工具类(设置过期时间,查找redis是否存在token)
package com.xiyou.utils;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* redis的相关工具类
*/
@Component
public class RedisTokenUtils {
/**
* 设置过期时间
*/
private long timeOut = 3600L;
/**
* 自己设计的redis库
*/
private BaseRedisService baseRedisService;
public String getToken(){
String token = "token_" + UUID.randomUUID().toString().replaceAll("-", "");
// 将token放到redis中
baseRedisService.setString(token, token, timeOut);
// 返回生成的token
return token;
}
/**
* 查找需要的token是否在redis中,如果在可以继续执行(并且删除存在redis中的key),如果不在表示重复提交
* @param tokenKey
* @return
*/
public boolean findToken (String tokenKey) {
// 尝试从redis中获取值
String token = (String) baseRedisService.getString(tokenKey);
if (StringUtils.isBlank(token)) {
// 如果当前token为空
return false;
} else {
// 存在redis中,删除key
baseRedisService.delKey(tokenKey);
return true;
}
}
}
(3)redis的常用方法
package com.xiyou.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 配置对应的redis
*/
@Component
public class BaseRedisService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void setString(String key, Object data, Long timeout){
if (data instanceof String) {
// value的类型是String类型
String value = (String) data;
stringRedisTemplate.opsForValue().set(key, value);
}
if (timeout != null) {
// 设置其过期时间
stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
}
}
/**
* 得到key对应的值
* @param key
* @return
*/
public Object getString(String key) {
return this.stringRedisTemplate.opsForValue().get(key);
}
/**
* 删除key对应的value
* @param key
*/
public void delKey (String key) {
this.stringRedisTemplate.delete(key);
}
}
- aop实现自定义注解的功能(重要)
package com.xiyou.aop;
import com.xiyou.ext.ExtApiIdempotent;
import com.xiyou.ext.ExtApiToken;
import com.xiyou.utils.ConstantUtils;
import com.xiyou.utils.RedisTokenUtils;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
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 javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 切面类
* @author
*/
@Aspect
// 必须加注解,不加注解扫描不上这个包
@Component
public class ExtApiIdempotentAop {
@Autowired
private RedisTokenUtils redisTokenUtils;
/**
* 自定义切点
*/
@Pointcut("execution(public * com.xiyou.controller.*.*(..))")
public void rlAop() {
}
/**
* 前置通知: 这里用来处理自动生成token的注解,有ExtApiToken注解的类,需要自动生成token放到request中
*/
@Before("rlAop()")
public void before(JoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
// 判断当前调用的方法上面是否有注解
ExtApiToken extApiToken = signature.getMethod().getDeclaredAnnotation(ExtApiToken.class);
if (extApiToken != null) {
// 表示当前方法上面存在该注解
// 生成request对象,并且生成token,将token放到请求头中
getRequest().setAttribute("token", redisTokenUtils.getToken());
}
}
/**
* 环绕通知: 这里用来处理ExtApiIdempotent注解。该注解的作用是保证接口的幂等性
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("rlAop()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
// 判断当前方法上是否有ExtApiIdempotent注解,有注解就表示了必须保证幂等性
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
ExtApiIdempotent declaredAnnotation = methodSignature.getMethod().getDeclaredAnnotation(ExtApiIdempotent.class);
if (declaredAnnotation != null) {
// 如果当前类上有这个注解,表示需要判断其token是否存在
// 得到其配置的type属性,type属性表示了token是请求头中的还是表单中的
String type = declaredAnnotation.type();
String token = null;
// 得到request对象
HttpServletRequest request = getRequest();
if (type.equals(ConstantUtils.EXTAPIHEAD)) {
// 如果类型请求头,表示token是来自请求头中的
token = request.getHeader("token");
} else {
// 如果不是请求头,则认为其是表单提交,即使什么没有写
token = request.getParameter("token");
}
if (StringUtils.isBlank(token)) {
// 如果当前的token是空,表示错误
return "参数错误,没有token";
}
// 判断能否从redis中取到key为当前token的记录
// 能取到记录返回的是true,且删除redis中存在的key(当前token)
boolean isToken = redisTokenUtils.findToken(token);
if (!isToken) {
// 表示不可以取到,则表示重复提交
response("请勿重复提交");
return null;
}
}
// 放行程序,开始执行
Object proceed = proceedingJoinPoint.proceed();
return proceed;
}
/**
* 得到当前的请求对象
* @return
*/
public HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
return request;
}
/**
* 得到响应对象,往浏览器上写数据
* @param msg
* @throws IOException
*/
public void response(String msg) throws IOException {
// 得到响应对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletResponse response = attributes.getResponse();
// 设置响应头,规定字符格式,避免乱码
response.setHeader("Content-type", "text/html;charset=UTF-8");
PrintWriter writer = response.getWriter();
try {
writer.println(msg);
} catch (Exception e) {
} finally {
writer.close();
}
}
}