如何設(shè)計一個冪等接口

一攒霹、什么叫接口冪等性

冪等性忧陪,就是只多次操作的結(jié)果是一致的扣泊。這里可能有人會有疑問近范。

問:為什么要多次操作結(jié)果都一致呢?比如我查詢數(shù)據(jù)延蟹,每次查出來的都一樣评矩,即使我修改了每次查出來的也都要一樣嗎?

答:我們說的多次阱飘,是指同一次請求中的多次操作稚照。這個多次操作可能會在如下情況發(fā)生:

  • 前端重復(fù)提交。比如這個業(yè)務(wù)處理需要2秒鐘俯萌,我在2秒之內(nèi)果录,提交按鈕連續(xù)點了3次,如果非冪等性接口咐熙,那么后端就會處理3次弱恒。如果是查詢,自然是沒有影響的棋恼,因為查詢本身就是冪等操作返弹,但如果是新增,本來只是新增1條記錄的爪飘,連點3次义起,就增加了3條,這顯然不行师崎。

  • 響應(yīng)超時而導(dǎo)致請求重試:在微服務(wù)相互調(diào)用的過程中默终,假如訂單服務(wù)調(diào)用支付服務(wù),支付服務(wù)支付成功了犁罩,但是訂單服務(wù)接收支付服務(wù)返回的信息時超時了齐蔽,于是訂單服務(wù)進行重試,又去請求支付服務(wù)床估,結(jié)果支付服務(wù)又扣了一遍用戶的錢含滴。如果真這樣的話,用戶估計早就提著砍刀來了丐巫。


歡迎大家關(guān)注我的公眾號 javawebkf谈况,目前正在慢慢地將簡書文章搬到公眾號,以后簡書和公眾號文章將同步更新递胧,且簡書上的付費文章在公眾號上將免費碑韵。


二、如何設(shè)計一個冪等接口

經(jīng)過上面的描述谓着,相信大家已經(jīng)清楚了什么叫接口冪等性及其重要性泼诱。那么如何設(shè)計呢?大致有以下幾種方案:

  • 數(shù)據(jù)庫記錄狀態(tài)機制:即每次操作前先查詢狀態(tài)赊锚,根據(jù)數(shù)據(jù)庫記錄的狀態(tài)來判斷是否要繼續(xù)執(zhí)行操作治筒。比如訂單服務(wù)調(diào)用支付服務(wù)屉栓,每次調(diào)用之前,先查詢該筆訂單的支付狀態(tài)耸袜,從而避免重復(fù)操作友多。

  • token機制:請求業(yè)務(wù)接口之前,先請求token接口(會將生成的token放入redis中)獲取一個token堤框,然后請求業(yè)務(wù)接口時域滥,帶上token。在進行業(yè)務(wù)操作之前蜈抓,我們先獲取請求中攜帶的token启绰,看看在redis中是否有該token,有的話沟使,就刪除委可,刪除成功說明token校驗通過,并且繼續(xù)執(zhí)行業(yè)務(wù)操作腊嗡;如果redis中沒有該token着倾,說明已經(jīng)被刪除了,也就是已經(jīng)執(zhí)行過業(yè)務(wù)操作了燕少,就不讓其再進行業(yè)務(wù)操作卡者。大致流程如下:

token機制
  • 其他方案:接口冪等性設(shè)計還有很多其他方案,比如全局唯一id客们、樂觀鎖等崇决。本文主要講token機制的使用,若感興趣可以自行研究镶摘。

三嗽桩、用token機制實現(xiàn)接口的冪等性

1岳守、pom.xml:主要是引入了redis相關(guān)依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-starter-data-redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<!-- jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<!-- org.json/json -->
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20190722</version>
</dependency>

2凄敢、application.yml:主要是配置redis

server:
  port: 6666
spring:
  application:
    name: idempotent-api
  redis:
    host: 192.168.2.43
    port: 6379

3、業(yè)務(wù)代碼:

  • 新建一個枚舉湿痢,列出常用返回信息涝缝,如下:
@Getter
@AllArgsConstructor
public enum ResultEnum {
    REPEATREQUEST(405, "重復(fù)請求"),
    OPERATEEXCEPTION(406, "操作異常"),
    HEADERNOTOKEN(407, "請求頭未攜帶token"),
    ERRORTOKEN(408, "token不正確")
    ;
    private Integer code;
    private String msg;
}
  • 新建一個JsonUtil,當(dāng)請求異常時往頁面中輸出json:
