天天看點

前端統一請求庫設計與落地

作者:閃念基因

前言

對于一個前端工程師而言,每天都在面對的較多的需求場景就是調用後端的接口,但是因為衆所周知的原因,前端目前已經有無數種調用接口的方式,例如:之前有基于 XHR、Axios、Fetch 進行封裝的工具,大家都試圖在統一接口的調用方式,但是他們看起來最後都需要再進行改造。于是,我們試圖在 B 站開發一套能夠綜合上述工具之長處,并結合 B 站事實需要的工具, 推出一個具有統一錯誤處理、減少代碼備援、抹平風格差異、降低文檔負擔、優化代碼提示等功能的統一請求庫。

背景

為什麼需要統一的請求庫

作為一名研發,我們會面臨各種各樣的業務需求和技術場景,這導緻我們不得不對大量的接口調用做差異化的設計和封裝,再混合開發人員的風格的差異和曆史問題,會導緻各種各樣問題的産生。

以下是比較典型的幾個問題:

  1. 代碼備援或維護成本過大:受曆史因素和業務需求影響,各團隊和倉庫中存在多版本請求庫,比如為 SSR 和 CSR 定制的處理邏輯、基于 Vue2 和 Vue3 的封裝,以及端内或 Web 的請求相容處理等。這些庫之間的子產品相似但不同,導緻維護和擴充性複雜;
  2. 存在性能問題: 公司之前的請求庫存在功能堆砌導緻代碼過多、可能存在體積過大等問題而影響頁面性能;
  3. 前後端無法協同進化:目前公司後端的基礎/通用能力已經遵循統一的标準,是以每當後端提供一種基礎能力需要前端接入時,前端由于是零散的并且沒有一個統一的标準,帶來的後果是不同的前端項目對應一種通用的後端能力需要各自開發,疊代成本高,這也是最嚴重的一個問題,其阻礙了前後端協同進化;

為了解決這些問題,我們設計了能夠解決上述問題的統一請求庫,并通過一些中間件模式的設計來盡可能減少包體積。

現狀以及能力的對比

我們調研了社群已經有的一些耳熟能詳的技術方案,看看是否已經符合我們的需求,同時剖析他們的優缺點以得出是否存在更佳方案的結論。

盡管市場上存在如 Axios 這樣的成熟請求庫,它們通過攔截器等機制提供了一定程度的擴充性,但在多端适配、中間件管理、動态配置等方面仍顯不足。例如,Axios 雖然在 Web 開發中廣受歡迎,但其在原生App内嵌H5頁面的相容處理、以及跨平台的靈活性方面存在局限。對比之下,我們新開發的基于中間件模式的統一請求庫能夠提供更為靈活的配置和擴充機制,不僅能夠動态管理請求流程中的各個環節,還能更好地适應不同平台和應用場景的需求。

前端統一請求庫設計與落地

(client-server中請求庫示意圖)

我們再來看看浏覽器原生提供的 XHR 和 fetch,XHR 的曆史無需贅述,但 fetch 作為“下一代Ajax”标準,我們相信它能走的更遠。但同時fetch目前隻具備發起請求的核心能力,而在請求的前後處理方面,它并不直接負責這些部分,因為我們可以将其作為架構的一部分,即标準之一,因為這樣才能讓更多的團隊适應以及接受這個約定。

社群似乎并不能完美覆寫我們的場景,不如我們先嘗試定義一套協定,由于我們已經對 KOA 等服務架構比較了解,其設計模式啟發了我們,是以我們決定以中間件模式作為我們的基礎通用型協定來實作我們的“統一請求庫”。

目标

首先,我們先為統一請求庫下一個定義:

⭐️ 一個 标準、靈活、強大 的 服務調用工具集 ⭐️

我們主要從以下幾個方面來看,統一請求庫能夠解決的問題:

1. 标準化和統一:統一不同前端倉庫中的接口調用方式,進而降低因使用不同請求庫而引發的行為差異和問題;

2. 完成場景優化:多平台代理優化( App 端 内嵌H5 場景的請求庫) 徹底解決體積過大等曆史問題;

3. 實作一鍵全局能力注冊;

