SpringBoot接口幂等性的实现

1.什么是接口的幂等性

简单来说就是当我们重复请求某个资源的时候,对于此资源要有同样的结果,也就是说只有第一次请求的时候对资源进行改变,以后的每一次请求都必须要有相同的结果

2.幂等性的案例实现

在一般情况下,我们进行接口调用的时候,都能进行正常的操作,但是在以下几个情况就会产生问题:

  • 前端重复提交表单:在我们填写一些表单的时候,比如登录,当点击完提交,由于网络问题后台没有及时响应,此时重复点击就会产生问题
  • RabbitMQ消息重复消费:也就是MQ的消息重复性问题,导致发生消息的重复消费
  • 恶意刷单:比如点赞的问题,如果针对一个文章重复点赞

3.保持接口幂等性会对系统有哪些影响

  • 幂等性把并行改为串行,显然效率会变得更低
  • 要保证幂等性,要增加很多的业务逻辑,让我们的代码变得更复杂

4.Restful API接口的幂等性

  • GET :一般用于查询,所以每个请求几乎不会改变,是保证了幂等的
  • POST:一般用于添加,输入一些信息,每次执行都会新增数据,所以不是幂等的
  • PUT:一般用于修改操作,当修改信息不需要记录的时候就是幂等的,当需要统计修改次数累加的时候就不是幂等的
  • DELETE:一般用于删除一些信息,当我们根据唯一键进行删除的时候,就是幂等的,如果根据某些条件进行删除的时候,删除的同时添加一些符合删除条件的字段,则就会将这些数据也删除,就不保证幂等性

5.如何实现幂等性

第一种:设置数据库唯一主键

使用数据库唯一主键不是自增的,而是分布式ID充当主键,这样才能保证分布式环境下ID的全局唯一性
适用操作:插入操作,删除操作(根据ID)
限制:需要生成全局唯一主键ID
操作流程:
1.客户端创建,调用服务器接口
2.生成分布式ID,执行插入操作
3.逻辑里面会进行判断,如果抛出主键重复异常,则就说明数据库中存在的此记录,则返回错误信息给前端

第二种:设置数据库乐观锁

我们使用乐观锁的时候,一般只适用于更新操作,首先我们要在数据库中多加一个字段,作为当前数据的版本标识,这样的话每次修改,条件都是该版本标识,修改完版本标识加一
适用操作:更新
需要在数据库中添加一个version字段作为版本标识

UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5

第三种:防重Token令牌

在我们操作订单的时候,这个操作就可以用Token的机制实现防止重复提交
逻辑流程:客户端调用接口的时候,后端会生成一个token,请求的时候携带这个token进行请求,将token作为key,用户信息作为value存入到redis中,校验成功之后就可以执行删除,然后执行后面的业务,如果校验失败就会返回错误信息
适用操作:插入 更新 删除
① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。

② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。

③ 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。

④ 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。

⑤ 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。

⑥ 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。

⑦ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。

说了这么多办法,那么下面实现一种方法吧例子:防重Token令牌看代码:

依赖



<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>

    <parent>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-parentartifactId>
        <version>2.3.4.RELEASEversion>
    parent>

    <groupId>mydlq.clubgroupId>
    <artifactId>springboot-idempotent-tokenartifactId>
    <version>0.0.1version>
    <name>springboot-idempotent-tokenname>
    <description>Idempotent Demodescription>

    <properties>
        <java.version>1.8java.version>
    properties>

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>
        <dependency>
            <groupId>org.apache.commonsgroupId>
            <artifactId>commons-pool2artifactId>
        dependency>
        
        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
        dependency>
    dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.bootgroupId>
                <artifactId>spring-boot-maven-pluginartifactId>
            plugin>
        plugins>
    build>

project>

配置文件

spring:
  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

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;

@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层


import lombok.extern.slf4j.Slf4j;
import mydlq.club.example.service.TokenUtilService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
public class TokenController {

    @Autowired
    private TokenUtilService tokenService;

    /**
     * 获取 Token 接口
     *
     * @return Token 串
     */
    @GetMapping("/token")
    public String getToken() {
        // 获取用户信息(这里使用模拟数据)
        // 注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为:
        // - 1)、使用"token"验证 Redis 中是否存在对应的 Key
        // - 2)、使用"用户信息"验证 Redis 的 Value 是否匹配。
        String userInfo = "mydlq";
        // 获取 Token 字符串,并返回
        return tokenService.generateToken(userInfo);
    }

    /**
     * 接口幂等性测试接口
     *
     * @param token 幂等 Token 串
     * @return 执行结果
     */
    @PostMapping("/test")
    public String test(@RequestHeader(value = "token") String token) {
        // 获取用户信息(这里使用模拟数据)
        String userInfo = "mydlq";
        // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
        boolean result = tokenService.validToken(token, userInfo);
        // 根据验证结果响应不同信息
        return result ? "正常调用" : "重复调用";
    }

}

测试类

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class IdempotenceTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Test
    public void interfaceIdempotenceTest() throws Exception {
        // 初始化 MockMvc
        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
        // 调用获取 Token 接口
        String token = mockMvc.perform(MockMvcRequestBuilders.get("/token")
                .accept(MediaType.TEXT_HTML))
                .andReturn()
                .getResponse().getContentAsString();
        log.info("获取的 Token 串:{}", token);
        // 循环调用 5 次进行测试
        for (int i = 1; i <= 5; i++) {
            log.info("第{}次调用测试接口", i);
            // 调用验证接口并打印结果
            String result = mockMvc.perform(MockMvcRequestBuilders.post("/test")
                    .header("token", token)
                    .accept(MediaType.TEXT_HTML))
                    .andReturn().getResponse().getContentAsString();
            log.info(result);
            // 结果断言
            if (i == 0) {
                Assert.assertEquals(result, "正常调用");
            } else {
                Assert.assertEquals(result, "重复调用");
            }
        }
    }

}

你可能感兴趣的:(java,springboot项目)