基於Observable構建前端防腐策略

To B 業務的生命週期與迭代通常會持續多年,隨著產品的迭代與演進,以介面呼叫為核心的前後端關係會變得非常複雜。在多年迭代後,介面的任何一處修改都可能給產品帶來難以預計的問題。在這種情況下,構建更穩健的前端應用,保證前端在長期迭代下的穩健與可拓展性就變得非常重要。本文將重點介紹如何利用介面防腐策略避免或減少介面變更對前端的影響。

一  困境與難題

為了更清晰解釋前端面臨的難題,我們以 To B 業務中常見的儀表盤頁面為例,該頁面包含了可用記憶體、已使用記憶體和已使用的記憶體佔比三部分資訊展示。
此時前端元件與介面之間的依賴關係如下圖所示。
當介面返回結構調整時MemoryFree 元件對介面的呼叫方式需要調整。同樣的,MemoryUsage 與 MemoryUsagePercent 也要進行修改才能工作。
真實的 To B 業務面臨的介面可能會有數百個,元件與介面的整合邏輯也遠比以上的例子要複雜。
經過數年甚至更長時間的迭代後,介面會逐步產生多個版本,出於對介面穩定性及使用者使用習慣的考量,前端往往會同時依賴介面的多個版本來構建介面。當部分介面需要調整下線或發生變更時,前端需要重新理解業務邏輯,並做出大量程式碼邏輯調整才能保證介面穩定執行。
常見的對前端造成影響的介面變更包括但不限於:
  • 返回欄位調整
  • 呼叫方式改變
  • 多版本共存使用
當前端面對的是平臺型業務時,此類問題會變得更為棘手。平臺型產品會對一種或多種底層引擎進行封裝,例如機器學習平臺可能會基於 TensorFlow、Pytorch 等機器學習引擎搭建,即時計算平臺可能基於 Flink、Spark 等計算引擎搭建。
雖然平臺會對引擎的大部分介面進行上層封裝,但不可避免的仍然會有部分底層介面會直接被透傳到前端,在這個時候,前端不僅要應對平臺的介面變更,還會面臨著開源引擎介面的變更帶來的挑戰。
前端在面臨的困境是由獨特的前後端關係決定的。與其他領域不同,在 To B 業務中,前端通常以下游客戶的身份接受後端供應商的供給,有些情況下會成為後端的跟隨者。
在客戶/供應商關係中,前端處於下游,而後端團隊處於上游,介面內容與上線時間通常由後端團隊來決定。
在跟隨者關係中,上游的後端團隊不會去根據前端團隊的需求進行任何調整,前端只能去順應上游後端的模型。這種情況通常發生在前端無法對上游後端團隊施加影響的時刻,例如前端需要基於開源專案的介面設計介面,或者是後端團隊的模型已經非常成熟且難以修改時。
《架構整潔之道》的作者描述過這樣一個嵌入式架構設計的難題,與上文我們描述的困境十分類似。
軟體應當是一種使用週期很長的東西,而韌體會隨著硬體的演進而淘汰過時,但事實上的情況是,雖然軟體本身不會隨著時間推移而磨損,但硬體及其韌體卻會隨時間推移而過時,隨即也需要對軟體做相應的改動。
無論是客戶/供應商關係,還是跟隨者關係,正如軟體無法決定硬體的發展與迭代一樣,前端也很難或者無法決定引擎與介面的設計,雖然前端本身不會隨著時間的推移而變得不可用,但技術引擎及相關介面卻會隨著時間推移而過時,前端程式碼會跟隨技術引擎的迭代更換逐步腐爛,最終難逃被迫重寫的命運。

二  防腐層設計

