接口異常狀態統一處理方案:優先業務端處理,再按需統一處理。

原文地址:https://monine.github.io/#/ar...vue

最近工做賊忙,這篇文章按說應該兩個月以前就產出,但是天天的精力基本都用在工做上,一寫文章就犯迷糊,斷斷續續的每次要從新屢邏輯,之後不再這樣了。這篇文章是我司後臺項目中遇到的一個基礎需求,本身設計了一個實現方案,感受還不錯。webpack

3ffb660c6a756466b5e948ee273e3dbfc86417fbc43ee47db5c05248dfeeabe47cbe87ecb72123341968a829eb25b016?pictype=scale&from=30113&version=3.3.3.3&uin=287531381&fname=Best-Practices-for-API-Error-Handling.png&size=750

需求

後端接口響應,根據與後端約定的狀態碼(非 http 狀態碼)斷定接口是否異常,我司的約定是 status !== 0 則表示接口異常。一旦接口處於異常狀態,先讓業務端(調用者)處理異常,再由業務端決定是否執行接口異常統一處理(目前我司的統一處理內容就是彈出個 element-ui message 提示消息 😂)。ios

start=>start: 接口響應
isApiError=>condition: 異常?
normalProcess=>operation: 執行正常接口處理流程
isUserHandle=>condition: 業務端處理?
userProcess=>operation: 執行業務端處理
isNeedUniteHandle=>condition: 統一處理?
uniteProcess=>operation: 執行統一處理
end=>end: 結束

start->isApiError
isApiError(no)->normalProcess->end
isApiError(yes)->isUserHandle
isUserHandle(no)->uniteProcess->end
isUserHandle(yes)->userProcess->isNeedUniteHandle
isNeedUniteHandle(yes)->uniteProcess->end
isNeedUniteHandle(no)->end

這個流程有一個難點,當接口響應後處於異常狀態,先交由業務端處理,再由業務端決定是否執行統一處理?git

API 層我司使用的是第三方庫 axios,接口響應後會先走響應攔截器,再走業務端代碼。
正常的接口異常統一處理流程,是在響應攔截器內斷定,與後端約定的響應狀態碼是否爲異常狀態碼。若是是,則先執行統一處理邏輯,再交由到業務端處理。那如今的需求是將接口異常處理的流程逆轉,接口響應狀態異常以後,先交由業務端執行異常處理,再由業務端決定是否執行接口異常狀態統一處理。github

如上所說,若是接口處於異常狀態,須要斷定是否要執行統一處理,分兩種狀況:web

  1. 業務端沒有處理異常,必然要執行統一處理。
  2. 業務端已經處理異常,而且主動聲明是否繼續執行統一處理。(_主動聲明該如何設計?_)

問題來了,接口異常統一處理的代碼應該寫在哪裏?如何保證它在狀態異常狀況下,先交由業務端處理,再根據業務端的聲明斷定是否執行統一處理。vue-cli

歷史解決方案之 mixin

我司以前已經有過處理方案,不過是隻針對 vue 框架下的處理,經過 mixin 將 methods 內全部方法進行覆寫,補丁函數內對源函數執行完成以後獲取的返回結果進行斷定,若是返回結果爲 Promise 類型,則繼續進行相關異常處理操做。這樣確實可以達到實現效果,但總以爲很不優雅:element-ui

  1. API 層的處理與框架深度綁定,這自己就不合理。
  2. methods 內函數所有被覆寫,大量無用開銷;若是進行函數名稱約定,加入覆寫篩選,這又增長約定成本。
  3. 只能應用於 vue 框架,沒法再其它項目下直接使用,很侷限。

當時我瞭解到上述的處理方案後第一反應是 API 層的任何操做都不該該與框架自己進行任何關聯綁定,如同當年 vue 從全家桶中移除 vue-resource 同樣。axios

個人解決方案

通過一些思考,大體肯定了一個思路:利用 Promise 狀態的穩定,以接口名稱做爲惟一標識,表示當前接口是否還須要執行統一處理。後端

我是這樣設計的,接口調用時使用 url 做爲惟一標識,以狀態的形式保存在數組內 const unhandleAPI = []

