對api請求封裝的探索和總結

Gauch發表於2019-02-20

第一份工作的時候我們老大讓我封裝下請求,我當即就說:封裝什麼?為什麼要封裝,本身人家的庫就已經進行封裝了啊,只需要幾個引數就可以呼叫了,封裝的還是要傳一些引數的。嗯~當時還是有點理直氣壯的,正所謂無知者無謂?當然最後我還是聽老大的了,那時候我只是封裝了幾個預設引數吧?而後經過幾年的歷練,對api請求的封裝也一直在升級,現在請陪著我來一起回顧下

為什麼進行封裝

  • 簡化使用成本。不同於庫,封裝是針對專案的,我們可以給定請求的主域名、請求頭等預設值,減少使用請求時的需要傳的引數和其他配置等
  • 可以統一處理一些邏輯,例如對請求異常的提醒處理等
  • 可以對請求進行一些改造以滿足使用習慣

怎麼封裝

回撥 VS PROMISE

很明顯,回撥容易陷入回撥地獄,所以無論請求還是其他場景我們目前的程式設計方式基本都是推薦使用promise的,尤其是新的async/await的引入更是讓promise的程式設計方式更加優雅

總是被resolve

請求總是被resolve?為什麼?如果不是,會怎樣?

async function f() {
  try {
    await Promise.reject(`出錯了`);
  } catch(e) {
    ...
  }
  ...
}
複製程式碼

正如上面這段程式碼,如果我們不加catch的話會怎樣?該f函式後面的所有的程式碼都不會被執行,也就是說如果我們要保證程式碼的健壯性則必須給async/await函式增try/catch容錯
那我們不用async了唄,確實是個不錯的主意,但我必須提醒async的幾點好處:

  • 總是返回promise,這一點很有用,例如
if (check) {
  return true
} else {
  return apiPromise()
}
複製程式碼

你是判斷返回值的型別還是resolve true?而async則比較完美的解決了這類問題!

  • async的await能讓非同步程式碼看起來是同步的,程式碼的結構也更清爽,語義也更明確
    我更相信你已經在大量使用async,所以,如果使用了async/await那麼try/catch千萬別忘記哦

即便是簡單場景下不需要使用async,promise被拒絕也會有一些小問題,例如

api().then(res=>{
  this.hideLoading()
  ...
}).catch(err=>{
  this.hideLoading()
  ...
})
複製程式碼

無論是否被成功resolve,都要執行的一些程式碼需要在兩處書寫

所以,你想推介什麼?
封裝的api請求總是被resolve,這樣是不是就沒必要關心reject了?也就不用管剛才那一堆問題了,是不是很爽??不對啊,總是有異常情況的啊,難道不管了?也resolve啊!?新增欄位區分就行了啊,是不是很聰明??

確認請求是完全理想的正確

什麼意思?我們先回想下自己是否曾大量寫過這樣的程式碼,如果沒有請忽略

api().then(res=>{
  this.hideLoading()
  if (res.code !== 0) {
    ...
  }
  ...
})
複製程式碼

因為現在很多後端因監控執行狀態等原因都不直接返回異常的http狀態碼,而是以各種code來標示是否處理成功,所以200的請求不一定是真正的請求完成,那校驗code就成為必須的了

像真正的api那樣

api有點被亂用了,api請求的api是後臺提供的業務服務介面,拋去這一種,我們腦中正常的api是什麼樣子的?是不是像這樣array.push(1),是預先定義的函式,是不需要關心內部實現的,所以請把api請求也封裝成像真正的api那樣,簡單好用,隨處可用

至此,個人關於對封裝api請求的思想基本都闡述了,我們來看看程式碼實現(基於小程式專案,供參考,核心程式碼用===============標示)

原始碼參考

先來看看最終你用的爽不爽

// 例如後臺文件是這樣的
// curl --request POST 
//  --url `http://user-interaction.ylf.org/controller/fun` 
//  --header `Content-Type: application/json` 
//  --data `{
//	"page":1
// }`

// 你只需要這樣
api.controller.fun({page: 10}).then(res=>{
  this.hideLoaing()
  if (res.errType) {
    ... // 異常處理
  }
  ... // 正常邏輯
})

// async方式
async function() {
  const res = await api.controller.fun({page: 10})
  this.hideLoaing()
  if (res.errType) {
    ... // 異常處理
  }
  ... // 正常邏輯
}
複製程式碼

