一個註解實現接口冪等性,真心優雅!

一、什麼是冪等性?

簡單來說,就是對一個接口執行重複的多次請求,與一次請求所產生的結果是相同的,聽起來非常容易理解,但要真正的在系統中要始終保持這個目標,是需要很嚴謹的設計的,在實際的生產環境下,我們應該保證任何接口都是冪等的,而如何正確的實現冪等,就是本文要討論的內容。

二、哪些請求天生就是冪等的?

首先,我們要知道查詢類的請求一般都是天然冪等的,除此之外,刪除請求在大多數情況下也是冪等的,但是 ABA 場景下除外。

舉一個簡單的例子

比如,先請求了一次刪除 A 的操作,但由於響應超時,又自動請求了一次刪除 A 的操作,如果在兩次請求之間,又插入了一次 A,而實際上新插入的這一次 A,是不應該被刪除的,這就是 ABA 問題,不過,在大多數業務場景中,ABA 問題都是可以忽略的。

除了查詢和刪除之外,還有更新操作,同樣的更新操作在大多數場景下也是天然冪等的,其例外是也會存在 ABA 的問題,更重要的是,比如執行update table set a = a + 1 where v = 1這樣的更新就非冪等了。

最後,就還剩插入了,插入大多數情況下都是非冪等的,除非是利用數據庫唯一索引來保證數據不會重複產生。

三、爲什麼需要冪等

1. 超時重試

當發起一次 RPC 請求時,難免會因爲網絡不穩定而導致請求失敗,一般遇到這樣的問題我們希望能夠重新請求一次,正常情況下沒有問題,但有時請求實際上已經發出去了,只是在請求響應時網絡異常或者超時,此時,請求方如果再重新發起一次請求,那被請求方就需要保證冪等了。

2. 異步回調

異步回調是提升系統接口吞吐量的一種常用方式,很明顯,此類接口一定是需要保證冪等性的。

3. 消息隊列

現在常用的消息隊列框架,比如:Kafka、RocketMQ、RabbitMQ 在消息傳遞時都會採取 At least once 原則(也就是至少一次原則,在消息傳遞時,不允許丟消息,但是允許有重複的消息),既然消息隊列不保證不會出現重複的消息,那消費者自然要保證處理邏輯的冪等性了。

四、實現冪等的關鍵因素

關鍵因素 1

冪等唯一標識,可以叫它冪等號或者冪等令牌或者全局 ID,總之就是客戶端與服務端一次請求時的唯一標識,一般情況下由客戶端來生成,也可以讓第三方來統一分配。

關鍵因素 2

有了唯一標識以後,服務端只需要確保這個唯一標識只被使用一次即可,一種常見的方式就是利用數據庫的唯一索引。

五、註解實現冪等性

下面演示一種利用 Redis 來實現的方式。

1. 自定義註解

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

@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * 參數名,表示將從哪個參數中獲取屬性值。
     * 獲取到的屬性值將作爲KEY。
     *
     * @return
     */
    String name() default "";

    /**
     * 屬性,表示將獲取哪個屬性的值。
     *
     * @return
     */
    String field() default "";

    /**
     * 參數類型
     *
     * @return
     */
    Class type();

}

2. 統一的請求入參對象

@Data
public class RequestData<T> {

    private Header header;

    private T body;

}


@Data
public class Header {

    private String token;

}

@Data
public class Order {

    String orderNo;

}

3.AOP 處理

import com.springboot.micrometer.annotation.Idempotent;
import com.springboot.micrometer.entity.RequestData;
import com.springboot.micrometer.idempotent.RedisIdempotentStorage;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Map;

@Aspect
@Component
public class IdempotentAspect {

    @Resource
    private RedisIdempotentStorage redisIdempotentStorage;

    @Pointcut("@annotation(com.springboot.micrometer.annotation.Idempotent)")
    public void idempotent() {
    }

    @Around("idempotent()")
    public Object methodAround(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        String field = idempotent.field();
        String name = idempotent.name();
        Class clazzType = idempotent.type();

        String token = "";

        Object object = clazzType.newInstance();
        Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint);
        if (object instanceof RequestData) {
            RequestData idempotentEntity = (RequestData) paramValue.get(name);
            token = String.valueOf(AopUtils.getFieldValue(idempotentEntity.getHeader(), field));
        }

        if (redisIdempotentStorage.delete(token)) {
            return joinPoint.proceed();
        }
        return "重複請求";
    }
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.CodeSignature;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class AopUtils {

    public static Object getFieldValue(Object obj, String name) throws Exception {
        Field[] fields = obj.getClass().getDeclaredFields();
        Object object = null;
        for (Field field : fields) {
            field.setAccessible(true);
            if (field.getName().toUpperCase().equals(name.toUpperCase())) {
                object = field.get(obj);
                break;
            }
        }
        return object;
    }


    public static Map<String, Object> getParamValue(ProceedingJoinPoint joinPoint) {
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        Map<String, Object> param = new HashMap<>(paramNames.length);

        for (int i = 0; i < paramNames.length; i++) {
            param.put(paramNames[i], paramValues[i]);
        }
        return param;
    }
}

4.Token 值生成

import com.springboot.micrometer.idempotent.RedisIdempotentStorage;
import com.springboot.micrometer.util.IdGeneratorUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/idGenerator")
public class IdGeneratorController {

    @Resource
    private RedisIdempotentStorage redisIdempotentStorage;

    @RequestMapping("/getIdGeneratorToken")
    public String getIdGeneratorToken() {
        String generateId = IdGeneratorUtil.generateId();
        redisIdempotentStorage.save(generateId);
        return generateId;
    }

}
public interface IdempotentStorage {

    void save(String idempotentId);

    boolean delete(String idempotentId);
}
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;

@Component
public class RedisIdempotentStorage implements IdempotentStorage {

    @Resource
    private RedisTemplate<String, Serializable> redisTemplate;

    @Override
    public void save(String idempotentId) {
        redisTemplate.opsForValue().set(idempotentId, idempotentId, 10, TimeUnit.MINUTES);
    }

    @Override
    public boolean delete(String idempotentId) {
        return redisTemplate.delete(idempotentId);
    }
}
import java.util.UUID;

public class IdGeneratorUtil {

    public static String generateId() {
        return UUID.randomUUID().toString();
    }

}

5. 請求示例

調用接口之前,先申請一個 token,然後帶着服務端返回的 token 值,再去請求。

import com.springboot.micrometer.annotation.Idempotent;
import com.springboot.micrometer.entity.Order;
import com.springboot.micrometer.entity.RequestData;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/order")
public class OrderController {

    @RequestMapping("/saveOrder")
    @Idempotent(name = "requestData"type = RequestData.class, field = "token")
    public String saveOrder(@RequestBody RequestData<Order> requestData) {
        return "success";
    }

}

請求獲取 token 值。

帶着 token 值,第一次請求成功。

第二次請求失敗。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GTaE0zamrVa8Cu-Ayh2MTg