接口返回後進入響應攔截器,在此對接口響應狀態進行判斷,若是屬於異常狀態,則使用 setTimeout 將接口異常統一處理函數設置爲 macro task,做爲一個異步任務推遲到下一輪 Event Loop 再執行,並返回 Promise.reject。而後會進入業務端接口調用代碼的 catch 回調函數內,執行完業務異常處理後,若是沒有返回值,則表示無需再執行統一處理。相反,返回非 undefined 值,則表示還須要執行統一處理。

執行接口異常統一處理以前,先斷定 url 標識是否存在於 unhandleAPI 內,如存在,則執行統一處理。

以上是一個大體的設計思路,具體到實現還須要解決一些實際問題:

  1. 如何肯定接口的惟一性?由於同一個接口可能毫秒內被屢次調用。
  2. 接口的異常狀態以什麼樣的形式進行保存?
  3. 如何在適當的時候移除接口異常狀態?好比業務端處理了異常不想再執行統一處理。

具體實現

  • 接口異常處理狀態存儲

    使用一個數組對象 const unhandleAPI = [] 保存全部已經調用但暫未響應的接口惟一標識,接口異常統一處理函以此斷定斷定是否須要執行。另外對外暴露一些操做 unhandleAPI 的接口。

    const unhandleAPI = [];
    
    if (process.env.NODE_ENV !== 'production') {
      window.unhandleAPI = unhandleAPI;
    }
    
    export function matchUnhandleAPI(id) {
      return unhandleAPI.find(apiUid => apiUid === id);
    }
    
    export function addUnhandleAPI(id) {
      unhandleAPI.push(id);
    }
    
    export function removeUnhandleAPI(id) {
      const index = unhandleAPI.findIndex(apiUid => apiUid === id);
    
      if (process.env.NODE_ENV === 'production') {
        unhandleAPI.splice(index, 1);
      } else {
        // 方便非 production 環境查看接口處理狀況
        unhandleAPI[index] += '#removed';
      }
    }
  • 發送接口請求

    經過查看 axios 源碼,知道 axios 真正調用接口的方法是 axios.Axios.prototype.request,因此須要對其進行覆寫。將當前調用接口的惟一標識添加到 unhandleAPI 數組對象內,同時也要添加到 axios.Axios.prototype.request 方法所返回的 Promise 實例對象當中(接口響應後的處理會使用到)。

    let uid = 0;
    const axiosRequest = axios.Axios.prototype.request;
    axios.Axios.prototype.request = function(config) {
      uid += 1;
    
      const apiUid = `${config.url}?uid=${uid}`; // 接口調用的惟一標識
      config.apiUid = apiUid; // 響應攔截器內須要使用到 apiUid,因此添加爲 config 屬性
      addUnhandleAPI(apiUid); // 添加到接口異常處理狀態存儲的數組對象
    
      const p = axiosRequest.call(this, config); // 觸發 axios 接口調用
      p.apiUid = apiUid; // 在當前接口調用所返回的 Promise 實例中添加惟一標識屬性
      return p;
    };
  • 接口響應進入響應攔截器

    在響應攔截器內斷定接口狀態,若是正常,則從接口狀態存儲的數組對象中移除當前響應接口的惟一標識。若是異常,則 setTimeout 延遲執行接口狀態異常統一處理函數,並返回 Promise.reject() 給到業務端。

    service.interceptors.response.use(
      ({ data, config }) => {
        const { status, msg, data: result } = data;
    
        // 判斷接口狀態是否異常
        if (status !== 0) {
          const pr = Promise.reject(data);
          pr.apiUid = config.apiUid; // Promise 實例中添加當前接口的惟一標識屬性
          setTimeout(handleAPIStatusError, 0, pr, msg); // 異常先交由業務端處理,延遲執行統一處理函數
          return pr;
        }
    
        // 接口狀態正常
        removeUnhandleAPI(config.apiUid); // 從接口異常處理狀態存儲的數組對象中移除當前響應接口的惟一標識
        return result;
      },
      error => {
        Message.error(error.message);
        return Promise.reject(error);
      }
    );
  • 業務端處理

    如今假設接口狀態屬於異常狀況,通過響應攔截器以後,代碼執行到業務端,先看看業務端接口調用代碼:

    callAPIMethod().catch(error => {
      // 業務端處理異常
    });

    以上是 Promise catch 的常規語法,此時若是 callAPIMethod 返回的 Promise 狀態爲 rejected,則會執行 catch 函數的回調函數。

    還記得上文提到的流程上的難點嗎?

    業務端決定是否執行接口異常統一處理函數,所以須要在此進行設計,catch 函數的回調函數如何進行聲明?其實上文已經提到 聲明 的設計方案,利用 catch 函數的回調函數的返回值。

    設計方案 OK,落實到具體實現該如何進行代碼編寫?無疑,須要針對 catch 函數進行覆寫:

    Promise.prototype.catch = function(onRejected) {
      function $onRejected(...args) {
        const catchResult = onRejected(...args);
        if (catchResult === undefined && this.apiUid) {
          removeUnhandleAPI(this.apiUid);
        }
      }
      return this.then(null, $onRejected.bind(this));
    };

    catch 方法自己其實只是語法糖,將 catch 函數的回調函數進行包裝,在包裝後的函數內,先執行業務端 catch 的回調函數,獲取到函數執行結果。接着,若是當前 promise 對象上有 apiUid 屬性,則表示當前 promise 是 API 層的 promise。若是 catch 的回調函數執行完畢以後的返回結果是 undefined,則表示再也不須要執行接口異常狀態統一處理函數,相應的,須要從以前定義的 unhandleAPI 數組內移除當前接口的惟一標識。

  • then 方法返回新 promise

    以上業務端處理看似正常,然而大多數狀況下,業務端代碼在接口調用以後不會直接鏈式調用 catch 方法,而是先調用 then 方法,再調用 catch 方法,以下:

    callAPIMethod()
      .then(response => {
        // ...
      })
      .catch(error => {
        // ...
      });

    callAPIMethod() 的執行結果返回的是個 promise 對象,而且這個 promise 對象上會有 apiUid 屬性,表示當前 promise 是 API 層接口。而後鏈式調用 then 方法和 catch 方法,就由於中間插入了 then 方法的調用,致使 catch 的覆寫函數內 this 對象的屬性上沒有了 apiUid 屬性,也就沒法斷定當前 promise 是 API 層接口的返回對象。緣由是 then 方法執行完後返回了新的 Promise 實例,因此一樣須要對 then 方法進行覆寫。

    const promiseThen = Promise.prototype.then;
    Promise.prototype.then = function(onFulfilled, onRejected) {
      // 獲取 then 方法返回的新 Promise 實例對象
      const p = promiseThen.call(this, onFulfilled, onRejected);
      // 在 promise 對象上有 apiUid 的狀況下,表示是接口層的 Promise
      // 則給 then 方法返回的 Promise 實例對象也加上 apiUid
      if (this.apiUid) p.apiUid = this.apiUid;
      return p;
    };

    then 方法的覆寫函數內,先執行原生的 then 方法,獲取返回結果,再判斷當前調用者 promise 對象是否有 apiUid 屬性。若是有,則表示是 API 層的 Promise,從而須要給當前 then 方法返回的 Promise 實例也添加上 apiUid 屬性。

  • 執行接口異常狀態統一處理函數

    接口異常狀態狀況下,若是業務端主動聲明須要執行接口異常狀態統一處理(業務端 catch 回調函數返回非 undefined 值),則在執行響應攔截器內 setTimeout 延遲執行的函數 handleAPIStatusError

    只要接口響應狀態爲異常,都會執行接口異常狀態統一處理函數,內部會進行斷定

    function handleAPIStatusError(pr, msg) {
      const index = unhandleAPI.findIndex(apiUid => apiUid === pr.apiUid);
      if (index >= 0) {
        pr.catch(() => {
          Message.error({ message: msg, duration: 5e3 });
        });
      }
    }

    若是 unhandleAPI 數組對象內可以找到 pr.apiUid,則表示須要執行接口異常狀態統一處理。

可能存在的問題

若是項目是由 vue-cli 搭建的 webpack 模板項目,在沒有修改 .babelrc 文件配置的狀況下,此方案在 Firefox 瀏覽器下是無效的。接口狀態異常的狀況下,老是會執行統一處理,不會先交由業務端處理異常,再斷定是否執行統一處理。

Firefox 下無效的緣由和解決方案我會在下一篇文章講解。

自我評價

我的認爲這樣的設計仍是很優雅的,認知成本很是小,對小夥伴的常規開發沒有任何污染;對框架沒有任何依賴,可移植到任何框架項目下。

另外

能力有限,哪位小夥伴有更加優雅合適的方案還望不吝賜教。

相關文章
相關標籤/搜索