4. 內建基礎設施:統一請求庫與全團隊的基礎技術生态進行內建,形成生态;

設計思路與實作

模式選型思路

為了解決B站複雜的業務場景,我們先将應用拆成各個單一的場景。在各個單一的場景下,我們希望一個單例對應一個場景,并且我們考慮到業務的靈活多變性,需要提供內建插件式邏輯的能力。

我們首先确定的是需要基于面向對象開發。其次,我們将工廠內建方案放在一邊,暫時因為這個在B站内部的曆史方案的不足,已經得到了一定的驗證。我們必須肯定的是 Axios 在前端領域網絡請求庫的翹楚地位,參考 Axios 中我們認為有價值的攔截器部分,以及 Koa 帶來的中間件形式的啟發,取二者做結合,這便有了前端領域的中間件模式。根據一開始的需求場景關系,有這麼一張圖可以理清工作原理。

前端統一請求庫設計與落地

(需求場景關系圖)

這就是請求庫基于面向對象設計,提供靈活插件能力,同時根據場景也能保持統一邏輯形式的原理。

中間件模式

在統一請求庫中,中間件為我們提供了插件式粒度的內建能力。中間件模式通過将請求處理流程分解為一系列獨立的功能單元,每個單元負責處理請求的一個特定方面,如日志記錄、錯誤處理、資料轉換等。這些中間件按照預定的順序組成一個處理鍊,請求和響應資料在鍊中依次通過每個中間件,每個中間件可以對資料進行處理、修改或者直接終止請求。這種模式的靈感來源于 Koa 架構的洋蔥模型,其中請求和響應像穿過洋蔥的每一層一樣,經過每個中間件的處理。這不僅增強了請求處理的靈活性和可擴充性,而且使得每個中間件都可以獨立開發和測試,大大提高了代碼的可維護性。它們在一定的秩序下這些中間件組合成一個請求邏輯,這個秩序,我們同樣選擇了洋蔥模式。在開始我們的請求庫示例之前,我們先介紹一下“洋蔥模型”。

什麼是洋蔥模型?

想象一下,一個洋蔥的結構——由許多層組成,每剝開一層就能看到下一層。在洋蔥模式中,每個中間件都像是洋蔥的一層,請求從最外層開始傳遞,依次穿過每一層中間件,直到達到核心處理邏輯,然後再逐層傳回,每一層都有機會對請求和響應進行處理。

前端統一請求庫設計與落地

Koa的洋蔥模型

我們的統一請求庫也基于洋蔥模型有自己的調用鍊:

前端統一請求庫設計與落地

請求庫的洋蔥模型

洋蔥模式同時是一種責任鍊模式(Chain of Responsibility Pattern),這種設計模式相較于傳統的大工廠模式優點十分明顯,它能最大程度地降低耦合度,增強了每個節點的靈活性,并且職責極其明确。

前端統一請求庫設計與落地

當然,這種模式也有一定的缺點,例如調試較困難以及鍊的管理等問題,需要好的團隊風格規範去限制。

是以我們需要約定請求庫中間件的處理原則來盡量規避這些問題,例如我們原則上規定允許編輯上下文中的 request 對象,而不是 config 對象。

中間件的擴充性

按照職責的劃分,我們有了明确的獨立處理者。于是在職責明确前,做好基礎協定的擴充也是必不可少的環節。為了實作統一的請求庫,我們就要讓它的各個部分做到可增、可減、可替換以及可拓展。

基礎模型

提供一個中間件基礎抽象類,作為所有插件的原型,這是面向對象開發中最重要的一步,同時也提供了類型檢查以提示開發者實作必須的接口。

覆寫預設行為

從内部内置的中間件開始,我們就設計成可通過同名方式索引替換目标内置中間件,使用者不僅可以通過自定義的方式建立同名中間件,也可以通過繼承或者直接調用這些中間件的靜态方法來快速實作内置能力的拓展。

另外,我們區分了 Fetch 中間件和其他所有中間件,因為從根源上這兩類是有差別的。其他所有中間件在順序上,按照先全局後臨時的方式排列,最後的,也是作為洋蔥中心的就是 Fetch 中間件,是以在覆寫這些預設行為上,我們也做了不同的接口區分。

