前言
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token
的机制实现防止重复提交。
简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
实现
pom.xml
在 pom.xml 中引入 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>
application.yaml
在 application 配置文件中配置连接 Redis 的参数:
spring:
application:
name: 郎家岭伯爵
redis:
ssl: false
host: 127.0.0.1
port: 6379
database: 0
timeout: 1000
password:
lettuce:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-idle: 20
创建与验证Token工具类
创建用于操作 Token 相关的 Service 类,里面存在 Token 创建与验证方法,其中:
-
Token 创建方法: 使用 UUID 工具创建 Token 串,设置以
"idempotent_token:" + "Token串"
作为Key
,以用户信息当成Value
,将信息存入 Redis 中。 -
Token 验证方法: 接收 Token 串参数,加上 Key 前缀形成 Key,再传入 Value 值,执行
Lua
表达式(Lua 表达式能保证命令执行的原子性)进行查找对应 Key 与删除操作。执行完成后验证命令的返回结果,如果结果不为空且非0,则验证成功,否则失败。
package com.langjialing.helloworld.service;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
/**
* @author 郎家岭伯爵
* @time 2023/3/22 14:15
*/
@Slf4j
@Service
public class TokenUtilService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 存入 Redis 的 Token 键的前缀
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
/**
* 创建 Token 存入 Redis,并返回该 Token
*
* @param value 用于辅助验证的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
// 实例化生成 ID 工具对象
String token = UUID.randomUUID().toString();
// 设置存入 Redis 的 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 存储 Token 到 Redis,且设置过期时间为5分钟
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
// 返回 Token
return token;
}
/**
* 验证 Token 正确性
*
* @param token token 字符串
* @param value value 存储在Redis中的辅助验证信息
* @return 验证结果
*/
public boolean validToken(String token, String value) {
// 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
// 根据 Key 前缀拼接 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 执行 Lua 脚本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
if (result != null && result != 0L) {
log.info("验证 token={},key={},value={} 成功", token, key, value);
return true;
}
log.info("验证 token={},key={},value={} 失败", token, key, value);
return false;
}
}
创建测试的Controller类
创建用于测试的 Controller 类,里面有获取 Token 与测试接口幂等性的接口,内容如下:
package com.langjialing.helloworld.controller;
import com.langjialing.helloworld.service.TokenUtilService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @author 郎家岭伯爵
* @time 2023/3/22 14:17
*/
@Slf4j
@RestController
public class TokenController {
@Autowired
private TokenUtilService tokenService;
/**
* 获取 Token 接口
*
* @return Token 串
*/
@GetMapping("/token")
public String getToken(@RequestParam String userId) {
// 获取 Token 字符串,并返回
return tokenService.generateToken(userId);
}
/**
* 接口幂等性测试接口
*
* @param token 幂等 Token 串
* @return 执行结果
*/
@PostMapping("/test")
public String test(@RequestHeader(value = "token") String token, @RequestParam String userId) {
// 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
boolean result = tokenService.validToken(token, userId);
// 根据验证结果响应不同信息
return result ? "正常调用" : "重复调用或无效的userId";
}
}
POSTMAN测试
调用 /token"
接口,创建指定用户的 Token 值:
调用 /test"
接口,通过验证 Headers 里的 Token 值是否仍存在 Redis 中(如存在则为第一次调用接口,业务代码正常执行;如不存在则为重复调用,拦截执行):
第一次调用:
重复调用:
总结
本文通过 Token 令牌的方案来解决接口幂等性问题。