早在 Windows 誕生之前,工程師為了解決上文中硬體、韌體與軟體的可維護性問題,引入了 HAL(Hardware Abstraction Layer)的概念, HAL 為軟體提供服務並且遮蔽了硬體的實現細節,使得軟體不必由於硬體或者韌體的變更而頻繁修改。
HAL 的設計思想在領域驅動設計(DDD) 中又被稱為防腐層(Anticorruption Layer)。在 DDD 定義的多種上下文對映關係中,防腐層是最具有防禦性的一種。它經常被使用在下游團隊需要阻止外部技術偏好或者領域模型入侵的情況,可以幫助很好地隔離上游模型與下游模型。
我們可以在前端中引入防腐層的概念,降低或避免當前後端的上下文對映介面變更對前端程式碼造成的影響。
在行業內有很多種方式可以實現防腐層,無論是近幾年大火的 GraphQL 還是 BFF 都可以作為備選方案,但是技術選型同樣受限於業務場景。與 To C 業務完全不同,在 To B 業務中,前後端的關係通常為客戶/供應商或者跟隨者/被跟隨者的關係。在這種關係下,寄希望於後端配合前端對介面進行 GraphQL 改造已經變得不太現實,而 BFF 的構建一般需要額外的部署資源及運維成本。
在上述情況下,在瀏覽器端構建防腐層是更為可行的方案,但是在瀏覽器中構建防腐層同樣面臨挑戰。
無論是 React、Angular 還是 Vue 均有無數的資料層解決方案,從 Mobx、Redux、Vuex 等等,這些資料層方案對檢視層實際上都會有入侵,有沒有一種防腐層解決方案可以與檢視層徹底解耦呢?以 RxJS 為代表的 Observable 方案在這時可能是最好的選擇。
RxJS 是 ReactiveX 專案的 JavaScript 實現,而 ReactiveX 最早是 LINQ 的一個擴充套件,由微軟的架構師 Erik Meijer 領導的團隊開發。該專案目標是提供一致的程式設計介面,幫助開發者更方便的處理非同步資料流。目前 RxJS 在開發中經常被作為響應式程式設計開發工具使用,但是在構建防腐層的場景中,RxJS 代表的 Observable 方案同樣可以發揮巨大作用。
我們選擇 RxJS 主要基於以下幾點考慮:
  • 統一不同資料來源的能力:RxJS 可以將 websocket、http 請求、甚至使用者操作、頁面點選等轉換為統一的 Observable 物件。
  • 統一不同型別資料的能力:RxJS 將非同步資料和同步資料統一為 Observable 物件。
  • 豐富的資料加工能力:RxJS 提供了豐富的 Operator 運算子,可以對 Observable 在訂閱前進行預先加工。
  • 不入侵前端架構:RxJS 的 Observable 可以與 Promise 互相轉換,這意味著 RxJS 的所有概念可以被完整封裝在資料層,對檢視層可以只暴露 Promise。
當在引入 RxJS 將所有型別的介面轉換為 Observable 物件後,前端的檢視元件將僅依賴 Observable,並與介面實現的細節解耦,同時,Observable 可以與 Promise 相互轉換,在檢視層獲得的是單純的 Promise,可以與任意資料層方案和框架搭配使用。
除了轉換為 Promise 之外,開發者也可以與 RxJS 在渲染層的解決方案,例如 rxjs-hooks 混用,獲得更好的開發體驗。

三  防腐層實現

參照上文的防腐層設計,我們在開頭的儀表盤專案中實現以 RxJS Observable 為核心的防腐層程式碼。
其中防腐層的核心程式碼如下
exportfunctiongetMemoryFreeObservable(): Observable<number> {return fromFetch("/api/v1/memory/free").pipe(mergeMap((res) => res.json()));}exportfunctiongetMemoryUsageObservable(): Observable<number> {return fromFetch("/api/v1/memory/usage").pipe(mergeMap((res) => res.json()));}exportfunctiongetMemoryUsagePercent(): Promise<number> {return lastValueFrom(forkJoin([getMemoryFreeObservable(), getMemoryUsageObservable()]).pipe( map(([usage, free]) => +((usage / (usage + free)) * 100).toFixed(2)) ));}exportfunctiongetMemoryFree(): Promise<number> {return lastValueFrom(getMemoryFreeObservable());}exportfunctiongetMemoryUsage(): Promise<number> {return lastValueFrom(getMemoryUsageObservable());}
MemoryUsagePercent 的實現程式碼如下,此時該元件將不再依賴具體的介面,而直接依賴防腐層的實現。
functionMemoryUsagePercent() {const [usage, setUsage] = useState<number>(0); useEffect(() => {(async () => {const result = await getMemoryUsagePercent(); setUsage(result); })(); }, []);return <div>Usage: {usage} %</div>;}exportdefaultMemoryUsagePercent;

1  返回欄位調整