目錄結構

api
├── doRequest.js // 封裝的請求方法
├── index.js     // 生成api和export請求方法等
├── inject.js    // 攔截器
├── renewToken.js // 重新獲取token
└── serviceJson.js // 供生成API的配置
複製程式碼

doRequest.js // 封裝的請求方法

import _ from `../lib/tools`
import injectMap from `./inject`
import {api as constApi} from `../constVar`
import renewToken from `./renewToken`

const apiDomain = constApi.domain

let getTokenPromise = ``// 只能同時存在一個例項
let wxSessionValid = null // 微信session_key的有效性
const checkWxSession = function () {
  return new Promise(resolve => {
    wx.checkSession({
      success() {
        resolve(true) // session_key 未過期,並且在本生命週期一直有效
      },
      fail() {
        resolve(false) // session_key 已經失效,需要重新執行登入流程
      }
    })
  })
}
// 檢查業務層是否也處理成功,引數為請求的返回值
const defaultCheckDoRequestSuccess = (res) => !res.data.error_code

export async function doRequestWithCheckSession(data = {}, opts) {
  const opt = Object.assign({needToken: true}, opts)
  if (typeof opt.needToken === `function`) { // 是否需要鑑權有一定邏輯性,則可以將needToken配置設定為返回布林值的函式,無參
    opt.needToken = opt.needToken()
  }
  if (typeof wxSessionValid !== `boolean`) {
    wxSessionValid = await checkWxSession() // 檢查微信session是否有效
  }
  let jwt = wx.getStorageSync(`jwt`)
  // 鑑權方式:業務側的鑑權和對微信session有效性的鑑權
  if (opt.needToken && (!jwt || jwt.expire_after <= +new Date() || !wxSessionValid)) { // 需要授權,已過期,去續租
    let jwt = ``
    if (getTokenPromise) {
      jwt = await getTokenPromise
    } else {
      getTokenPromise = renewToken()
      jwt = await getTokenPromise
    }
    wxSessionValid = true
    getTokenPromise = ``
    wx.setStorageSync(`jwt`, jwt)
  }
  Object.assign(opt, opt.needToken ? {httpOpt: {header: {Authorization: jwt.token}}} : {})
  return doRequest(opt.url, data, opt)
}

============================================================================================
/**
 * 請求介面函式
 * @param url
 * @param data 請求body
 * @param opt 具體配置見該函式的引數
 * @returns {Promise<any>}
 *
 * !!! 總是被解決,永遠不會被拒絕,不過你可以通過判斷是否有errType值來判斷是否請求OK
 * errType === `http` 是請求出錯
 * errType === `server` 是服務端處理出錯,需要checkDoRequestSuccess函式提供判斷邏輯
 */
export function doRequest(url, data, {
  method = `get`, httpOpt = {}, needToken = true, needToast = true,
  checkDoRequestSuccess = defaultCheckDoRequestSuccess
} = {}) {
  return new Promise((resolve) => {
    wx.request({
      url,
      data,
      method,
      ...httpOpt,
      success: (res) => { // 請求成功
        if (checkDoRequestSuccess(res)) { // 服務端也處理成功
          injectMap.forEach((val, key) => { // 匹配攔截規則
            if (key.indexOf(url.replace(apiDomain, ``)) !== -1) {
              val()
            }
          })
          resolve(res)
        } else { // 服務端處理失敗
          needToast && wx.showToast({
            title: res.data.reason || `請求出錯,請稍後重試`,
            icon: `none`,
            duration: 2000
          })
          resolve(Object.assign({
            errType: `server`
          }, res))
        }
      },
      fail: (err) => { // 請求失敗
        resolve({
          errType: `http`,
          err
        })
        checkNetWorkAndSaveCurrentPath()
      }
    })
  })
}
============================================================================================