public class JsonUtil {
    private JsonUtil() {}
    public static void writeJsonToPage(HttpServletResponse response, String msg) {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(msg);
        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }
}
  • 新建一個RedisUtil譬重,用來操作redis:
@Component
public class RedisUtil {
    
    private RedisUtil() {}

    private static RedisTemplate redisTemplate;

    @Autowired
    public  void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) {
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //設(shè)置序列化Value的實例化對象
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        RedisUtil.redisTemplate = redisTemplate;
    }
    
    /**
     * 設(shè)置key-value拒逮,過期時間為timeout秒
     * @param key
     * @param value
     * @param timeout
     */
    public static void setString(String key, String value, Long timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    
    /**
     * 設(shè)置key-value
     * @param key
     * @param value
     */
    public static void setString(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }
    
    /**
     * 獲取key-value
     * @param key
     * @return
     */
    public static String getString(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }
    
    /**
     * 判斷key是否存在
     * @param key
     * @return
     */
    public static boolean isExist(String key) {
        return redisTemplate.hasKey(key);
    }
    
    /**
     * 刪除key
     * @param key
     * @return
     */
    public static boolean delKey(String key) {
        return redisTemplate.delete(key);
    }
}
  • 新建一個TokenUtil,用來生成和校驗token:生成token沒什么好說的臀规,這里為了簡單直接用uuid生成滩援,然后放入redis中。校驗token塔嬉,如果用戶沒有攜帶token玩徊,直接返回false租悄;如果攜帶了token,但是redis中沒有這個token恩袱,說明已經(jīng)被刪除了泣棋,即已經(jīng)訪問了,返回false畔塔;如果redis中有潭辈,但是redis中的token和用戶攜帶的token不一致,也返回false澈吨;有且一致把敢,說明是第一次訪問,就將redis中的token刪除谅辣,然后返回true技竟。
public class TokenUtil {

    private TokenUtil() {}
    
    private static final String KEY = "token";
    private static final String CODE = "code";
    private static final String MSG = "msg";
    private static final String JSON = "json";
    private static final String RESULT = "result";
    
    /**
     * 生成token并放入redis中
     * @return
     */
    public static String createToken() {
        String token = UUID.randomUUID().toString();
        RedisUtil.setString(KEY, token, 60L);
        return RedisUtil.getString(KEY);
    }
    
    /**
     * 校驗token
     * @param request
     * @return
     * @throws JSONException 
     */
    public static Map<String, Object> checkToken(HttpServletRequest request) throws JSONException {
        String headerToken = request.getHeader(KEY);
        JSONObject json = new JSONObject();
        Map<String, Object> resultMap = new HashMap<>();
        // 請求頭中沒有攜帶token,直接返回false
        if (StringUtils.isEmpty(headerToken)) {
            json.put(CODE, ResultEnum.HEADERNOTOKEN.getCode());
            json.put(MSG, ResultEnum.HEADERNOTOKEN.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
        
        if (StringUtils.isEmpty(RedisUtil.getString(KEY))) {
            // 如果redis中沒有token屈藐,說明已經(jīng)訪問成功過了榔组,直接返回false
            json.put(CODE, ResultEnum.REPEATREQUEST.getCode());
            json.put(MSG, ResultEnum.REPEATREQUEST.getMsg());
            resultMap.put(RESULT, false);
            resultMap.put(JSON, json.toString());
            return resultMap;
        } else {
            // 如果redis中有token,就刪除掉联逻,刪除成功返回true搓扯,刪除失敗返回false
            String redisToken = RedisUtil.getString(KEY);
            boolean result = false;
            if (!redisToken.equals(headerToken)) {
                json.put(CODE, ResultEnum.ERRORTOKEN.getCode());
                json.put(MSG, ResultEnum.ERRORTOKEN.getMsg());
            } else {
                result = RedisUtil.delKey(KEY);
                String msg = result ? null : ResultEnum.OPERATEEXCEPTION.getMsg();
                json.put(CODE, 400);
                json.put(MSG, msg);
            }
            resultMap.put(RESULT, result);
            resultMap.put(JSON, json.toString());
            return resultMap;
        }
    }
}
  • 新建一個注解,用來標(biāo)注需要進行冪等的接口:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedIdempotent {
}
  • 接著要新建一個攔截器包归,對有@NeedIdempotent注解的方法進行攔截锨推,進行自動冪等。
public class IdempotentInterceptor implements HandlerInterceptor{

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,Object object) throws JSONException {
        // 攔截的不是方法公壤,直接放行
        if (!(object instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) object;
        Method method = handlerMethod.getMethod();
        // 如果是方法换可,并且有@NeedIdempotent注解,就自動冪等
        if (method.isAnnotationPresent(NeedIdempotent.class)) {
            Map<String, Object> resultMap = TokenUtil.checkToken(httpServletRequest);
            boolean result = (boolean) resultMap.get("result");
            String json = (String) resultMap.get("json");
            if (!result) {
                JsonUtil.writeJsonToPage(httpServletResponse, json);
            }
            return result;
        } else {
            return true;
        }
    }
    
    @Override
    public void postHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse, Object o,ModelAndView modelAndView) {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object o, Exception e) {
    }
}
  • 然后將這個攔截器配置到spring中去:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor())
                .addPathPatterns("/**");   
    }
    @Bean
    public IdempotentInterceptor idempotentInterceptor() {
        return new IdempotentInterceptor();
    }

}
  • 最后新建一個controller厦幅,就可以愉快地進行測試了沾鳄。