例如執行個體化的時候,我們設計的接口預設是傳入中間件清單,是因為修改 Fetch 是一個低頻的行為,并且在傳入對象配置時,可以讓使用者清晰的将二者分開。

...
    /**
     * 初始化
     * @param initConfig 初始配置,參考interface
     */
    (initConfig?: IHttpServiceInit | IHttpSvcMiddleware[]);   
    ...
}
    
// interface
export interface IHttpServiceInit {
  baseURL?: FetchBaseURL
  fetch?: IHttpSvcMiddleware
  middlewares?: IHttpSvcMiddleware[]
}           

範圍制定

我們提出了“全局”與“臨時”這樣的範圍概念。全局中間件的作用範圍在執行個體化的時候就已經确定,至于這個中間件邏輯是否真正作用于該執行個體發起的每一個請求,其實還是掌握在使用者手中。通過基于基礎抽象類去派生中間件,這種模式允許使用者自行定義子類的更多行為,這從某種意義上也是擴充性的一部分。

例如,使用者可以真正實作 Provide/Inject,即先注入,後按需使用,這完全取決于你在撰寫中間件内部邏輯時是否預設開啟功能,進而在後續調用請求時,是否允許通過一個激活的行為,将邏輯開關打開。

const globalMeta = (ctx, next, config) => {
    if (config?.payload?.active) {
        ctx.request.params["meta"] = {
            platform: "web",
            device: ""
        }
    }
    await next()
}
class GlobalMeta extends Middleware {
    name = "GLOBA_META"
    constructor() {
        super(globalMeta)
    }
}


const httpSvc = new HttpService([new GlobalMeta()])
// 預設該能力注冊,但是沒有激活辨別不會工作。


httpSvc.with("GLOBAL_META", { active: true })
// 通過指定激活全局注冊過的中間件名來使其工作(使活躍)           

從這些目标出發,可以找到一一對應的關系:

  1. 索引鍵——中間件名稱
  2. 全局注冊方式——Register API
  3. 臨時攜帶處理者及狀态——With API
  4. 通過索引中間件名稱的方式,臨時禁用全局能力——Disable API

Package的分離

我們将 middleware 作為一個獨立的包,而不是作為請求庫這個架構的子導出成員,考慮這樣做的原因是可以減輕使用者制作中間件的了解成本,并且允許這種方式的一個重要條件是中間件的基礎形态已經确認。

控制器

我們為請求庫設計了三個控制子產品:

1. ConfigCtrl,配置控制器

2. AssembleCtrl,裝配控制器

3. RequestCtrl,請求控制器

如若用一句話串聯起這三個子產品:在使用請求庫發起請求時,調用裝配控制器收集全部的臨時中間件與 Payload(載荷),同時将 Payload 存入配置控制器中,收集完畢後通過組合中間件邏輯産出請求方法,同時配置控制器将産出中間件配置上下文,将這些物料一齊彙向請求控制器,建立請求上下文,發起請求。

通過依賴注入的方式,将三個職責分明的子產品互相關聯起來:

前端統一請求庫設計與落地

整體設計圖

裝配控制器(AssembleCtrl)

顧名思義,裝配控制器負責裝配各種物件,具體物件包括:

1. 中間件

2. 臨時 Payload

它既可以為已經注冊過的全局中間件裝配 Payload,也可以攜帶臨時中間件一同挂載 Payload;除了增,也能減;我們也提供了禁用功能,可以禁用那些已經全局注冊的中間件;

這一增一減就能夠靈活控制用于發起請求的所有中間件的活躍态;并且臨時 Payload 的設計相較于傳統的一個大 Config 對象模式更直覺清晰,這有效地避免了配置成員無限增加這種不可維護的趨勢,同時相較于大對象内置邏輯的這種方式,我們的中間件邏輯都是可熱拔插的。

配置控制器(ConfigCtrl)

配置控制器為每次請求生成中間件上下文,所有的配置都記錄在此,組裝時參與各種設定配置,運作時為中間件注入配置。

請求控制器(RequestCtrl)