// 檢查網路問題和記錄當前頁面的路徑
function checkNetWorkAndSaveCurrentPath() {
  /* eslint-disable no-undef */
  const pages = getCurrentPages() // 獲取當前的頁面棧
  const page = pages[pages.length - 1] // 當前的頁面
  // 避免多個請求失敗造成多個弱網頁面棧,影響回跳
  if ([`pages/normal/network`, `pages/normal/load`].indexOf(page.route) !== -1) {
    return
  }
  wx.getNetworkType({
    success: function (res) {
      const pathParamsStrArr = [] // 記錄當前頁面的路徑引數
      _.forOwn(page.options, (v, k) => {
        pathParamsStrArr.push(`${k}=${v}`)
      })
      const path = `${page.route}?${pathParamsStrArr.join(`&`)}`
      wx.setStorageSync(`badNetPagePath`, path) // 記錄被弱網中斷的頁面完整路徑
      if (res.networkType === `none`) { // 如果是沒有網路環境
        wx.redirectTo({
          url: `/pages/normal/network`
        })
      } else { // 弱網環境和其他異常情況
        wx.redirectTo({
          url: `/pages/normal/load`
        })
      }
    }
  })
}
複製程式碼

核心index.js // 生成api和export請求方法等

import serviceJson from `./serviceJson`
import { doRequestWithCheckSession, doRequest } from `./doRequest`
import _ from `../lib/tools`
import {api as constApi} from `../constVar`

const apiDomain = constApi.domain
const api = {}


serviceJson.forEach(obj => {
  const keys = obj.url.replace(///g, `.`)
  obj.url = apiDomain + obj.url
  _.set(api, keys, function (data) {
    return doRequestWithCheckSession(data, obj)
  })
})

/**
 * 呼叫示例
 * api.controller.fun({page: 10})
 *
 * 同時暴露出兩個封裝好的請求方法
 */
export default {
  ...api,
  doRequest,
  doRequestWithCheckSession
}
複製程式碼

核心serviceJson.js // 供生成API的配置

/**
 * 專案請求配置
 *
 * 引數請前往 ./doRequest.js 檢視doRequest函式說明,一下引數可能會出現變動而導致不準確
 * needToken=true 是否需要token認證
 * method=get 請求方法
 * dataType=json dataType
 * check 函式,引數為請求的返回值,要求返回布林值,true代表請求成功(後臺處理成功),false反之
 */
export default [
  {`url`: `joke/content/list`}
]
複製程式碼

inject.js // 攔截器

// 請求hooks,當請求被匹配則執行預設的回撥
// map的key為 ./serviceJson.js 配置裡的url,value為callback

// import _ from `../lib/tools`

const map = new Map()

export default map
複製程式碼

renewToken.js // 重新獲取token

import {doRequest} from `./doRequest`
import _ from `../lib/tools`
import {api as constApi} from `../constVar`

const apiDomain = constApi.domain

function navToLogin(resolve) {
  /* eslint-disable no-undef */
  const pages = getCurrentPages()
  const page = pages[pages.length - 1]
  page.openLoginModal(resolve)
}

export default async function renewToken() {
  // 確保有使用者資訊
  // 雖然只要有code即可換取使用者id,但通常我們都需要
  await new Promise(resolve => {
    wx.getSetting({
      success: (res) => {
        // 如果使用者沒有授權或者沒有必要的使用者資訊
        if (!res.authSetting[`scope.userInfo`] || !_.isRealTrue(wx.getStorageSync(`userInfoRes`).userInfo)) {
          wx.hideLoading()
          navToLogin(resolve)
        } else {
          resolve()
        }
      }
    })
  })
  return new Promise((resolve) => {
    wx.login({
      success: res => {
        login(res.code).then((jwt) => {
          resolve(jwt) // resolve jwt
        }) // 通過code進行登入
      },
      fail(err) {
        wx.showToast({
          title: err.errMsg,
          icon: `none`,
          duration: 2000
        })
      }
    })
  })
}

/**
 * 登陸,獲取jwt
 * @param code
 * @returns {Promise<any>}
 */
function login(code) {
  return new Promise((resolve) => {
    // 模擬登入換取業務端的使用者資訊和登入資訊,僅測試
    doRequest(apiDomain + `test/getToken`, {code}, { needToast: false }).then(res => {
      if (res.errType) {
        // resolve(`loginerr`)
        resolve({
          `token`: `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9`,
          `expire_after`: +new Date() + 1000 * 360 * 24
        })
        return
      }
      resolve({
        `token`: `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9`,
        `expire_after`: +new Date() + 1000 * 360 * 24
      })
    })
  })
}
複製程式碼

雖然是對API的思考,不僅限小程式,但作為同期的思考和總結,來波系列連結?
開發微信小程式必須要知道的事
微信小程式之登入態的探索

歡迎交流指正,謝謝

相關文章