spring boot + redis实现接口防重

参考

springboot + redis + 注解 + 拦截器 实现接口幂等性校验

背景

在业务开发中,我们经常会遇到由于网络抖动或者用户误操作引起同一份请求数据多次请求后端接口的问题,这种问题可以综合多方面进行解决:

  • 数据库:增加唯一索引
  • 前端:按钮防止重复点击
  • 后端:判断请求数据是否重复

本文从后端层面考虑,使用Spring boot + redis + 自定义注解 + 拦截器来降低同一份请求数据被重复处理的可能性

思路

前端在请求需要做防重校验接口之前,先请求token获取接口得到防重token,之后在header中携带token再去请求具体接口,时序图如下:


spring boot + redis实现接口防重_第1张图片
Spring boot + redis实现接口防重-思路图

这里可能会有疑问:前端在什么时候去获取token比较合适呢?以保存数据为例,如果当点击了保存按钮再去获取token,则依旧可能存在重复点击保存按钮,此时获取的token是不一样的,这时候防重就没起到作用,因此,如果保存数据是在模态框上进行的,那么,可以在弹框的时候,获取token,除非重启弹框,否则,token都是同一个,从而避免上述问题。不过要注意的是,当保存数据异常的时候,这个时候token已经校验完毕被删除了,而出于用户体验的考虑,往往是允许用户无需重新弹框直接修正数据之后再次保存数据的,所以,一旦保存失败,需要重置token,但这里有一种例外,就是token校验失败的异常,这时候需要抛出特定错误码,方便前端不重置token。至于没有模态框的情景,则需要具体场景具体分析了

Demo

ApiIdempotent

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiIdempotent {
}

用自定义注解来标识哪些接口需要做幂等处理

TokenService

public interface TokenService {

    String createToken();

    void checkToken(String token);
}

TokenServiceImpl

@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private RedisUtil redisUtil;

    @Override
    public String createToken() {
        String token = UuidUtil.getId();
        StringBuilder tokenBuilder = new StringBuilder();
        tokenBuilder.append(CommonConstant.IDEMPOTENT_TOKEN).append(tokenBuilder);
        redisUtil.set(tokenBuilder.toString(), tokenBuilder.toString(), 5, TimeUnit.MINUTES);
        return token;
    }

    @Override
    public void checkToken(String token) {
        if (StringUtils.isBlank(token)) {
            throw new ApiIdempotentException(ExceptionConstant.ILLEGAL_ARGUMENT);
        }

        StringBuilder tokenBuilder = new StringBuilder();
        tokenBuilder.append(CommonConstant.IDEMPOTENT_TOKEN).append(tokenBuilder);

        // 获取token,如果redis中不存在,说明已经被删除了,此时便是重复请求
        Object value = redisUtil.get(tokenBuilder.toString());
        if (value == null) {
            throw new ApiIdempotentException(ExceptionConstant.DUPLICATE_REQUEST);
        }

        // 校验通过之后,将token从redis中删除
        boolean deleteResult = redisUtil.delete(tokenBuilder.toString());
        // 必须校验删除结果,多线程环境下,依旧有可能多个线程走到delete这一步
        if (!deleteResult) {
            throw new ApiIdempotentException(ExceptionConstant.DUPLICATE_REQUEST);
        }
    }
}

RedisUtil

@Component
@Slf4j
public class RedisUtil {
    public boolean set(Object key, Object value, long timeout, TimeUnit timeUnit) {
        try {
            redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
        } catch (Exception e) {
            log.error("set with expire异常",e);
            return false;
        }
        return true;
    }
}    

ApiIdempotentInterceptor

@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private RedisUtil redisUtil;

    @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();
        ApiIdempotent apiIdempotent = method.getAnnotation(ApiIdempotent.class);
        if (apiIdempotent != null) {
            request.getRequestURL();
            request.getParameterMap();
            String token = request.getHeader(CommonConstant.IDEMPOTENT_TOKEN);
            tokenService.checkToken(token);
        }
        return true;
    }
}

InterceptorConfiguration

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInterceptor()).addPathPatterns("/**");
    }

    @Bean
    public ApiIdempotentInterceptor apiIdempotentInterceptor() {
        return new ApiIdempotentInterceptor();
    }
}

注册拦截器

博主在项目中实际使用时,在配置完拦截器之后,发现始终未能进入拦截器逻辑,且启动的时候debug,也不会进入该段代码,百度发现在Spring boot中,WebMvcConfigurer和WebMvcConfigurationSupport如果同时存在,只会生效先扫描到的配置类,不会同时生效,感觉离真相近了一步,一波操作,去掉原本实现WebMvcConfigurationSupport的配置,but,事情并没有那么简单,依旧没生效。

spring boot + redis实现接口防重_第2张图片
挠头一想,项目还引入了一个common包,会不会里面已经配置了WebMvcConfigurer呢?一看,果然如此,一把梭把InterceptorConfiguration改成继承common包中的拦截器配置类,这回总可以了吧?依旧没生效
spring boot + redis实现接口防重_第3张图片

难道是扫描顺序问题?查看启动类的注解,发现common包的扫描配置在业务之前: @ComponentScan(basePackages = {"com.common","com.biz"}),调整一下顺序,哎,成了

测试

被测试接口

    @PostMapping(value = "/saveProduct")
    @ApiIdempotent
    public Response saveProduct() {
        Product product = new Product();
        product.setCreateTime(new Date());
        productService.save(product);
        return Response.success("success");
    }

测试结果

这里使用postman runner进行测试,测试并发量为100/s:


spring boot + redis实现接口防重_第4张图片
测试接口

spring boot + redis实现接口防重_第5张图片
并发配置

spring boot + redis实现接口防重_第6张图片
测试结果

总结

使用token可以有效降低接口重复请求的概率,但并不能保证100%不重复,取决于前端获取token的时机,且前端请求业务接口之前需要先获取token,对存量接口尤其不友好(可能前端大神有统一处理的方案,反正我这个前端渣渣并不是很清楚)。

你可能感兴趣的:(spring boot + redis实现接口防重)