也许已经,没有明天。

参考文章

接口的幂等性怎么设计?

Spring Boot 实现接口幂等性的 4 种方案!还有谁不会?

代码示例

自定义注解

/**
 * 接口幂等性注解
 * <p>一个幂等操作的特点是指其多次执行所产生的影响均与一次执行的影响相同。
 * 在业务中也就是指的,多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。</p>
 * <p>幂等性保证难点在于:</p>
 * <li>如何区分是否为重复请求 —— token 防重令牌</li>
 * <li>请求是否已经成功执行过 —— 目标方法需要再次校验</li>
 * <p>该注解采用 token 令牌机制实现,因此需要客户端请求该接口前先请求获取 token</p>
 * <p>注意:</p>
 * <li>token 校验成功会先删除缓存中 token 再执行目标方法</li>
 * <li>token 校验失败直接返回错误信息</li>
 * <li>接口的幂等性仍需要业务代码操作的幂等性,token 机制作用是限流、防刷</li>
 *
 * @author pikachu
 * @see Token
 * @since 2023/5/9 22:56
 */
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Idempotent {
    /**
     * 请求携带的防重令牌名称
     */
    String value() default "token";

    /**
     * token 携带位置,默认为请求头 request_header
     */
    TokenPosition tokenPosition() default TokenPosition.REQUEST_HEADER;

    enum TokenPosition {
        /**
         * 请求头
         */
        REQUEST_HEADER,
        /**
         * 请求参数
         */
        REQUEST_PARAMETER,
        /**
         * 请求体
         */
        REQUEST_BODY
    }
}
/**
 * 为通用返回结果 {@link com.pika.utils.R} 添加token字段,被标注的接口应为标识性接口,不处理其它业务逻辑
 * <p>默认需要从请求参数中获取 request_uri (需要幂等性的接口地址) 作为缓存 token 的 key</p>
 *
 * @author pikachu
 * @see Idempotent
 * @since 2023/5/10 21:47
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Token {
    String REQUEST_URI = "reqUri";

    /**
     * 请求参数中的幂等性接口 URI (不包含 http协议 和 主机名,只有路径)
     */
    String requestURI() default REQUEST_URI;

    /**
     * 为响应添加防重字段,默认名称为 token,需要响应类型为 application/json;
     * <p>下次请求(需要幂等性)接口时应将其携带该 token</p>
     */
    String value() default "token";

    /**
     * token 过期时间,默认为 30
     */
    long expire() default 30;

    /**
     * 过期时间单位,默认为分钟
     */
    TimeUnit timeUnit() default TimeUnit.MINUTES;
}

切面类

/**
 * @author pikachu
 * @since 2023/5/9 22:58
 */
@Aspect
@Component
@Slf4j
public class IdempotentAspect {

    @Resource
    private TokenUtils tokenUtils;

    @Pointcut("@annotation(com.pika.annotation.Idempotent) || @within(com.pika.annotation.Idempotent)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 接口幂等性校验
        boolean isValid = false;
        try {
            final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
            isValid = tokenUtils.checkRepetitive(joinPoint, request);
        } catch (Exception e) {
            log.error("检查 token 出错:{}", e.getMessage());
        }
        if (isValid) {
            return joinPoint.proceed();
        }
        // token 校验失败,直接返回通用结果 R 对象
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse();
        R.sendResponse(response, 403, "token 校验失败");
        return null;
    }

}

/**
 * @author pikachu
 * @since 2023/5/11 10:47
 */
@Aspect
@Component
@Slf4j
public class TokenAspect {
    @Resource
    private TokenUtils tokenUtils;

    @Pointcut("@annotation(com.pika.annotation.Token) || @within(com.pika.annotation.Token)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = joinPoint.proceed();
        if (proceed instanceof R) {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
            String token = tokenUtils.generateToken(request, joinPoint);
            if (StringUtils.hasText(token)) {
                ((R) proceed).setToken(token);
            } else {
                ((R) proceed).setMsg("generate token error").setCode(HttpStatus.BAD_REQUEST.value());
            }
        }
        return proceed;
    }
}

工具类