返回欄位變更時,防腐層可以有效攔截介面對元件的影響,當 /api/v2/quota/free 與 /api/v2/quota/usage 的返回資料變更為以下結構時
{requestId: string;data: number;}
我們只需要調整防腐層的兩行程式碼,注意此時我們的上層封裝的 getMemoryUsagePercent 基於 Observable 構建所以不需要進行任何改動。
exportfunctiongetMemoryUsageObservable(): Observable<number> {return fromFetch("/api/v2/memory/free").pipe( mergeMap((res) => res.json()),+ map((data) => data.data) );}exportfunctiongetMemoryUsageObservable(): Observable<number> {return fromFetch("/api/v2/memory/usage").pipe( mergeMap((res) => res.json()),+ map((data) => data.data) );}
在 Observable 化的防腐層中,會存在高階 Observable 與 低階 Observable 兩種設計,在上文的例子中,Free Observable 和 Usage Observable 為低階封裝,而 Percent Observable 利用 Free 和 Usage 的 Observable 進行了高階封裝,當低階封裝改動時,由於 Observable 本身的特性,高階封裝經常是不需要進行任何改動的,這也是防腐層給我們帶來的額外好處。

2  呼叫方式改變

當呼叫方式發生改變時,防腐層同樣可以發揮作用。/api/v3/memory 直接返回了 free 與 usage 的資料,介面格式如下。
{requestId: string;data: { free: number;usage: number; }}
防腐層程式碼只需要進行如下更新,就可以保障元件層程式碼無需修改。
exportfunctiongetMemoryObservable(): Observable<{ free: number; usage: number }> {return fromFetch("/api/v3/memory").pipe( mergeMap((res) => res.json()), map((data) => data.data) );}exportfunctiongetMemoryFreeObservable(): Observable<number> {return getMemoryObservable().pipe(map((data) => data.free));}exportfunctiongetMemoryUsageObservable(): Observable<number> {return getMemoryObservable().pipe(map((data) => data.usage));}exportfunctiongetMemoryUsagePercent(): Promise<number> {return lastValue(getMemoryObservable().pipe( map(({ usage, free }) => +((usage / (usage + free)) * 100).toFixed(2)) ));}

3  多版本共存使用

當前端程式碼需要在多套環境下部署時,部分環境下 v3 的介面可用,而部分環境下只有 v2 的介面部署,此時我們依然可以在防腐層遮蔽環境的差異。
exportfunctiongetMemoryLegacyObservable(): Observable<{ free: number; usage: number }> {const legacyUsage = fromFetch("/api/v2/memory/usage").pipe( mergeMap((res) => res.json()) );const legacyFree = fromFetch("/api/v2/memory/free").pipe( mergeMap((res) => res.json()) );return forkJoin([legacyUsage, legacyFree], (usage, free) => ({ free: free.data.free, usage: usage.data.usage, }));}exportfunctiongetMemoryObservable(): Observable<{ free: number; usage: number }> {const current = fromFetch("/api/v3/memory").pipe( mergeMap((res) => res.json()), map((data) => data.data) );return race(getMemoryLegacyObservable(), current);}exportfunctiongetMemoryFreeObservable(): Observable<number> {return getMemoryObservable().pipe(map((data) => data.free));}exportfunctiongetMemoryUsageObservable(): Observable<number> {return getMemoryObservable().pipe(map((data) => data.usage));}exportfunctiongetMemoryUsagePercent(): Promise<number> {return lastValue(getMemory().pipe( map(({ usage, free }) => +((usage / (usage + free)) * 100).toFixed(2)) ));}
透過 race 運算子,當 v2 與 v3 任何一個版本的介面可用時,防腐層都可以正常工作,在元件層無需再關注介面受環境的影響。

四  額外應用

防腐層不僅僅是多了一層對介面的封裝與隔離,它還能起到以下作用。

1  概念對映

介面語義與前端需要資料的語義有時並不能完全對應,當在元件層直接呼叫介面時,所有開發者都需要對介面與介面的語義對映足夠了解。有了防腐層後,防腐層提供的呼叫方法包含了資料的真實語義,減少了開發者的二次理解成本。

2  格式適配

在很多情況下,介面返回的資料結構與格式與前端需要的資料格式並不符合,透過在防腐層增加資料轉換邏輯,可以降低介面資料對業務程式碼的入侵。在以上的案例裡,我們封裝了 getMemoryUsagePercent 的資料返回,使得元件層可以直接使用百分比資料,而不需要再次進行轉換。

