接口幂等解决方案——防重Token令牌

郎家岭伯爵 2023年03月22日 1,270次浏览

前言

针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。

简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。

实现

pom.xml

在 pom.xml 中引入 Redis 依赖:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-data-redis</artifactId>
  4. </dependency>
  5. <dependency>
  6. <groupId>org.apache.commons</groupId>
  7. <artifactId>commons-pool2</artifactId>
  8. </dependency>

application.yaml

在 application 配置文件中配置连接 Redis 的参数:

  1. spring:
  2. application:
  3. name: 郎家岭伯爵
  4. redis:
  5. ssl: false
  6. host: 127.0.0.1
  7. port: 6379
  8. database: 0
  9. timeout: 1000
  10. password:
  11. lettuce:
  12. pool:
  13. max-active: 100
  14. max-wait: -1
  15. min-idle: 0
  16. 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,则验证成功,否则失败。

  1. package com.langjialing.helloworld.service;

  2. import java.util.Arrays;
  3. import java.util.UUID;
  4. import java.util.concurrent.TimeUnit;
  5. import lombok.extern.slf4j.Slf4j;
  6. import org.springframework.beans.factory.annotation.Autowired;
  7. import org.springframework.data.redis.core.StringRedisTemplate;
  8. import org.springframework.data.redis.core.script.DefaultRedisScript;
  9. import org.springframework.data.redis.core.script.RedisScript;
  10. import org.springframework.stereotype.Service;

  11. /**
  12. * @author 郎家岭伯爵
  13. * @time 2023/3/22 14:15
  14. */

  15. @Slf4j
  16. @Service
  17. public class TokenUtilService {

  18. @Autowired
  19. private StringRedisTemplate redisTemplate;

  20. /**
  21. * 存入 Redis 的 Token 键的前缀
  22. */
  23. private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

  24. /**
  25. * 创建 Token 存入 Redis,并返回该 Token
  26. *
  27. * @param value 用于辅助验证的 value 值
  28. * @return 生成的 Token 串
  29. */
  30. public String generateToken(String value) {
  31. // 实例化生成 ID 工具对象
  32. String token = UUID.randomUUID().toString();
  33. // 设置存入 Redis 的 Key
  34. String key = IDEMPOTENT_TOKEN_PREFIX + token;
  35. // 存储 Token 到 Redis,且设置过期时间为5分钟
  36. redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
  37. // 返回 Token
  38. return token;
  39. }

  40. /**
  41. * 验证 Token 正确性
  42. *
  43. * @param token token 字符串
  44. * @param value value 存储在Redis中的辅助验证信息
  45. * @return 验证结果
  46. */
  47. public boolean validToken(String token, String value) {
  48. // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
  49. String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
  50. RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
  51. // 根据 Key 前缀拼接 Key
  52. String key = IDEMPOTENT_TOKEN_PREFIX + token;
  53. // 执行 Lua 脚本
  54. Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
  55. // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
  56. if (result != null && result != 0L) {
  57. log.info("验证 token={},key={},value={} 成功", token, key, value);
  58. return true;
  59. }
  60. log.info("验证 token={},key={},value={} 失败", token, key, value);
  61. return false;
  62. }

  63. }

创建测试的Controller类

创建用于测试的 Controller 类,里面有获取 Token 与测试接口幂等性的接口,内容如下:

  1. package com.langjialing.helloworld.controller;

  2. import com.langjialing.helloworld.service.TokenUtilService;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.springframework.beans.factory.annotation.Autowired;
  5. import org.springframework.web.bind.annotation.*;

  6. /**
  7. * @author 郎家岭伯爵
  8. * @time 2023/3/22 14:17
  9. */

  10. @Slf4j
  11. @RestController
  12. public class TokenController {

  13. @Autowired
  14. private TokenUtilService tokenService;

  15. /**
  16. * 获取 Token 接口
  17. *
  18. * @return Token 串
  19. */
  20. @GetMapping("/token")
  21. public String getToken(@RequestParam String userId) {
  22. // 获取 Token 字符串,并返回
  23. return tokenService.generateToken(userId);
  24. }

  25. /**
  26. * 接口幂等性测试接口
  27. *
  28. * @param token 幂等 Token 串
  29. * @return 执行结果
  30. */
  31. @PostMapping("/test")
  32. public String test(@RequestHeader(value = "token") String token, @RequestParam String userId) {
  33. // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
  34. boolean result = tokenService.validToken(token, userId);
  35. // 根据验证结果响应不同信息
  36. return result ? "正常调用" : "重复调用或无效的userId";
  37. }

  38. }

POSTMAN测试

调用 /token" 接口,创建指定用户的 Token 值:

调用 /test" 接口,通过验证 Headers 里的 Token 值是否仍存在 Redis 中(如存在则为第一次调用接口,业务代码正常执行;如不存在则为重复调用,拦截执行):

第一次调用:

重复调用:

总结

本文通过 Token 令牌的方案来解决接口幂等性问题。