@Component
@Slf4j
public class TokenUtils {
    /**
     * 存放在 redis 中 token 的 key
     * <p>key -> {module}:token:{requestURI}:{userId}</p>
     * <p>value -> token</p>
     */
    @Setter
    public Supplier<String> tokenKeyFormat = () -> "%s:token:%s:%s";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 模块名称
     */
    @Setter
    private Supplier<String> moduleName = () -> "TEST";
    /**
     * 用户标识
     */
    @Setter
    private Supplier<String> userId = () -> "SYSTEM";
    private static final String URI_REGEX = "^(\\/[\\w-]+)+\\/?$";
    @Setter
    private Supplier<String> tokenValue = IdUtil::fastSimpleUUID;

    @SuppressWarnings("all")
    private String getCheckToken(ProceedingJoinPoint joinPoint, HttpServletRequest request) throws IOException {
        String token = null;
        Idempotent idempotent = null;
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //先尝试从类中获取 Idempotent 注解,在从方法中获取
        idempotent = joinPoint.getTarget().getClass().getAnnotation(Idempotent.class);
        if (idempotent == null) {
            idempotent = method.getAnnotation(Idempotent.class);
        }
        String tokenName = idempotent.value();
        switch (idempotent.tokenPosition()) {
            case REQUEST_HEADER -> {
                return request.getHeader(tokenName);
            }
            case REQUEST_PARAMETER -> {
                return request.getParameter(tokenName);
            }
            case REQUEST_BODY -> {
                // 判断当前 request_body 是否被读取过
                Annotation[][] parameterAnnotations = method.getParameterAnnotations();
                // @RequestBody 注解的参数索引
                Integer argIndex = null;
                for (int i = 0; i < parameterAnnotations.length; i++) {
                    for (Annotation annotation : parameterAnnotations[i]) {
                        if (annotation.annotationType().isAssignableFrom(RequestBody.class)) {
                            argIndex = i;
                            //从方法形参(@RequestBody 标注的参数)中获取
                            return (String) JSONUtil.parseObj(joinPoint.getArgs()[argIndex]).get(tokenName);
                        }
                    }
                }
                //从 request 读取
                String requestBody = request.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
                return (String) JSONUtil.parseObj(requestBody).get(tokenName);
            }
        }
        return token;
    }

    public boolean checkRepetitive(ProceedingJoinPoint joinPoint, HttpServletRequest request) throws IOException {
        String token = getCheckToken(joinPoint, request);
        if (!StringUtils.hasText(token)) {
            return false;
        }
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Long result = stringRedisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(buildTokenKey(request.getRequestURI())),
                token);
        return result != null && result == 1;
    }

    public String buildTokenKey(String requestUri) {
        return (StringUtils.hasText(requestUri) && requestUri.matches(URI_REGEX)) ? tokenKeyFormat.get().formatted(moduleName.get(), requestUri, userId.get()) : null;
    }

    public String generateToken(HttpServletRequest request, ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Token tokenAnno = method.getAnnotation(Token.class);
        String requestUri = request.getParameter(tokenAnno.requestURI());
        String tokenKey = buildTokenKey(requestUri);
        if (StringUtils.hasText(requestUri) && StringUtils.hasText(tokenKey)) {
            String token = tokenValue.get();
            stringRedisTemplate.opsForValue().set(tokenKey, token, tokenAnno.expire(), tokenAnno.timeUnit());
            return token;
        }
        log.warn("generateToken error,current_requestUri:{},dest_requestUri:{}", request.getRequestURI(), requestUri);
        return null;
    }
}

测试

/**
 * @author pikachu
 * @since 2023/5/10 10:37
 */
@RestController
@RequestMapping("idem")
public class IdempotentController {
    @PostMapping("test1")
    @Idempotent(tokenPosition = Idempotent.TokenPosition.REQUEST_BODY)
    public R test1(@RequestBody Order order) {
        return R.ok().setData(order);
    }

    @GetMapping("token")
    @Token
    public R test2(@RequestParam(Token.REQUEST_URI) String reqUri) {
        return R.ok().setData(reqUri);
    }
}

版权声明:如无特别声明,本站收集的文章归  HuaJi66/Others  所有。 如有侵权,请联系删除。

联系邮箱: GenshinTimeStamp@outlook.com

本文标题:《 接口幂等性 》

本文链接:/%E5%BC%80%E5%8F%91%E7%9B%B8%E5%85%B3/%E9%94%A6%E5%9B%8A/%E6%8E%A5%E5%8F%A3%E5%B9%82%E7%AD%89%E6%80%A7.html