在裝配控制器為核心的模式下,準備好一切條件,發起請求時調用請求控制器,請求控制器用于将組裝好的請求函數(RequestFunction),結合中間件配置上下文以及初始請求配置生成 Context 後,執行請求。

小結

裝配控制器作為鍊式模式最重要的控制器,也為 HTTP Service 提供直接的 API,實作它循環鍊式調用能力的核心需要實作一個 Dispatcher(排程器)。

通過這個排程器,三個控制器在一次請求排程發起時的工作流程如下:

前端統一請求庫設計與落地

排程流程圖

實作組合排列

通過排程器的排程,無論是添加 Payload 還是設定禁用,我們都要有一套明确的組織規則去将排程後得到的資源進行有序整合。上文提到,我們确定了先全局、後臨時的基調,這在直覺上也是符合規律的。

全局注冊的時機相對是靠前的,這個時機遠遠早于臨時攜帶的部分,是以才會有全局注冊能力在前這一說法。當然,想到這裡,我們也考慮到使用者也可能有想操作全局已經固定位置的中間件順序,是以我們可以繼續向上擴充,于是有了全局中間件提權的邏輯。

前端統一請求庫設計與落地

全局提權行為

成果

直接收益

各個團隊通過接入統一請求庫,首先在接入公司級通用能力時,僅需通過安裝 NPM 包即可快速內建,所需開發資源大大降低。在适配多種平台方面,我們提供了平替老 SDK 的專用 Fetch 中間件包,解放了受困于舊的封裝形式,穩固了中間件拓展內建的風格,而且還為建構一個健康、可持續發展的前端技術生态打下了堅實的基礎。

前端統一請求庫設計與落地

價值模型

橫向對比

假設我們調用一個API,要實作:

1. 對入參進行 encode

2. 請求需要上報狀态(成功or失敗)

3. 需要擷取 headers 裡的某标記

4. 該接口傳回的是非 JSON 格式(如純文字)

5. 服務端渲染請求時需要透傳 headers(user-agent,ip 等 kv)

傳統方式

// 傳統 API 形式
// http 内部需要實作 encode,responseType 等邏輯
http.request({
    url: '/xxx',
    params: { id: 1 },
    encode: true,
    report: true,
    responseType: 'text',
    responseAll: true,
    headers: {
        ...(typeof window === 'undefined' ? context.headers : {})
    },
})           

每需要增加一個 config key,都要深入http核心裡去增加一個 if case,核心會随着疊代越來越大直至耦合到難以拆分的地步。

統一請求庫方式

// 請求庫
httpSvc
    .with(encodeHandler),
    .with(reportHandler),
    .with('RES_DATA', { type: 'text' }) // 對應 responseType
    .disable('RES_EXTRACT') // 負責從 response 對象中取得data資料,我們此舉會禁用該中間件進而實作擷取到整個響應對象,對應上面的responseAll的輸入
    .with('SERVER_SIDE', { headers: context.headers || {} })
    .request({
        url: '/xxx',
        params: { id: 1 },
    })           

對比傳統方式的每增加一個功能就要增加一個配置鍵這種形式,請求庫通過鍊式方式順序一一調用指定中間件的方式,直覺清晰,并且充分解耦,好維護。

結語

随着 HttpService 的不斷成熟和完善,它已成為我們處理前端網絡請求不可或缺的基礎能力之一。它的靈活性和擴充性極大地簡化了前端開發工作,使我們能夠更加專注于創造出色的使用者體驗。随着技術的不斷進步,我們相信,基于中間件模式的請求庫将繼續引領前端請求處理的創新,為開發者帶來更多可能性。

通過上面的介紹,相信你已經了解如何使用請求庫的幾項基本能力了,如果想要更多案例,請前往我們的 Github()站點,在那您将獲得:

  1. 内置中間件定義及說明
  2. 社群已釋出的公共中間件
  3. 更全的設計介紹,方案對比
  4. 玩壞中間件的N種方式

作者:大前端

來源-微信公衆号:哔哩哔哩技術

出處:https://mp.weixin.qq.com/s/NNeTDuTUbbrM4G_cqUhpFg

繼續閱讀