3  介面快取

對於多種業務依賴同一介面的情況,我們可以透過防腐層增加快取邏輯,從而有效降低介面的呼叫壓力。
與格式適配類似,將快取邏輯封裝在防腐層可以避免元件層對資料的二次快取,並可以對快取資料集中管理,降低程式碼的複雜度,一個簡單的快取示例如下。
classCacheService{private cache: { [key: string]: any } = {}; getData() {if (this.cache) {return of(this.cache); } else {return fromFetch("/api/v3/memory").pipe( mergeMap((res) => res.json()), map((data) => data.data), tap((data) => {this.cache = data; }) ); } }}

4  穩定性兜底

當介面穩定性較差時,通常的做法是在元件層對 response error 的情況進行處理,這種兜底邏輯通常比較複雜,元件層的維護成本會很高。我們可以透過防腐層對穩定性進行兜底,當接口出錯時可以返回兜底業務資料,由於兜底資料統一維護在防腐層,後續的測試與修改也會更加方便。在上文中的多版本共存的防腐層中,增加以下程式碼,此時即使 v2 和 v3 介面都無法返回資料,前端仍然可以保持可用。
return race(getMemoryLegacy(), current).pipe(+ catchError(() =>of({ usage: '-', free: '-' })) );

5  聯調與測試

介面和前端可能會存在並行開發的狀態,此時,前端的開發並沒有真實的後端介面可用。與傳統的搭建 mock api 的方式相比,在防腐層直接對資料進行 mock 是更方便的方案。
exportfunctiongetMemoryFree(): Observable<number> {returnof(0.8);}exportfunctiongetMemoryUsage(): Observable<number> {returnof(1.2);}exportfunctiongetMemoryUsagePercent(): Observable<number> {return forkJoin([getMemoryUsage(), getMemoryFree()]).pipe( map(([usage, free]) => +((usage / (usage + free)) * 100).toFixed(2)) );}
在防腐層對資料進行 mock 也可以用於對頁面的測試,例如 mock 大量資料對頁面效能影響。
exportfunctiongetLargeList(): Observable<string[]> {const options = [];for (let i = 0; i < 100000; i++) {const value = `${i.toString(36)}${i}`; options.push(value); }return of(options);}

五  總結

在本文中我們介紹了以下內容:
  1. 前端面對介面頻繁變動時的困境及原因如何
  2. 防腐層的設計思想與技術選型
  3. 使用 Observable 實現防腐層的程式碼示例
  4. 防腐層的額外作用
請讀者注意,只在特定的場景下引入前端防腐層才是合理的,即前端處於跟隨者或供應商/客戶關係中,且面臨大量介面無法保障穩定和相容。如果在防腐層可以在後端 Gateway 構建,或者介面數量較少時,引入防腐層帶來的額外成本會大於其帶來的好處。
RxJS 在防腐層構建場景下提供的更多的是 Observable 化的能力,如果讀者不需要複雜的 operators 轉換工具,也可以自行構建 Observable 構建方案,事實上只需要 100 行的程式碼就可以實現 https://stackblitz.com/edit/mini-rxjs
改造後的前端架構將不再直接依賴介面實現,不會入侵現有前端資料層設計,還可以承擔概念對映、格式適配、介面快取、穩定性兜底以及協助聯調測試等工作。文中所有的示例程式碼都可以在倉庫 https://github.com/vthinkxie/rxjs-acl 獲得。

求職特訓營火熱來襲!阿里專家教你製作專業簡歷!
如何在眾多簡歷中吸引HR關注?如何描述過往經歷突出亮點?如何增加簡歷加分項?阿里專家從面試官角度告訴你一份高質量簡歷應該長什麼樣!
阿里雲開發者社群舉辦“阿里專家五堂課教你製作專業簡歷”訓練營,邀請四位阿里專家傾情傳授簡歷秘籍,深挖簡歷承載的價值,從面試官的角度切入講解簡歷必含模組,更有資深專家直播線上答疑幫你修改簡歷!金三銀四黃金求職季,阿里專家助力你的求職之路!還在等什麼?立即點選閱讀原文免費報名參加!


相關文章