Spring Boot实现接口幂等

Spring Boot实现接口幂等

1、pom依赖

xml 复制代码
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.6</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>idempotent_demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>idempotent_demo</name>
    <description>idempotent_demo</description>

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

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--springboot data redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <!-- StringUtils工具类 -->
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.5</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <!--fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.25</version>
        </dependency>

    </dependencies>

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

</project>

2、Redis工具类

java 复制代码
package com.example.idempotent_demo.util;

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.Component;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

/**
 * @author tom
 * Redis工具类
 */
@Slf4j
@Component
public class RedisUtil {

    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    public void setRedisTemplate(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将key和value存入redis
     *
     * @param key        redis的key
     * @param value      redis的value
     * @param expireTime key过期时间
     * @return 保存进redis是否成功
     */
    public boolean save(String key, String value, Long expireTime) {
        try {
            // 存储Token到Redis,且设置过期时间为5分钟
            stringRedisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 验证key和value并删除key
     *
     * @param key   redis的key
     * @param value redis的value
     * @return 验证是否成功
     */
    public boolean valid(String key, 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);
        // 执行Lua脚本
        Long result = stringRedisTemplate.execute(redisScript, Arrays.asList(key, value));
        // 根据返回结果判断是否成功成功匹配并删除Redis键值对,若果结果不为空和0,则验证通过
        if (null != result && result != 0L) {
            log.info("验证 key={},value={} 成功", key, value);
            return true;
        }
        log.error("验证 key={},value={} 失败", key, value);
        return false;
    }
}

3、Token服务类

token 服务,里面主要是两个方法,一个用来创建 token,一个用来验证 token。

java 复制代码
package com.example.idempotent_demo.service;

import javax.servlet.http.HttpServletRequest;

/**
 * @author tom
 */
public interface TokenService {

    /**
     * 创建token
     *
     * @return
     */
    String generateToken();

    /**
     * 检验token
     *
     * @param request
     * @return
     */
    boolean validToken(HttpServletRequest request);

}
java 复制代码
package com.example.idempotent_demo.service.impl;

import com.example.idempotent_demo.constant.Constant;
import com.example.idempotent_demo.exception.NoTokenException;
import com.example.idempotent_demo.exception.ValidTokenException;
import com.example.idempotent_demo.service.TokenService;
import com.example.idempotent_demo.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import java.util.UUID;

/**
 * @author tom
 */
@Service
@Slf4j
public class TokenServiceImpl implements TokenService {

    private RedisUtil redisUtil;

    @Autowired
    public void setRedisUtil(RedisUtil redisUtil) {
        this.redisUtil = redisUtil;
    }

    /**
     * 创建token
     *
     * @return
     */
    @Override
    public String generateToken() {
        // 实例化生成ID工具对象
        String uuid = UUID.randomUUID().toString();
        String token = Constant.IDEMPOTENT_TOKEN_PREFIX + uuid;
        boolean success = redisUtil.save(token, token, 5L);
        if (success) {
            log.info("save token {} to redis success", token);
            return token;
        }
        log.error("save token {} to redis fail", token);
        return null;
    }

    /**
     * 检验token
     *
     * @param request
     * @return
     */
    @Override
    public boolean validToken(HttpServletRequest request) {
        String token = request.getHeader(Constant.IDEMPOTENT_TOKEN_HEADER);
        // header中不存在token
        if (StringUtils.isBlank(token)) {
            log.error("用户未携带token!");
            throw new NoTokenException();
        }
        // 验证token失败
        if (!redisUtil.valid(token, token)) {
            log.error("重复提交!");
            throw new ValidTokenException();
        }
        return true;
    }
}

redis.get(token) 、token.equals 、redis.del(token) 如果这几个操作不是原子,可能导致,高并发下,都get到同

样的数据,判断都成功,继续业务并发执行。这里 redis 使用 lua 脚本完成这个操作:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

java 复制代码
package com.example.idempotent_demo.exception;

/**
 * 用户为携带token
 * @author tom
 */
public class NoTokenException extends RuntimeException {

    public NoTokenException() {
        super();
    }
}
java 复制代码
package com.example.idempotent_demo.exception;

/**
 * @author
 * 验证token失败
 */
public class ValidTokenException extends RuntimeException{
    public ValidTokenException(){
        super();
    }
}
java 复制代码
package com.example.idempotent_demo.util;

/**
 * @author 结果集返回封装
 */
public class ResponseResult {

    /**
     * 响应业务状态
     */
    private Integer code;

    /**
     * 响应消息
     */
    private String msg;

    /**
     * 响应中的数据
     */
    private Object data;

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }

    /**
     * 无参构造方法
     */
    public ResponseResult() {
    }

    /**
     * 全参构造方法
     *
     * @param code
     * @param msg
     * @param data
     */
    public ResponseResult(Integer code, String msg, Object data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

}
java 复制代码
package com.example.idempotent_demo.constant;

/**
 * @author tom
 */
public class Constant {

    /**
     * 存入Redis的Token键的前缀
     */
    public static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

    /**
     * 请求头的token名称
     */
    public static final String IDEMPOTENT_TOKEN_HEADER = "idempotent_token";
}

4、Redis配置

properties 复制代码
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
server:
  servlet:
    encoding:
      charset: UTF-8

5、自定义注解

自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现

自动幂等。

后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等,使用元注解 ElementType.METHOD 表示它

只能放在方法上,EetentionPolicy.RUNTIME 表示它在运行时。

java 复制代码
package com.example.idempotent_demo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author tom
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}

6、拦截器配置

主要的功能是拦截扫描到 AutoIdempotent 注解的方法,然后调用 TokenService 的 validToken方法校验 token

是否正确,如果捕捉到异常就将异常信息渲染成json返回给前端。

java 复制代码
package com.example.idempotent_demo.config;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.example.idempotent_demo.annotation.AutoIdempotent;
import com.example.idempotent_demo.exception.NoTokenException;
import com.example.idempotent_demo.exception.ValidTokenException;
import com.example.idempotent_demo.service.TokenService;
import com.example.idempotent_demo.util.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;

/**
 * @author tom
 */
@Slf4j
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {

    private TokenService tokenService;

    @Autowired
    public void setTokenService(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    /**
     * 预处理
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        // 被AutoIdempotent注解标记的方法
        AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);
        if (methodAnnotation != null) {
            try {
                // 幂等性校验,校验通过则放行,校验失败则抛出异常,并通过统一异常处理返回友好提示
                return tokenService.validToken(request);
            } catch (NoTokenException ex) {
                log.error("用户未携带token!");
                returnJson(response, JSON.toJSONString(new ResponseResult(10001, "用户未携带token!", null), SerializerFeature.WriteMapNullValue));
                return false;
            } catch (ValidTokenException ex) {
                log.error("重复提交!");
                returnJson(response, JSON.toJSONString(new ResponseResult(10002, "重复提交!", null), SerializerFeature.WriteMapNullValue));
                return false;
            }
        }
        //必须返回true,否则会被拦截一切请求
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }

    private void returnJson(HttpServletResponse response, String json) {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(json);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
    }
}

7、注册拦截器

添加autoIdempotentInterceptor到配置类中,这样我们到拦截器才能生效,注意使用@Configuration注解,

这样在容器启动是时候就可以添加进入context中。

java 复制代码
package com.example.idempotent_demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

/**
 * @author
 */
@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Resource
    private AutoIdempotentInterceptor autoIdempotentInterceptor;

    /**
     * 添加拦截器
     *
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(autoIdempotentInterceptor);
    }
}

8、启动类

java 复制代码
package com.example.idempotent_demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author tom
 */
@SpringBootApplication
public class IdempotentDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(IdempotentDemoApplication.class, args);
    }

}

9、测试

9.1 生成token

请求生成了 token。

9.2 redis查看生成的token

redis 中生成了 token。

9.3 无header请求

请求需要携带token。

9.4 正常请求

请求成功。

9.5 再次查看redis

发现该 token 已经被删除了。

9.5 再次请求

返回重复请求。

相关推荐
风象南8 小时前
SpringBoot的零配置API文档工具的设计与实现
spring boot·后端
haciii1 天前
Spring Boot启动源码深度分析 —— 新手也能看懂的原理剖析
spring boot
泉城老铁1 天前
Spring Boot对接抖音获取H5直播链接详细指南
spring boot·后端·架构
后端小张2 天前
基于飞算AI的图书管理系统设计与实现
spring boot
考虑考虑3 天前
Jpa使用union all
java·spring boot·后端
阿杆3 天前
同事嫌参数校验太丑,我直接掏出了更优雅的 SpEL Validator
java·spring boot·后端
昵称为空C4 天前
SpringBoot3 http接口调用新方式RestClient + @HttpExchange像使用Feign一样调用
spring boot·后端
麦兜*4 天前
MongoDB Atlas 云数据库实战:从零搭建全球多节点集群
java·数据库·spring boot·mongodb·spring·spring cloud
麦兜*4 天前
MongoDB 在物联网(IoT)中的应用:海量时序数据处理方案
java·数据库·spring boot·物联网·mongodb·spring
汤姆yu4 天前
基于springboot的毕业旅游一站式定制系统
spring boot·后端·旅游