@RestController
@RequestMapping("/idempotent")
public class IdempotentApiController {

    @NeedIdempotent
    @GetMapping("/hello")
    public String hello() {
        return "are you ok?";
    }
    
    @GetMapping("/token")
    public String token() {
        return TokenUtil.createToken();
    }
}

訪問/token,不需要什么校驗确憨,訪問/hello译荞,就會自動冪等,每一次訪問都要先獲取token休弃,一個token不能用兩次吞歼。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者塔猾。
  • 序言:七十年代末篙骡,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌糯俗,老刑警劉巖慎皱,帶你破解...
    沈念sama閱讀 211,743評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異叶骨,居然都是意外死亡茫多,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,296評論 3 385
  • 文/潘曉璐 我一進店門忽刽,熙熙樓的掌柜王于貴愁眉苦臉地迎上來天揖,“玉大人,你說我怎么就攤上這事跪帝〗癫玻” “怎么了?”我有些...
    開封第一講書人閱讀 157,285評論 0 348
  • 文/不壞的土叔 我叫張陵伞剑,是天一觀的道長斑唬。 經(jīng)常有香客問我,道長黎泣,這世上最難降的妖魔是什么恕刘? 我笑而不...
    開封第一講書人閱讀 56,485評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮抒倚,結(jié)果婚禮上褐着,老公的妹妹穿的比我還像新娘。我一直安慰自己托呕,他們只是感情好含蓉,可當(dāng)我...
    茶點故事閱讀 65,581評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著项郊,像睡著了一般馅扣。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上着降,一...
    開封第一講書人閱讀 49,821評論 1 290
  • 那天差油,我揣著相機與錄音,去河邊找鬼鹊碍。 笑死厌殉,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的侈咕。 我是一名探鬼主播,決...
    沈念sama閱讀 38,960評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼器紧,長吁一口氣:“原來是場噩夢啊……” “哼耀销!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,719評論 0 266
  • 序言:老撾萬榮一對情侶失蹤熊尉,失蹤者是張志新(化名)和其女友劉穎罐柳,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體狰住,經(jīng)...
    沈念sama閱讀 44,186評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡张吉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,516評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了催植。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片肮蛹。...
    茶點故事閱讀 38,650評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖创南,靈堂內(nèi)的尸體忽然破棺而出伦忠,到底是詐尸還是另有隱情,我是刑警寧澤稿辙,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布昆码,位于F島的核電站,受9級特大地震影響邻储,放射性物質(zhì)發(fā)生泄漏赋咽。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,936評論 3 313
  • 文/蒙蒙 一吨娜、第九天 我趴在偏房一處隱蔽的房頂上張望冬耿。 院中可真熱鬧,春花似錦萌壳、人聲如沸亦镶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,757評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽缤骨。三九已至,卻和暖如春尺借,著一層夾襖步出監(jiān)牢的瞬間绊起,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,991評論 1 266
  • 我被黑心中介騙來泰國打工燎斩, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留虱歪,地道東北人。 一個月前我還...
    沈念sama閱讀 46,370評論 2 360
  • 正文 我出身青樓栅表,卻偏偏與公主長得像笋鄙,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子怪瓶,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,527評論 2 349