攜程度假基于 RPC 和 TypeScript 的 BFF 設(shè)計(jì)與實(shí)踐
作者簡(jiǎn)介
工業(yè)聚,攜程高級(jí)前端開(kāi)發(fā)專(zhuān)家,react-lite, react-imvc, farrow, remesh 等開(kāi)源項(xiàng)目作者,專(zhuān)注 GUI 開(kāi)發(fā)、框架設(shè)計(jì)、工程化建設(shè)等領(lǐng)域。
一、前言
隨著多終端的發(fā)展,前后端的數(shù)據(jù)交互的復(fù)雜性和多樣性都在急劇增加。不同的終端,其屏幕尺寸和頁(yè)面 UI 設(shè)計(jì)不一,對(duì)接口的數(shù)據(jù)需求也不盡相同。構(gòu)建一套接口滿(mǎn)足所有場(chǎng)景的傳統(tǒng)方式,面對(duì)新的復(fù)雜性日益捉襟見(jiàn)肘。
在這個(gè)背景下,BFF 作為一種模式被提出。其全稱(chēng)是 Backend for frontend,即為前端服務(wù)的后端。它的特點(diǎn)是考慮了不同端的數(shù)據(jù)訪(fǎng)問(wèn)需求,并給予各端針對(duì)性的優(yōu)化。
在這篇文章中,我們將介紹一種基于 RPC 和 TypeScript 的 BFF 設(shè)計(jì)與實(shí)踐。我們稱(chēng)之為 RPC-BFF,它利用前后端都采用同一語(yǔ)言(TypeScript)的優(yōu)勢(shì),實(shí)現(xiàn)了其它 BFF 技術(shù)方案所不具備的多項(xiàng)功能,可以顯著提升前后端數(shù)據(jù)交互的性能、效率以及類(lèi)型安全。
二、為什么會(huì)需要 BFF?
用發(fā)展的視角來(lái)看,業(yè)界存在的兩大趨勢(shì)催生了 BFF 模式:
- 硬件行業(yè)的多終端發(fā)展趨勢(shì)
- 軟件行業(yè)的微服務(wù)發(fā)展趨勢(shì)
其中,微服務(wù)化以及中臺(tái)化的趨勢(shì),改變了后端團(tuán)隊(duì)構(gòu)建服務(wù)的方式。整個(gè)系統(tǒng)將按照領(lǐng)域模型分隔出多個(gè)微服務(wù),這增強(qiáng)了各個(gè)服務(wù)的內(nèi)聚性和可復(fù)用性的同時(shí),也給下游的接口調(diào)用者增加了數(shù)據(jù)聚合的成本。
多終端的發(fā)展,又讓數(shù)據(jù)聚合的需求進(jìn)一步多樣化。使得處于微服務(wù)和多終端之間的團(tuán)隊(duì),不管是前端團(tuán)隊(duì)還是后端團(tuán)隊(duì),他們面對(duì)的問(wèn)題日趨復(fù)雜化。
系統(tǒng)復(fù)雜度的增加,將體現(xiàn)在代碼復(fù)雜度和團(tuán)隊(duì)協(xié)作復(fù)雜度上。這意味著,沒(méi)有采用有效手段應(yīng)對(duì)復(fù)雜度的團(tuán)隊(duì),自身將成為產(chǎn)研流程中的瓶頸。他們既要面對(duì)上游多個(gè)微服務(wù)的聯(lián)調(diào)需求,又要應(yīng)對(duì)下游多個(gè)端的數(shù)據(jù)消費(fèi)需求;有更多的會(huì)議要參加,更多的需求文檔要讀,更多的代碼、單元測(cè)試和技術(shù)文檔要寫(xiě),然而敏捷開(kāi)發(fā)模式的交付周期卻不隨之增加。
此時(shí)一般有兩個(gè)應(yīng)對(duì)策略。一種是去掉數(shù)據(jù)聚合層,讓各個(gè)下游前端應(yīng)用自行對(duì)接微服務(wù)接口,將聚合數(shù)據(jù)的業(yè)務(wù)邏輯轉(zhuǎn)移到前端,增加它們的代碼體積,拖慢其加載速度,同時(shí)顯著增加客戶(hù)端向服務(wù)端發(fā)起的請(qǐng)求數(shù),達(dá)到拉低頁(yè)面渲染性能,破壞用戶(hù)體驗(yàn)的效果。
另一種做法則是采用 BFF 模式,降低前后端數(shù)據(jù)交互的復(fù)雜度。微服務(wù)強(qiáng)調(diào)按領(lǐng)域模型分隔服務(wù),BFF 則強(qiáng)調(diào)按終端類(lèi)型分隔服務(wù)。將原本單個(gè)團(tuán)隊(duì)處理“多對(duì)多”的復(fù)雜關(guān)系,轉(zhuǎn)變成多個(gè)團(tuán)隊(duì)處理的“一對(duì)多”關(guān)系。因此,本質(zhì)上,BFF 模式的優(yōu)化方式是通過(guò)調(diào)整開(kāi)發(fā)團(tuán)隊(duì)在人力組織關(guān)系層面的職能分工而實(shí)現(xiàn)的。
也就是說(shuō),BFF 不是作為新技術(shù)用以提升生產(chǎn)力,而是通過(guò)改變生產(chǎn)關(guān)系去解放生產(chǎn)力。從單一團(tuán)隊(duì)成為產(chǎn)研流程中的單點(diǎn)瓶頸,轉(zhuǎn)變成多個(gè) BFF 研發(fā)團(tuán)隊(duì)各自應(yīng)對(duì)某一端的數(shù)據(jù)聚合需求。
這種轉(zhuǎn)變所爭(zhēng)取到的是增加人手以提升效率的空間。一個(gè)工作任務(wù)相耦合的研發(fā)團(tuán)隊(duì)不能無(wú)限加人提效,但若能拆成多個(gè)研發(fā)團(tuán)隊(duì),則可以擴(kuò)大每個(gè)子團(tuán)隊(duì)的提效空間。當(dāng)然,代價(jià)是團(tuán)隊(duì)之間可能存在重復(fù)工作,只是相比效率瓶頸而言,這些問(wèn)題可能不屬于現(xiàn)階段主要矛盾,值得取舍。
盡管 BFF 不是新技術(shù),也不要求新技術(shù),但不意味著不能引入新技術(shù),或者引入新技術(shù)無(wú)法提效。我們?nèi)钥梢杂眯录夹g(shù)去實(shí)現(xiàn) BFF,解決因團(tuán)隊(duì)拆分而產(chǎn)生的問(wèn)題,收獲研發(fā)效率(生產(chǎn)力)和產(chǎn)研流程(生產(chǎn)關(guān)系)兩方面的提升。
接下來(lái),我們先看一下實(shí)現(xiàn) BFF 的幾種不同方式,然后介紹作為新技術(shù)出現(xiàn)的 RPC-BFF。
三、BFF 的實(shí)現(xiàn)方式
如前所述,BFF 是作為模式(Pattern)被提出,而非一種新技術(shù)。在技術(shù)層面上,任何支持服務(wù)端開(kāi)發(fā)的語(yǔ)言和運(yùn)行時(shí),不管是 Java、Python、Go 還是 Node.js,都可以開(kāi)發(fā) BFF 服務(wù)。只要它們所實(shí)現(xiàn)的這個(gè)服務(wù),是面向前端需求的。
BFF 的實(shí)現(xiàn)方式多種多樣,不僅跟技術(shù)選型有關(guān),跟研發(fā)團(tuán)隊(duì)的分工方式、職能邊界、協(xié)作流程等因素也息息相關(guān)。
3.1 樸素模式
所謂的樸素模式,是指 BFF 的實(shí)現(xiàn)方式不改變前后端的分工方式、職能邊界和協(xié)作流程。前端不介入 BFF 層的實(shí)現(xiàn),BFF 層的需求由后端團(tuán)隊(duì)自行消化。
也就是說(shuō),BFF 在這里僅僅是后端團(tuán)隊(duì)的內(nèi)部分工:
- 開(kāi)發(fā)微服務(wù)應(yīng)用(后端團(tuán)隊(duì))
- 開(kāi)發(fā) BFF 服務(wù)(后端團(tuán)隊(duì))
- 消費(fèi) BFF 接口(前端團(tuán)隊(duì))
前端的工作方式跟之前一樣,后端負(fù)責(zé)開(kāi)發(fā) BFF 為前端團(tuán)隊(duì)提供面向前端的數(shù)據(jù)聚合接口。開(kāi)發(fā) BFF 的編程語(yǔ)言由后端決定。
如上圖所示,紫框?yàn)檠邪l(fā)團(tuán)隊(duì)(前端或后端),藍(lán)框?yàn)?BFF 服務(wù),黃框?yàn)榍岸藨?yīng)用,綠色箭頭表示“開(kāi)發(fā)”,紫色箭頭表示“調(diào)用”。
在圖示中,BFF 按照終端尺寸分為 Mobile BFF 和 PC BFF 兩類(lèi),它背后的假設(shè)是:相近終端尺寸的前端應(yīng)用擁有相近的數(shù)據(jù)訪(fǎng)問(wèn)需求。移動(dòng)端應(yīng)用調(diào)用 Mobile BFF,PC 端應(yīng)用調(diào)用PC BFF。
3.2 解耦模式
解耦模式,相比樸素模式而言,它改變了分工方式、職能邊界和協(xié)作流程。后端不介入 BFF 層的實(shí)現(xiàn),BFF 層的需求由前端團(tuán)隊(duì)自行消化。
也就是說(shuō),BFF 是前后端團(tuán)隊(duì)共同完成的一種新的分工:
- 開(kāi)發(fā)微服務(wù)應(yīng)用(后端團(tuán)隊(duì))
- 開(kāi)發(fā) BFF 服務(wù)(前端團(tuán)隊(duì))
- 消費(fèi) BFF 服務(wù)(前端團(tuán)隊(duì))
前端的工作方式跟之前有所不同,前端團(tuán)隊(duì)自己開(kāi)發(fā) BFF 服務(wù)以供自己的前端應(yīng)用消費(fèi)聚合好的數(shù)據(jù)。開(kāi)發(fā) BFF 的編程語(yǔ)言由前端決定。
如上圖所示,前端團(tuán)隊(duì)同時(shí)開(kāi)發(fā)了 App 及配套的 BFF 服務(wù)。前端開(kāi)發(fā)的 BFF 服務(wù)背后調(diào)用了后端團(tuán)隊(duì)提供的各個(gè)微服務(wù)接口,將它們聚合起來(lái),轉(zhuǎn)換為前端應(yīng)用可直接消費(fèi)的數(shù)據(jù)形態(tài)。
3.3 樸素模式 VS 解耦模式
兩種模式都有其適用場(chǎng)景,具體要看不同研發(fā)團(tuán)隊(duì)的人力資源、應(yīng)用類(lèi)型和技術(shù)文化風(fēng)格等多個(gè)因素。
從技術(shù)發(fā)展的角度,解耦模式更能代表“徹底的前后端分離”的趨勢(shì)和目標(biāo)。
前后端分離可以大體分為兩個(gè)階段:
- 渲染服務(wù)回歸前端團(tuán)隊(duì)(SSR)
- 數(shù)據(jù)服務(wù)回歸前端團(tuán)隊(duì)(BFF)
部分開(kāi)發(fā)者可能認(rèn)為完成了第一階段,就達(dá)到了前后端分離的目標(biāo)。此時(shí)前端團(tuán)隊(duì)可以自行構(gòu)建SSR 服務(wù)器,更早介入渲染流程,不必等到瀏覽器加載頁(yè)面的 JavaScript 腳本后才開(kāi)始發(fā)揮作用。不必再跟后端頻繁溝通,交代他們?cè)?html 文件里添加指定的 id 或class 名;前端團(tuán)隊(duì)可以全權(quán)處理,后端團(tuán)隊(duì)只需要提供數(shù)據(jù)接口即可。
然而,“前后端分離”不只是把前端代碼(如 html 文件)從后端代碼倉(cāng)庫(kù)轉(zhuǎn)移到前端代碼倉(cāng)庫(kù)里,這只是形式和手段。前后端分離是指職能的分離,是為了讓前端研發(fā)人員不必低效率地遙控后端研發(fā)人員,讓他們?nèi)C(jī)械地調(diào)整面向前端需求的代碼。前端團(tuán)隊(duì)可以自主負(fù)責(zé)、自主修改、快速迭代,專(zhuān)業(yè)的人做專(zhuān)業(yè)的事兒。
面向前端需求的代碼,不僅僅是 html 文件,也包括了面向渲染優(yōu)化的數(shù)據(jù)聚合代碼。前端的職能是為用戶(hù)開(kāi)發(fā)出體驗(yàn)更好的 GUI 應(yīng)用,有助于這個(gè)目標(biāo)的所有合理的技術(shù)手段都在其職能范圍之內(nèi)。
讓 SSR 服務(wù)從后端團(tuán)隊(duì)轉(zhuǎn)到前端團(tuán)隊(duì),是為了得到面向前端需求的界面渲染優(yōu)化空間。讓更加理解渲染優(yōu)化原理的前端團(tuán)隊(duì),可以在 SSR 服務(wù)上應(yīng)用新的渲染優(yōu)化措施,包括但不限于 Streaming SSR、Suspense、Selective Hydration、Server Component 等技術(shù)可以得以應(yīng)用。它們可以顯著提升頁(yè)面的各項(xiàng)性能指標(biāo),令用戶(hù)更早看到內(nèi)容,更早看到有意義的內(nèi)容,更早跟界面自由交互。
讓 BFF 服務(wù)從后端團(tuán)隊(duì)轉(zhuǎn)到前端團(tuán)隊(duì),則是為了獲得面向前端需求的數(shù)據(jù)聚合優(yōu)化空間,可以提高代碼的跨端復(fù)用率、減少前端應(yīng)用的代碼體積、減少前端應(yīng)用的加載時(shí)間,提升用戶(hù)體驗(yàn)。
當(dāng)研發(fā)團(tuán)隊(duì)完成了 SSR 和 BFF 兩個(gè)階段的前后端分離后,前端團(tuán)隊(duì)同時(shí)掌握了 SSR 和 BFF 兩重優(yōu)化空間。他們既不需要讓后端團(tuán)隊(duì)幫忙添加 id 和 class 到 html 文件中,也不需要讓他們幫忙把某個(gè)文案添加到某個(gè)接口里。面向前端的渲染優(yōu)化需求和數(shù)據(jù)優(yōu)化需求,都能在前端團(tuán)隊(duì)職責(zé)范圍內(nèi)自主解決,顯著減少前后端的溝通頻次和成本,分離彼此的關(guān)注度,提升雙方的專(zhuān)業(yè)聚焦水平。
更重要的是,SSR 和 BFF 在渲染優(yōu)化上有著不可分割的關(guān)系。視圖的渲染不是憑空的,它依賴(lài)數(shù)據(jù)的準(zhǔn)備,有意義的內(nèi)容才得以呈現(xiàn);當(dāng)視圖需要流式渲染,數(shù)據(jù)請(qǐng)求也需要做相應(yīng)配合。假設(shè)我們頁(yè)面的首屏所依賴(lài)的數(shù)據(jù),都被聚合到一個(gè)單一接口里,其后果便是 Streaming SSR 無(wú)法發(fā)揮充分價(jià)值;所有組件都在等待單一聚合接口的響應(yīng)數(shù)據(jù),才開(kāi)始進(jìn)行渲染。
如上圖所示,紫色箭頭表示“時(shí)間”,粉色框?yàn)閿?shù)據(jù)獲取,橙色框?yàn)榻M件渲染,綠色框?yàn)榘l(fā)送 HTML 到瀏覽器。我們可以看到,樸素的 SSR 是一個(gè)串行過(guò)程,組件渲染階段需要等待數(shù)據(jù)獲取全部結(jié)束。而充分的 Streaming SSR 則有多次數(shù)據(jù)獲取的并發(fā)任務(wù)(調(diào)用了多次后端接口),組件渲染并不需要等待所有數(shù)據(jù)獲取任務(wù)結(jié)束,只需相關(guān)數(shù)據(jù)就位即可進(jìn)入組件渲染及后續(xù)發(fā)送 HTML 的流程。用戶(hù)可以更早看到內(nèi)容。
在前端團(tuán)隊(duì)缺乏 BFF 掌控能力的情況下,他們無(wú)法自主控制 SSR 的數(shù)據(jù)獲取過(guò)程,只能被動(dòng)接受后端提供的接口。即便視圖層框架(如 React)支持 Streaming rendering,也僅僅是把一大塊 html 分多次發(fā)送給瀏覽器,它仍受制于 data-fetching 的阻塞時(shí)間,無(wú)法做到充分的 Streaming SSR。
因此,技術(shù)層面更合理的做法是,每個(gè)組件描述它自身所需的數(shù)據(jù)依賴(lài),頁(yè)面渲染時(shí)遇到?jīng)]有數(shù)據(jù)依賴(lài)的靜態(tài)組件,立即發(fā)送給用戶(hù),遇到有數(shù)據(jù)依賴(lài)的組件則發(fā)起請(qǐng)求(Render-As-You-Fetch),不同組件可以獨(dú)立發(fā)起不同請(qǐng)求,每個(gè)組件數(shù)據(jù)請(qǐng)求完畢后即刻開(kāi)始自身渲染,最終得到頁(yè)面流式渲染,用戶(hù)漸進(jìn)式地看到一塊塊成形的界面渲染的效果。
我們可以看到,在這個(gè)渲染優(yōu)化需求下,傳統(tǒng)意義上的數(shù)據(jù)聚合思路(盡可能少的接口)反而是不利的;微服務(wù)式的多個(gè)接口,反而是有利的。當(dāng)然,這不意味著前端直接對(duì)接微服務(wù)接口,不需要 BFF;這其實(shí)意味著我們需要 Streaming BFF 去解放 Streaming SSR 的潛力。
總的而言,徹底的前后端分離是指前端掌握了面向渲染優(yōu)化的充分條件,包括 SSR 和 BFF 兩個(gè)彼此緊密關(guān)聯(lián)的優(yōu)化空間,缺少任意一個(gè),都難以獲得充分的渲染優(yōu)化能力。從這個(gè)角度來(lái)看,前端團(tuán)隊(duì)開(kāi)發(fā) BFF 是一個(gè)未來(lái)的技術(shù)方向。研發(fā)團(tuán)隊(duì)的分工模式隨著技術(shù)發(fā)展,將從樸素模式逐漸轉(zhuǎn)向解耦模式。
四、解耦模式的 BFF 技術(shù)選型
解耦模式的 BFF 服務(wù)由前端團(tuán)隊(duì)開(kāi)發(fā),所用的編程語(yǔ)言也由前端團(tuán)隊(duì)決定。大部分情況下前端團(tuán)隊(duì)會(huì)采用相同的編程語(yǔ)言(JavaScript/TypeScript),基于 Node.js 運(yùn)行時(shí)開(kāi)發(fā)相應(yīng)的 BFF 服務(wù)?;谶@個(gè)前提,我們討論幾種技術(shù)選型。
4.1 RESTful API
處于模仿階段的前端團(tuán)隊(duì),往往會(huì)采用 RESTful API 方式實(shí)現(xiàn) BFF 服務(wù)。技術(shù)目標(biāo)是把后端之前做但現(xiàn)在不做的功能,用同樣的方式和思路讓前端團(tuán)隊(duì)用Node.js 實(shí)現(xiàn)一遍。
實(shí)現(xiàn) BFF 服務(wù)的前端開(kāi)發(fā)人員,其心智模型跟普通后端無(wú)異,涉及 URL,HttpRequest, HttpResponse, RequestHeader, Query, Body, Cookie, Authorization, CORS,Service, Controller 等等。
然而,即便是現(xiàn)在,掌握成熟后端接口開(kāi)發(fā)能力的前端開(kāi)發(fā)人員,依舊是稀缺的。所以,往往這種方式開(kāi)發(fā)的 BFF 服務(wù)的質(zhì)量,劣于專(zhuān)業(yè)后端開(kāi)發(fā)的,并且?guī)缀醪豢紤]面向前端的極致優(yōu)化,能滿(mǎn)足需求已經(jīng)達(dá)到了目標(biāo)。
此外,從語(yǔ)義角度看,RESTful API 是面向資源的,跟面向前端需求的 BFF 場(chǎng)景并不契合。很多時(shí)候,前端需要的數(shù)據(jù)并不是后端資源的直接映射,而是經(jīng)過(guò)聚合、轉(zhuǎn)換、過(guò)濾、排序等處理后的結(jié)果。
因此,一開(kāi)始基于 RESTful API 開(kāi)發(fā)的 BFF 服務(wù),最終將有意或無(wú)意、主動(dòng)或被動(dòng)地演變成只有一半功能的 RPC 服務(wù)。它的 url 參數(shù)設(shè)計(jì)是函數(shù)語(yǔ)義,而非資源語(yǔ)義,但調(diào)用這些遠(yuǎn)程函數(shù)時(shí)仍然要考慮 server-client 之間底層通訊細(xì)節(jié)。既沒(méi)有被封裝,也缺少優(yōu)化。
4.2 GraphQL
GraphQL是一個(gè)面向前端數(shù)據(jù)訪(fǎng)問(wèn)優(yōu)化的數(shù)據(jù)抽象層,它相當(dāng)適合作為 BFF 技術(shù)選型,并且也是我們之前包括現(xiàn)在仍在使用的 BFF 方案。
它的技術(shù)特點(diǎn)是,在數(shù)據(jù)訪(fǎng)問(wèn)層實(shí)現(xiàn)了一定的控制反轉(zhuǎn)(Inversion of control,IOC)的能力。
GraphQL 服務(wù)的開(kāi)發(fā)者負(fù)責(zé)構(gòu)建一個(gè)數(shù)據(jù)網(wǎng)絡(luò)結(jié)構(gòu)(Graph),支持其消費(fèi)者根據(jù)自身需求編寫(xiě) GraphQL Query 語(yǔ)句查詢(xún)所需 JSON 數(shù)據(jù)(Tree)。這種靈活的查詢(xún)能力,實(shí)際上是將前后端數(shù)據(jù)交互相關(guān)的代碼分成了兩部分,一部分是關(guān)于通用性的,放在數(shù)據(jù)提供方(GraphQL 服務(wù))里,一部分是關(guān)于特殊性的,放在數(shù)據(jù)消費(fèi)方(前端應(yīng)用的查詢(xún)語(yǔ)句)里。
如此,GraphQL BFF 可以將按終端尺寸分類(lèi)的多個(gè) BFF 整合成一個(gè) BFF。從之前Mobile BFF 和 PC BFF,變成統(tǒng)一的GraphQL BFF。減少了兩個(gè) BFF 之間重復(fù)的部分。
- Mobile BFF = GraphQL BFF + Mobile GraphQL Query
- PC BFF = GraphQL BFF + PC GraphQL Query
通過(guò)統(tǒng)一的 GraphQL-BFF 配合差異的 *-GraphQL-Query 實(shí)現(xiàn)了之前多個(gè) BFF服務(wù)提供的數(shù)據(jù)訪(fǎng)問(wèn)能力。
前端團(tuán)隊(duì)開(kāi)發(fā) GraphQL BFF 應(yīng)用時(shí),其心智模型不再是純粹的后端概念,而是 GraphQL 相關(guān)的概念,如 Schema, Query, Mutation, Resolver,DataLoader, Directive, SelectionSet 等等,它們更加利于前后端數(shù)據(jù)訪(fǎng)問(wèn)的優(yōu)化。更多詳情可以閱讀另一篇文章《GraphQL-BFF:微服務(wù)背景下的前后端數(shù)據(jù)交互方案》。
4.3 RPC
RPC 是指 Remote Procedure Call,即遠(yuǎn)程過(guò)程調(diào)用。這個(gè)模式也適用于 BFF 服務(wù)的實(shí)現(xiàn)。
它的技術(shù)特征是,將實(shí)現(xiàn)端和調(diào)用端之間的通訊過(guò)程封裝,作為技術(shù)細(xì)節(jié)隱藏起來(lái),并不暴露給調(diào)用者。對(duì)于調(diào)用者而言,仿佛像調(diào)用本地函數(shù)。
正因如此,它不要求調(diào)用一次 RPC 函數(shù)即發(fā)起一次獨(dú)立的通訊過(guò)程。它可以將多次 RPC 函數(shù)批量化(Batching)并流式響應(yīng)(Streaming),進(jìn)一步減少反復(fù)重建通訊過(guò)程的開(kāi)銷(xiāo)。
前端團(tuán)隊(duì)基于 RPC 模式開(kāi)發(fā) BFF時(shí),其心智模型跟開(kāi)發(fā)傳統(tǒng)后端服務(wù)不同。所謂的接口設(shè)計(jì),被轉(zhuǎn)化為函數(shù)參數(shù)設(shè)計(jì)和返回值設(shè)計(jì),沿襲了前端開(kāi)發(fā)者熟悉的標(biāo)準(zhǔn)編程語(yǔ)言特性。相比 RESTful API 和 GraphQL 而言,RPC 引入更少概念和領(lǐng)域知識(shí)要求。
4.4 GraphQL VS RPC
我們團(tuán)隊(duì)從四年前開(kāi)始使用 GraphQL-BFF,并成功落地到多個(gè)項(xiàng)目,取得了不錯(cuò)的效果。我們看到了它帶來(lái)的好處和價(jià)值,同時(shí)也發(fā)現(xiàn)了它當(dāng)前的一些局限性。為了突破桎梏,爭(zhēng)取到更廣闊的優(yōu)化空間,我們開(kāi)始探索 RPC-BFF 方案,并試圖克服 GraphQL-BFF 方案未能解決的問(wèn)題。
值得提醒的是,本文提到的 GraphQL-BFF 面臨的難題,是在精益求精的層面上的探討,并非否定和質(zhì)疑 GraphQL 作為 BFF 方案的合理性。
GraphQL-BFF 的第一個(gè)難題是類(lèi)型問(wèn)題。GraphQL 是一個(gè)跨編程語(yǔ)言的數(shù)據(jù)抽象層,它自帶了一套 Type System。使用具體某個(gè)帶類(lèi)型的編程語(yǔ)言(如 TypeScript)開(kāi)發(fā) GraphQL 服務(wù)時(shí),就存在兩個(gè)類(lèi)型系統(tǒng),因此難免有一些語(yǔ)言特性無(wú)法對(duì)齊以及類(lèi)型重復(fù)定義的地方。
比如,GraphQL 的類(lèi)型系統(tǒng)支持代數(shù)數(shù)據(jù)類(lèi)型(Algebraic data type),可以用 union 定義 A 或 B 的類(lèi)型關(guān)系。這在 Rust和 TypeScript 中有不錯(cuò)的支持,在 Java 或 Go 中還沒(méi)有直接的支持。然而即便是 TypeScript,很多類(lèi)型聲明也得在 GraphQL 和 TypeScript 中分別定義一份。
此外,GraphQL 的 Client-side 類(lèi)型問(wèn)題也是一個(gè)挑戰(zhàn)。由于 GraphQL 服務(wù)的返回值取決于發(fā)送過(guò)來(lái)的查詢(xún)語(yǔ)句,因此其響應(yīng)的數(shù)據(jù)類(lèi)型不是固定的,而是隨著查詢(xún)語(yǔ)句代碼的修改而變化的。
盡管 GraphQL 由于提供了內(nèi)省機(jī)制(Introspection),可以構(gòu)建出專(zhuān)門(mén)根據(jù) GraphQL Schema + GraphQL Query 生成 TypeScript 類(lèi)型定義的 Code Generator 工具。但其中包含很多手動(dòng)處理或復(fù)雜的工程化配置環(huán)節(jié)。
開(kāi)發(fā)者有可能需要手動(dòng)從 TypeScript 代碼里復(fù)制出 GraphQL Query 語(yǔ)句,在 GraphQL Code Generator 工具里生成 TypeScript 類(lèi)型后,復(fù)制該類(lèi)型定義到項(xiàng)目中,然后傳入 GraphQL Client 調(diào)用函數(shù)標(biāo)記其返回值類(lèi)型。
或者像 Facebook/Meta 公司推出的 Relay 框架那樣,實(shí)現(xiàn)一個(gè) Compiler 去掃描代碼里的 GraphQL Query 語(yǔ)句,自動(dòng)生成類(lèi)型到指定目錄,讓開(kāi)發(fā)者可以直接使用。這塊對(duì)研發(fā)團(tuán)隊(duì)的技術(shù)能力和工程化水平有較高要求。
GraphQL-BFF 的第二個(gè)難題是,缺乏 Streaming 優(yōu)化支持。當(dāng)前的 GraphQL 數(shù)據(jù)響應(yīng),是由查詢(xún)語(yǔ)句中最慢的節(jié)點(diǎn)決定的,尚未支持已 resolved 的節(jié)點(diǎn)提前返回給調(diào)用端消費(fèi)的能力。
雖然 GraphQL 有 @stream/@defer 相關(guān)的 RFC,但目前并未進(jìn)入 GraphQL 規(guī)范中,也未在相關(guān) GraphQL 庫(kù)中得到實(shí)現(xiàn)或推廣。
GraphQL-BFF 的第三個(gè)難題是缺少 Client-side Data-Loader 優(yōu)化支持。
在 Server-side 的GraphQL 有相關(guān) Data-Loader,支持在一次查詢(xún)請(qǐng)求處理過(guò)程中,相同資源的訪(fǎng)問(wèn)可以被去重。但是在調(diào)用端,一次 GraphQL Query 就對(duì)應(yīng)著一次 Http 請(qǐng)求與響應(yīng)。多次 GraphQL Query 很難被自動(dòng) merge 和 batching,遑論 streaming 優(yōu)化。
如前所述,為了渲染上的進(jìn)一步優(yōu)化,前端組件實(shí)踐的流行趨勢(shì)是,每個(gè)組件可以將自己的數(shù)據(jù)需求定義在自己的組件代碼中,而非托管給父級(jí)組件。如此,可以方便組件自身做 streaming 優(yōu)化;當(dāng)它自身的數(shù)據(jù)已經(jīng)獲取完畢,它可以先行渲染,不必等待。
相關(guān) GraphQL Client(如Relay)的處理辦法是,讓視圖組件用 GraphQL Fragment 而非 GraphQL Query 去表達(dá)數(shù)據(jù)需求。通過(guò)編譯器處理后,它們將可以提取到父級(jí)組件或者根組件里合并為 GraphQL Query。實(shí)現(xiàn)編寫(xiě)時(shí)在組件內(nèi),運(yùn)行時(shí)托管在父級(jí)組件中獲取數(shù)據(jù)。
這種策略在實(shí)踐上是可行的,然而既有較高的工程化門(mén)檻,難以普遍推廣,又不是 GraphQL 規(guī)范所定義的標(biāo)準(zhǔn)行為,甚至需要額外自定義很多指令以達(dá)到目標(biāo)(如 Relay 框架的 @argument, @argumentDefinitions 等),這進(jìn)一步損害了它的易用性。
我們需要的一種 BFF 技術(shù)方案是:
- 支持使用標(biāo)準(zhǔn)的語(yǔ)言特性解決問(wèn)題
- BFF 實(shí)現(xiàn)端類(lèi)型定義不必編寫(xiě)兩份
- BFF 調(diào)用端可無(wú)縫復(fù)用 BFF 實(shí)現(xiàn)端的定義,不必重復(fù)定義
- 支持 Client-side Data-Loader 機(jī)制,可以將客戶(hù)端的多個(gè)數(shù)據(jù)訪(fǎng)問(wèn)調(diào)用自動(dòng)
- merging
- batching
- streaming
RPC-BFF 技術(shù)方案,可以滿(mǎn)足上述目標(biāo)。
五、RPC-BFF 的技術(shù)選型
基于 RPC 實(shí)現(xiàn) BFF 的思路和方案,也有很多種選擇。
5.1 gRPC
gRPC 是一個(gè)非常優(yōu)秀的 RPC 技術(shù)方案,但它跟 GraphQL 一樣是跨編程語(yǔ)言的,需要額外使用一種 DSL 定義類(lèi)型(Interface Definition Language,IDL),因此有類(lèi)似的重復(fù)定義類(lèi)型的問(wèn)題,也未對(duì)前端常用語(yǔ)言(如TypeScript)做充分的針對(duì)性?xún)?yōu)化,并且它主要服務(wù)于分布式系統(tǒng)這類(lèi) server-to-server 的調(diào)用,對(duì) client/ui-to-server 的 BFF 場(chǎng)景沒(méi)有特殊處理。
在很多基于 gRPC 的 BFF 實(shí)踐中,BFF 跟背后的領(lǐng)域服務(wù)之間是 RPC 模式,BFF 跟前端之間則回到 RESTful API 模式。而我們所謂的 RPC-BFF,其實(shí)是指前端和 BFF 之間是 RPC 模式的調(diào)用關(guān)系。
5.2 tRPC
tRPC 的設(shè)計(jì)目的則跟我們的目標(biāo)更加貼合,它是基于 TypeScript 實(shí)現(xiàn)的端到端類(lèi)型安全方案(End-to-end type-safe APIs)。
然而,tRPC 的技術(shù)實(shí)現(xiàn)方式,跟我們的需求場(chǎng)景卻不契合。
tRPC 跟前文的 Relay 框架某種意義上是兩個(gè)極端。Relay 框架完全依賴(lài)它的 Relay Compiler 去掃描代碼,充分分析和收集代碼里的 GraphQL 配置,有很多編譯、構(gòu)建和代碼生成的環(huán)節(jié)。而 tRPC 則相反,它目前沒(méi)有構(gòu)建、編譯和代碼生成的步驟,正如其官方文檔里所言:
tRPC has no build or compile steps, meaning no code generation, runtime bloat or build step.
tRPC 假設(shè)了開(kāi)發(fā)者的項(xiàng)目是全棧項(xiàng)目(full-stack application),或者前后端代碼都在一個(gè)倉(cāng)庫(kù)。
因此,前端項(xiàng)目可以直接 import 后端項(xiàng)目里的 TypeScript 類(lèi)型。
如果我們構(gòu)建的 BFF 項(xiàng)目,不只為了一個(gè)前端項(xiàng)目服務(wù),而是多個(gè)前端項(xiàng)目共用的,涉及跨端、跨語(yǔ)言、跨團(tuán)隊(duì)等復(fù)雜組合。使用 tRPC 時(shí),可能就得采取 Monorepo 模式,把 BFF 項(xiàng)目及其所有調(diào)用方的代碼項(xiàng)目,放到一個(gè) Git 倉(cāng)庫(kù)中管理。這將顯著地增加多個(gè)團(tuán)隊(duì)之間的 Git 工作流、CI/CD、研發(fā)和部署流程等多方面的協(xié)調(diào)問(wèn)題,要求處理新的工程化復(fù)雜度(Monorepo),能滿(mǎn)足這個(gè)條件的團(tuán)隊(duì)不多。
我們的場(chǎng)景所期望的技術(shù)方案是,允許 BFF 項(xiàng)目及各個(gè)下游前端項(xiàng)目在不同倉(cāng)庫(kù)中管理,它們無(wú)法直接 import BFF 項(xiàng)目的類(lèi)型,不滿(mǎn)足 tRPC 的項(xiàng)目假設(shè)。
5.3 DeepKit
DeepKit 是一個(gè)富有野心的項(xiàng)目,它在 TypeScript 基礎(chǔ)上增加了很多特性,力圖打造一個(gè)更加完備的 TypeScript 后端基礎(chǔ)設(shè)施。
它也有 RPC 模塊的部分,但出于以下幾個(gè)原因,最后未被我們選擇。
第一個(gè)原因是,DeepKit 的 RPC 實(shí)踐方式跟 tRPC 有類(lèi)似的項(xiàng)目結(jié)構(gòu)假設(shè)。
如上所示,rpc server 和 rpc client 之間需要有個(gè)共享的接口,一個(gè)負(fù)責(zé)實(shí)現(xiàn)該接口,一個(gè)負(fù)責(zé)消費(fèi)該接口。這也要求前后端需要放到一個(gè)倉(cāng)庫(kù)中?;蛘卟捎?npm 包這類(lèi)更低效的代碼共享途徑,對(duì)庫(kù)和框架這種變動(dòng)比較不頻繁的場(chǎng)景來(lái)說(shuō)是合適的,對(duì)業(yè)務(wù)迭代這種更新頻率則難以接受。
另一個(gè)原因則是,選擇 DeepKit 并不是選擇一個(gè)庫(kù)或者框架這種小決策,它從編程語(yǔ)言開(kāi)始侵入,然后到運(yùn)行時(shí)以及庫(kù)和框架等方方面面。有較強(qiáng)的 Vendor lock-in 風(fēng)險(xiǎn),一個(gè)項(xiàng)目要從 DeepKit 中遷移到另一個(gè)技術(shù)相當(dāng)困難。
團(tuán)隊(duì)需要下很大的決心才敢押注 DeepKit 選型,以 DeepKit 目前的完成度和流行度,還無(wú)法支撐我們做出這個(gè)決定。
5.4 自研 RPC-BFF
如前所述,我們深入分析了 RPC BFF 的優(yōu)勢(shì),以及考察了多個(gè)不同的技術(shù)選型。有的過(guò)于龐大、過(guò)分復(fù)雜,有的則過(guò)于簡(jiǎn)單、過(guò)于局限。
但這些項(xiàng)目也啟發(fā)了我們的自研方向,幫助我們從簡(jiǎn)單到復(fù)雜的光譜中,根據(jù)自身實(shí)際需求找到一個(gè)平衡點(diǎn),可以用盡可能低的研發(fā)成本、盡可能小的侵入性、盡可能少的項(xiàng)目結(jié)構(gòu)要求,實(shí)現(xiàn) RPC-BFF 模式。
也就是說(shuō),我們既不要像 DeepKit 和 Relay 那樣,從完整的編譯器乃至編程語(yǔ)言層面切入到代碼生成和運(yùn)行時(shí),它們或許有更大的目標(biāo)和野心,配得上如此高昂的技術(shù)成本和實(shí)現(xiàn)難度,但對(duì)純粹的 BFF 場(chǎng)景而言可能過(guò)猶不及。同時(shí)也不要像 tRPC 那樣完全沒(méi)有代碼生成,而是選擇一個(gè)最小化代碼生成的路線(xiàn),滿(mǎn)足 RPC-BFF 這一聚焦場(chǎng)景的需求。
六、自研 RPC-BFF 的設(shè)計(jì)與實(shí)現(xiàn)
6.1 RPC-BFF 的設(shè)計(jì)思路
RPC-BFF 可以看作樸素 BFF 的拓展增強(qiáng)版。在樸素 BFF 中,后端在最簡(jiǎn)陋的情況下只為前端提供了數(shù)據(jù)。
而 RPC-BFF 則需要做更多:提供數(shù)據(jù)、提供類(lèi)型以及提供代碼。
如上圖所示,RPC-BFF 既是數(shù)據(jù)提供者(Data Provider),也是類(lèi)型提供者(Type Provider),還是代碼提供者(Code Provider)。前端不必重復(fù)定義 BFF 響應(yīng)的數(shù)據(jù)類(lèi)型,也不必親自構(gòu)造 Http 請(qǐng)求處理通訊問(wèn)題。
要做到這些功能,同時(shí)又不能損害易用性,需要極致地挖掘 TypeScript 的類(lèi)型表達(dá)能力。
上圖為 RPC-BFF 架構(gòu)圖示意,其中存在四種顏色,分別的含義如下:
- 服務(wù)端運(yùn)行時(shí)(server-runtime)為粉色,服務(wù)器代碼在此運(yùn)行
- 類(lèi)型編譯時(shí)(compile-time)為藍(lán)色,類(lèi)型檢查在此進(jìn)行
- CLI 運(yùn)行時(shí)(cli-runtime)為黃色,本地開(kāi)發(fā)階段使用的命令行工具
- 客戶(hù)端運(yùn)行時(shí)(client-runtime)為紫色,前端代碼在此運(yùn)行
該 RPC-BFF 架構(gòu)設(shè)計(jì)的核心在于Schema 部分,它是一切的基礎(chǔ)。我們可以看到,Schema 有兩條箭頭,一條為 type infer,一條為 to JSON。也就是說(shuō),Schema 既作用于類(lèi)型(type)所在的編譯時(shí)(compile-time),也作用于值(value)所在的運(yùn)行時(shí)(runtime)。
當(dāng) BFF 端的代碼經(jīng)過(guò)編譯,類(lèi)型信息被編譯器抹除后,我們?nèi)钥梢栽谶\(yùn)行時(shí)訪(fǎng)問(wèn)到 JSON 數(shù)據(jù)結(jié)構(gòu)表示的 Schema。
通過(guò)這種機(jī)制,我們的 RPC-BFF 像 GraphQL 那樣支持內(nèi)省特性(introspection)。在開(kāi)發(fā)階段,前端可以通過(guò)本地 CLI 工具向 RPC-BFF 發(fā)起內(nèi)省請(qǐng)求(Introspection Request)拿到 JSON 形式的 Schema 結(jié)構(gòu),然后通過(guò) Code Generator 生成前端所需的 Client 類(lèi)型和 Client 代碼。
如此,我們既不需要用編譯器去掃描和分析服務(wù)端代碼以提取類(lèi)型,也不需要用編譯器去掃描和分析前端代碼去生成類(lèi)型。對(duì)于 RPC-BFF 來(lái)說(shuō),不需要掌握到圖靈完備的編程語(yǔ)言的所有信息才能工作,只需要掌握RPC 函數(shù)列表及其輸入和輸入類(lèi)型結(jié)構(gòu)即可。
6.2 RPC-BFF Schema 設(shè)計(jì)與實(shí)現(xiàn)
Schema 是一段特殊的代碼,它介于 Type 和 Program 之間,面向特定領(lǐng)域保留其所需的元數(shù)據(jù)性質(zhì)的配置結(jié)構(gòu)。Schema 往往比類(lèi)型復(fù)雜,但比一般意義上的程序簡(jiǎn)單。
不管是 tRPC 還是 GraphQL 都包含 Schema 性質(zhì)的要素。然而,對(duì)于 RPC-BFF 的場(chǎng)景來(lái)說(shuō),它們分別都有能力的缺失。
tRPC 中的 Schema 是只起到了 Validator 和 Type-infer 的作用,而沒(méi)有 Introspection 機(jī)制。
如上所示,tRPC 自身沒(méi)有實(shí)現(xiàn) schema 部分,但可以從開(kāi)源社區(qū)的多個(gè) schema-based validator 庫(kù)中選擇一個(gè)它目前支持的(如 zod),從而得到在 server runtime 對(duì)客戶(hù)端傳遞進(jìn)來(lái)的參數(shù)驗(yàn)證,以及在開(kāi)發(fā)階段提供 type-infer 功能。
而 GraphQL 的情況則復(fù)雜一些,它分為 Schema-first 和 Code-first 兩類(lèi)實(shí)踐方式。
Schema-first GraphQL 實(shí)踐如上圖所示,GraphQL Schema 以字符串的形式出現(xiàn)在 TypeScript/JavaScript 代碼中,它是 DSL 形態(tài),要復(fù)用 host language(如 TypeScript)的類(lèi)型系統(tǒng)相當(dāng)困難。
Code-first/Code-only GraphQL 則如上圖所示,它是 eDSL 形態(tài),即嵌入式領(lǐng)域特定語(yǔ)言(Embedded domain specific language),它可以基于 host language 的API 以程序的方式而非字符串的方式,創(chuàng)建出 GraphQL Schema。因此,這種模式下 GraphQL 相當(dāng)于嵌入到 TypeScript 中,它有機(jī)會(huì)利用 TypeScript 的類(lèi)型推導(dǎo)(type infer)能力,反推出 TypeScript 類(lèi)型;也能夠在運(yùn)行時(shí) stringify 為 DSL 形態(tài)的 GraphQL Schema。
可以說(shuō),這種實(shí)踐方式的 GraphQL 跟 host language 的整合度更高,某種程度上是更靈活的,盡管犧牲了 DSL 那種直觀性。
然而,目前幾乎所有 Code-first/Code-only 的 GraphQL 庫(kù)的 TypeScript 類(lèi)型支持程度都有很大的不足。特別是對(duì) GraphQL 這種類(lèi)型之間遞歸結(jié)構(gòu)特別頻繁的技術(shù)來(lái)說(shuō),其類(lèi)型推導(dǎo)的技術(shù)挑戰(zhàn)遠(yuǎn)大于zod 等樸素 schema-based validator 庫(kù)。
即便是 zod 這類(lèi)更簡(jiǎn)單的場(chǎng)景,對(duì)遞歸類(lèi)型也沒(méi)有充分支持。
You can define a recursive schema in Zod, but because of a limitation of TypeScript, their type can't be statically inferred. Instead you'll need to define the type definition manually, and provide it to Zod as a "type hint".
如 zod 官方文檔所述,當(dāng)我們的 schema 中存在遞歸,type-infer 就受到了限制,需要更繁瑣的方式去自行拼裝出遞歸類(lèi)型。
const baseCategorySchema = z.object({
name: z.string(),
});
type Category = z.infer<typeof baseCategorySchema> & {
subcategories: Category[];
};
const categorySchema: z.ZodType<Category> = baseCategorySchema.extend({
subcategories: z.lazy(() =>categorySchema.array()),
})
如上所示,它需要先用 schema 方式定義遞歸類(lèi)型(Category)中非遞歸的部分,然后用 type-infer 在 type-level 定義遞歸部分的類(lèi)型,最后回到 schema-level 中顯式類(lèi)型標(biāo)注,定義遞歸 schema。
它在 schema-level 和 type-level 中來(lái)回穿梭。每一個(gè)遞歸字段都要求拆成上述 3 個(gè)部分,其工程上的易用性缺乏保障,其代碼也缺乏可讀性。有多少讀者能輕易看出上面的復(fù)雜 schema 是為了定義下面這幾行代碼?
type Category = {
name: string
subcategories: Category[]
}
回到 GraphQL,我們現(xiàn)在能夠看到,它在 Validator 和 Introspection 特性上有良好的支持,可以在 server runtime 驗(yàn)證參數(shù)結(jié)構(gòu)和返回值,也可以通過(guò)內(nèi)省請(qǐng)求曝露出schema 結(jié)構(gòu),但在 type-infer 上仍有一些難以攻克的挑戰(zhàn)存在。
RPC-BFF 的 Schema 需要同時(shí)滿(mǎn)足 Validator,Type-infer 和 Introspection 三個(gè)能力,現(xiàn)有方案并不滿(mǎn)足,因此我們通過(guò)自研 Schema方案實(shí)現(xiàn)了它們。
下面是一段定義 User 類(lèi)型的TypeScript 代碼:
type UserType = {
id: string;
name: string;
age: number;
}
const user: UserType = {
id: 'user_id_01',
name: 'Jade',
age: 18
}
經(jīng)過(guò)編譯后,在運(yùn)行時(shí)執(zhí)行的 JavaScript 代碼如下:
"use strict";
const user = {
id: 'user_id_01',
name: 'Jade',
age: 18
};
類(lèi)型信息被抹去,在運(yùn)行時(shí)無(wú)法獲取。通過(guò) RPC-BFF Schema 重新定義 User 結(jié)構(gòu)如下:
我們用 ObjectType 定義了User Schema,用 TypeOf 推導(dǎo)出UserType,經(jīng)過(guò) TypeScript 編譯后,JavaScript 代碼如下所示:
import { ObjectType } from '@ctrip/rpc-bff';
class User extends ObjectType {
constructor() {
super(...arguments);
this.id = String;
this.name = String;
this.age = Number;
}
}
const user = {
id: 'user_id_01',
name: 'Jade',
age: 18
};
我們可以看到,由 ObjectType 定義的 User Schema 在運(yùn)行時(shí)也得到了保留,因此我們可以基于這些信息,實(shí)現(xiàn)在運(yùn)行時(shí)的 Validation 和 Introspection 功能。
此外,我們的 RPC-BFF Schema 技術(shù)還克服了 zod 等庫(kù)未能解決的遞歸類(lèi)型定義問(wèn)題:
我們無(wú)需為了支持遞歸而人為拆分類(lèi)型,可以直觀地定義出上述的 Category 結(jié)構(gòu),并支持靜態(tài)類(lèi)型推導(dǎo)。其秘訣在于利用 TypeScript 中 class 聲明具備的獨(dú)特性質(zhì)—— value & type 二象性。
當(dāng)我們用 class 聲明一個(gè)結(jié)構(gòu)時(shí),它同時(shí)也定義了:
- Constructor 函數(shù)值
- Instance Type 實(shí)例類(lèi)型
如上圖所示,左邊箭頭的 Test 是一個(gè)實(shí)例類(lèi)型(Instance Type),右邊箭頭的則是類(lèi)的構(gòu)造器函數(shù)(Constructor)。
目前就我們所知,只在 class 聲明場(chǎng)景下 TypeScript 對(duì)遞歸 Schema 有良好的類(lèi)型推導(dǎo)支持。因此,如果 zod 或者 Code-first GraphQL 庫(kù)想要支持遞歸 Schema 結(jié)構(gòu),它們的 Schema API 可能需要大改,變成我們上面演示的 RPC-BFF Schema 的 API 風(fēng)格。
盡管我們掌握了在 Validator, Type-infer 和 Introspection 能力上更完備的 Schema 技術(shù),滿(mǎn)足了 Code-first GraphQL 的技術(shù)要求,但也僅限于 server 端的情況,在 client 端的類(lèi)型,仍需要引入復(fù)雜的編譯技術(shù)掃描前端代碼庫(kù)里的 GraphQL Query 片段以生成類(lèi)型。這不是我們期望的。
因此,我們目前先將這種技術(shù)應(yīng)用于更簡(jiǎn)單的 RPC-BFF 場(chǎng)景,未來(lái)也不排除支持 GraphQL。
6.3 定義 RPC-BFF 函數(shù)
有了 RPC-BFF Schema 之后,我們就可以用它來(lái)定義 RPC-BFF 函數(shù)了。
在純 TypeScript 的代碼里,定義函數(shù)的 input 和 output 是這樣的:
// 定義 input
type HelloInput = {
name: string
}
// 定義 output
type HelloOutput = {
message: string
}
type HelloFunction = (input: HelloInput) => HelloOutput
然后實(shí)現(xiàn)滿(mǎn)足該函數(shù)類(lèi)型的代碼:
const hello: HelloFunction = ({ name })=> {
return {
message: `Hello ${name}!`,
}
}
在 RPC-BFF 里,我們只是換了一種方式去定義 input 和 output。
去掉注釋?zhuān)⑶乙?ObjectType,前面的 hello 函數(shù)就變成了這樣:
import { Api, ObjectType } from '@ctrip/rpc-bff'
class HelloInput extends ObjectType {
name = String
}
class HelloOutput extends ObjectType {
message = String
}
export const hello = Api(
{
input: HelloInput,
output: HelloOutput,
},
async ({ name }) => {
return {
message: `Hello ${name}!`,
}
},
)
可以看到,它跟我們純 TypeScript 的結(jié)構(gòu)幾乎是一樣的。
6.4 RPC 函數(shù)和普通函數(shù)的區(qū)別
RPC 函數(shù)和普通函數(shù)的區(qū)別在于,RPC函數(shù)的 input 和 output 都是 value,而普通函數(shù)的 input 和 output 都是 type。
type 會(huì)在編譯時(shí)被擦除,而value 會(huì)在運(yùn)行時(shí)被保留。所以,RPC 函數(shù)的 input和 output 需要是 value,它們?cè)谶\(yùn)行時(shí)是可以被訪(fǎng)問(wèn)到的。這樣可以為 RPC-BFF client 生成類(lèi)型代碼和調(diào)用代碼。
盡管我們使用 value 的方式去定義RPC 函數(shù)的輸入和輸出,但通過(guò) TypeScript 提供的type infer 能力,我們不必為 RPC 函數(shù)的實(shí)現(xiàn)重新寫(xiě)一次類(lèi)型定義,而是可以使用 TypeScript 的類(lèi)型推導(dǎo)能力,讓 TypeScript 自動(dòng)推導(dǎo)出 RPC 函數(shù)的輸入和輸出類(lèi)型。
可以看到,我們的 hello 函數(shù)實(shí)現(xiàn)是有類(lèi)型的。不僅如此,我們還可以通過(guò) TypeOf 獲取到 Schema API 定義出來(lái)的結(jié)構(gòu)。
通過(guò)這種方式,我們實(shí)現(xiàn)了 RPC 函數(shù)的輸入和輸出結(jié)構(gòu),具備以下能力:
可以在運(yùn)行時(shí)保留,用以生成代碼或者生成文檔
可以在編譯時(shí)被隱式地推導(dǎo)出來(lái),用以做類(lèi)型檢查
可以通過(guò) TypeOf 工具類(lèi)型顯式地獲取到,用以做類(lèi)型標(biāo)記
現(xiàn)在,讓我們把 RPC-BFF 函數(shù)放到 RPC-BFF App 中:
import { createApp } from '@ctrip/rpc-bff'
import { hello } from './api/hello'
export const app = createApp({
entries: {
hello,
}
})
createApp 將創(chuàng)建一個(gè) RPC-BFF App,其中 options.entries 字段就是我們想要曝露給前端調(diào)用的 RPC-BFF 函數(shù)列表。
啟動(dòng)后,一個(gè) RPC-BFF Server 就運(yùn)行起來(lái)了。
6.5 RPC-BFF 的 Client
在前端項(xiàng)目的開(kāi)發(fā)階段,它將會(huì)新增 rpc.config.js 配置腳本。
rpc.config.js
const { createRpcBffConfig } = require('@ctrip/rpc-bff-cli')
module.exports = createRpcBffConfig({
client: {
rootDir: './__generated__/',
list: [
{
src: 'http://localhost:3001/rpc_bff',
dist: 'my-bff-client.ts',
}
]
}
})
如上所示,當(dāng)該腳本被 rpc-bff-cli 運(yùn)行時(shí),它會(huì)向 src 發(fā)起 introspection request 并生成代碼到指定目錄下的指定文件(如 my-bff-client.ts)。
就我們前面所演示的 hello 函數(shù)來(lái)說(shuō),其生成的代碼大概如下所示:
./__generated__/my-bff-client.ts
/**
* This file is auto generated by rpc-bff-client
* Please do not modify this file manually
*/
import { createRpcBffLoader } from '@ctrip/rpc-bff-client'
export type JsonType =
| number
| string
| boolean
| null
| undefined
| JsonType[]
| { toJSON(): string }
| { [key: string]: JsonType }
/**
* @label HelloInput
*/
export type HelloInput = {
name: string
}
/**
* @label HelloOutput
*/
export type HelloOutput = {
message: string
}
export type ApiClientLoaderInput = {
path: string[]
input: JsonType
}
declare global {
interface ApiClientLoaderOptions {}
}
export type ApiClientOptions = {
loader: (input: ApiClientLoaderInput,
options?: ApiClientLoaderOptions) => Promise<JsonType>
}
export const createApiClient = (options: ApiClientOptions) => {
return {
hello: (input: HelloInput,
loaderOptions?: ApiClientLoaderOptions) => {
return options.loader(
{
path: ['hello'],
input: input as JsonType,
},
loaderOptions
) as Promise<HelloOutput>
}
}
}
export const loader = createRpcBffLoader("http://localhost:3001/rpc_bff")
export default createApiClient({ loader })
我們可以看到,RPC-BFF 的代碼生成結(jié)果主要包含三個(gè)部分:
- RPC-BFF Server 里的 Schema 變成了前端里的 Type,將在編譯后被擦除,不會(huì)增加前端代碼體積
- RPC-BFF Server 里的 entries 變成了 createApiClient 函數(shù),包含了跟 BFF 端對(duì)齊的函數(shù)調(diào)用列表及其類(lèi)型信息
- RPC-BFF Client 被引入和實(shí)例化,它將在前端的運(yùn)行時(shí)接管 RPC 函數(shù)的前后端通訊過(guò)程,對(duì)前端調(diào)用者無(wú)感
通過(guò) Introspection + Code-generator 途徑,一個(gè) RPC-BFF 服務(wù)不必跟它的下游前端項(xiàng)目綁定,而是每個(gè)前端項(xiàng)目通過(guò) rpc.config.js 各自同步它們所需的 RPC-BFF 服務(wù)。如此解耦了前后端的項(xiàng)目依賴(lài),同時(shí)這個(gè)模式在 Monorepo 項(xiàng)目中也能很好地工作,是一種更加靈活的方式。
七、RPC-BFF 特性概覽
至此,我們了解到了 RPC-BFF 的后端和前端分別的開(kāi)發(fā)方式,可以看到對(duì)于 RPC-BFF 服務(wù)的開(kāi)發(fā)者來(lái)說(shuō),并沒(méi)有引入復(fù)雜的 API 或者概念,僅僅是在編寫(xiě)樸素函數(shù)的心智模型的基礎(chǔ)上,將定義函數(shù)輸入和輸出結(jié)構(gòu)的方式,從樸素的 Type 換成了 RPC-BFF Schema。
對(duì)于 RPC-BFF 服務(wù)的調(diào)用方而言,只是增加了 rpc.config.js 配置腳本,在開(kāi)發(fā)階段就能得到 RPC-BFF 的類(lèi)型及其 Client 封裝,用極小的成本獲得極大便利。
但這仍不是 RPC-BFF 的優(yōu)勢(shì)的全部,接下來(lái),我們來(lái)了解一下 RPC-BFF 的幾大特性。
7.1 端到端類(lèi)型安全的函數(shù)調(diào)用
端到端類(lèi)型安全(End to end type-safety)的函數(shù)調(diào)用是 RPC-BFF 的基本功能,前端通過(guò)生成的 RPC-BFF Client 模塊訪(fǎng)問(wèn) RPC-BFF Server 時(shí),像調(diào)用本地異步函數(shù)一樣。
如上所示,BFF 后端和前端的類(lèi)型對(duì)齊。前端不必關(guān)心底層 HTTP 通訊細(xì)節(jié),可以聚焦 RPC 函數(shù)的 input 和 output 結(jié)構(gòu)。
當(dāng) RPC 函數(shù)執(zhí)行時(shí),它將發(fā)起 HTTP 請(qǐng)求,將所調(diào)用的 RPC 函數(shù)的路徑(path)和輸入(input)等信息打包發(fā)送給 RPC-BFF Server。
RPC-BFF Server 接受到 RPC 函數(shù)調(diào)用請(qǐng)求后,將匹配出指定函數(shù)并以 input 參數(shù)調(diào)用它得到其 output 結(jié)果后發(fā)送給前端,前端收到響應(yīng)結(jié)果后,RPC-BFF Client 將其轉(zhuǎn)換為前端 RPC 調(diào)用函數(shù)的返回值。整個(gè) RPC 過(guò)程的前后端流程就完成了。
7.2 直觀的錯(cuò)誤處理
前面介紹的 RPC 調(diào)用只涉及請(qǐng)求正確處理和返回的情況,如果服務(wù)端報(bào)錯(cuò)了,前端如何處理呢?
對(duì)于這個(gè)問(wèn)題,RPC-BFF 也有其優(yōu)勢(shì)。不同于樸素的接口請(qǐng)求錯(cuò)誤處理,需要去判斷 HTTP Status Code 檢查請(qǐng)求狀態(tài)碼是否正確,甚至還得判斷 result.code 檢查業(yè)務(wù)狀態(tài)碼是否正確等等。
RPC-BFF 的錯(cuò)誤處理支持最直觀和自然的 throw 和 try-catch 特性。在 RPC-BFF Client 中可以 catch 到 RPC-BFF Server 里 throw 的錯(cuò)誤。
如上,改造我們的 hello 函數(shù),當(dāng)它遇到空的 name 參數(shù)時(shí) throw 指定錯(cuò)誤。
前端則構(gòu)造一個(gè)空的 name 參數(shù),并 try-catch 此次 RPC 調(diào)用,它將能捕獲服務(wù)端拋出的錯(cuò)誤。
再次執(zhí)行后,從 Chrome Devtools 的 Network 面板中,我們看到了標(biāo)記為錯(cuò)誤的 RPC 響應(yīng)結(jié)果。
在 Console 面板中,我們則看到了前端 catch 到的 RPC 調(diào)用錯(cuò)誤日志輸出。
7.3 代碼即文檔
RPC-BFF 從 GraphQL-BFF 中學(xué)習(xí)到了很多優(yōu)秀之處。在 GraphQL 中,可以基于其 Schema 的 Introspection 能力,構(gòu)建 GraphQL Playground 平臺(tái),可以在其中查看接口的參數(shù)類(lèi)型、字段描述等信息,還能發(fā)起查詢(xún),相當(dāng)方便。
RPC-BFF 的 Schema 也擁有 Introspection 能力,因此我們可以為前端提供更多內(nèi)容。
export class Todo extends ObjectType {
id = {
description: `Todo id`,
[Type]: Int,
}
content = {
description: 'Todo content',
[Type]: String,
}
completed = {
description: 'Todo status',
[Type]: Boolean,
}
}
export class AddTodoInput extends ObjectType {
content = {
description: 'a content of todo for creating',
[Type]: String,
}
}
export class AddTodoOutput extends ObjectType {
todos = {
description: 'Todo list',
[Type]: TodoList,
}
}
export const addTodo = Api(
{
description: 'add todo',
input: AddTodoInput,
output: AddTodoOutput,
},
(input) => {
state.todos.push({
id: state.uid++,
content: input.content,
completed: false,
})
return {
todos: state.todos,
}
},
)
如上所示,我們?cè)诙x addTodo 接口的 input schema 和 output schema 時(shí),不僅僅提供了對(duì)應(yīng)的類(lèi)型,還添加了相關(guān)的 description 描述。
經(jīng)過(guò)前端的代碼生成后,將得到如下代碼:
/**
* @label Todo
*/
export type Todo = {
/**
* @remarks Todo id
*/
id: number,
/**
* @remarks Todo content
*/
content: string,
/**
* @remarks Todo status
*/
completed: boolean
}
/**
* @label AddTodoInput
*/
export type AddTodoInput = {
/**
* @remarks a content of todo for creating
*/
content: string
}
/**
* @label AddTodoOutput
*/
export type AddTodoOutput = {
/**
* @remarks Todo list
*/
todos: (Todo)[]
}
export const createApiClient = (options: ApiClientOptions) => {
return {
/**
* @remarks add todo
*/
addTodo: (input: AddTodoInput, loaderOptions?: ApiClientLoaderOptions) => {
return options.loader(
{
path: ['addTodo'],
input: input as JsonType,
},
loaderOptions
) as Promise<AddTodoOutput>
},
}
}
相比文本形式的接口契約文檔,RPC-BFF 通過(guò)注釋的方式將接口描述信息呈現(xiàn)出來(lái)。
如上所示,生成到注釋的方式,比樸素的接口契約更貼近開(kāi)發(fā)者,可以在代碼編輯器里直觀地看到接口描述和類(lèi)型描述,并且基于開(kāi)發(fā)階段的同步機(jī)制,它總是實(shí)時(shí)反映當(dāng)前 RPC-BFF 的最新?tīng)顟B(tài),避免了接口文檔過(guò)時(shí)的問(wèn)題。
當(dāng)我們想要廢棄一個(gè) RPC 函數(shù)時(shí),這項(xiàng)機(jī)制尤為重要。
如上,我們通過(guò)添加 RPC 函數(shù)的 deprecated 描述,宣布廢棄。
前端經(jīng)過(guò)代碼生成同步到 RPC-BFF 最新?tīng)顟B(tài)時(shí),將能在代碼編輯器里直觀地看到廢棄的提示信息。如此可以實(shí)現(xiàn)流暢的前后端接口廢棄過(guò)程。
7.4 自由的函數(shù)組合
在 RPC-BFF 中,RPC 函數(shù)跟普通函數(shù)本質(zhì)上是一樣的,只是它通過(guò) Schema 定義額外攜帶了描述自身的元數(shù)據(jù)信息。我們可以像組合普通函數(shù)一樣,組合 RPC 函數(shù)。
假設(shè)我們有 updateTodo 和 removeTodo 兩個(gè) RPC 函數(shù),然后我們希望添加一個(gè)功能:當(dāng) updateTodo 收到的 todo.content 為空時(shí),則 remove 該 todo。
那么,我們不必把 removeTodo 功能分別在 updateTodo 和 removeTodo 中各自實(shí)現(xiàn)一遍,而是在 updateTodo 中根據(jù)條件調(diào)用 removeTodo。
如上所示,在 updateTodo 中調(diào)用 removeTodo 并沒(méi)有特殊的要求,就像調(diào)用別的異步函數(shù)一樣簡(jiǎn)單,并且對(duì)于前端發(fā)起的 updateTodo RPC 調(diào)用也沒(méi)有額外開(kāi)銷(xiāo),仍是一次對(duì) updateTodo 的遠(yuǎn)程函數(shù)調(diào)用。
7.5 可靠的接口兼容性識(shí)別與版本跟蹤
由于 RPC-BFF 的調(diào)用方在自己的前端項(xiàng)目中,通過(guò) rpc.config.js 同步了 RPC-BFF 當(dāng)前的接口的類(lèi)型,因此它還解決了前后端之間常常遇到的接口兼容性爭(zhēng)議。
在以往樸素的實(shí)踐中,接口兼容往往只是后端對(duì)前端的口頭承諾,缺乏自動(dòng)化的、系統(tǒng)化的方式去發(fā)現(xiàn)和識(shí)別接口兼容性,甚至往往將問(wèn)題暴露在生產(chǎn)環(huán)境。然后前后端開(kāi)始爭(zhēng)執(zhí)判空職責(zé)的前后端邊界劃分問(wèn)題。
而現(xiàn)在,RPC-BFF 提供了自動(dòng)發(fā)現(xiàn)接口兼容性機(jī)制,并且是以系統(tǒng)性的、無(wú)爭(zhēng)議的方式實(shí)現(xiàn)的。
export class AddTodoOutput extends ObjectType {
todos = {
description: 'Todo list',
[Type]: Nullable(TodoList),
}
}
如上,當(dāng)后端的 addTodo 接口改變了返回值類(lèi)型,從非空的 todos 變成可空的 todos,這是一種不兼容的變更。
前端同步 RPC-BFF 的接口契約后,在代碼編輯器里立即可以看到類(lèi)型系統(tǒng)的 type-check 結(jié)果。
在不解決這個(gè)類(lèi)型問(wèn)題的情況下,前端項(xiàng)目難以通過(guò)編譯,從而避免將問(wèn)題泄露到生產(chǎn)環(huán)境。
此外,RPC-BFF 生成的代碼也進(jìn)入了前端項(xiàng)目的版本管理中,可以從 git diff 中,清晰地看到每一次迭代的接口契約變更記錄。更完整無(wú)誤地追溯和跟蹤前后端的接口契約歷史。
7.6 自動(dòng) merge & batch & stream 優(yōu)化
如前文所描述的,RPC-BFF 需要支持自動(dòng)的 merge & batch & stream 功能,以便達(dá)到更少的 HTTP 請(qǐng)求、更快的數(shù)據(jù)響應(yīng)以及更優(yōu)的 SSR 支持。
我們先來(lái)講解一下它們分別的含義。
首先講 batch,它是一種常見(jiàn)的優(yōu)化技術(shù),可以把一組數(shù)據(jù)請(qǐng)求合并為一次,往往用在相同資源的批量化請(qǐng)求上。這需要服務(wù)端接口提供支持,比如 getUser 接口是獲取單個(gè)用戶(hù)信息的,而 getUsers 則是獲取多個(gè)的,后者可以被視為 batch 接口。
而 merge 在這里則偏向前端概念,接口支持 batch 只是說(shuō)我們可以調(diào)用一個(gè) batch 接口獲取更多數(shù)據(jù),但不意味著前端代碼里多個(gè)地方調(diào)用 getUser 接口,會(huì)自動(dòng) merge 到一起去調(diào)用 getUsers 接口。
merge & batch 結(jié)合起來(lái),就可以讓前端里分散的各個(gè)調(diào)用自動(dòng)合并到一次 batch 接口的請(qǐng)求中。
現(xiàn)在我們來(lái)看 stream,它跟 batch 一樣需要服務(wù)端的支持。一次 batch 接口請(qǐng)求的響應(yīng)時(shí)間,往往取決于最慢的數(shù)據(jù),因?yàn)榉?wù)端需要準(zhǔn)備好所有數(shù)據(jù)后才能返回 JSON 結(jié)果;所謂的 stream 支持,則是服務(wù)端能夠提前將已獲得的數(shù)據(jù)一份份發(fā)送給前端。
某種意義上,stream 也需要前端的支持。假設(shè)接口支持 stream 可以一批批返回?cái)?shù)據(jù),前端卻一個(gè) await 等待所有數(shù)據(jù)就位后才開(kāi)始下一步,那么等于沒(méi)有發(fā)揮出接口流式響應(yīng)的優(yōu)勢(shì)。
因此,merge & batch & stream 三者的結(jié)合才能發(fā)揮出更充分的優(yōu)化效果。
- 通過(guò) merge,前端代碼里各個(gè)地方的接口調(diào)用被 batch 起來(lái)
- 通過(guò) batch & stream 接口,一份份數(shù)據(jù)從服務(wù)端發(fā)送給前端
- 通過(guò) RPC-BFF Client 內(nèi)部數(shù)據(jù)分發(fā),指定的數(shù)據(jù)被一份份地發(fā)送到各個(gè)前端調(diào)用點(diǎn)
每個(gè)前端調(diào)用點(diǎn)收到數(shù)據(jù)響應(yīng)后都將第一時(shí)間進(jìn)入后續(xù)的渲染流程,不會(huì)受到其它調(diào)用點(diǎn)的阻塞影響。
我們來(lái)看一個(gè)例子:
如上,為了演示方便,我用 Promise.all 將 3 次 RPC 函數(shù)調(diào)用的結(jié)果打包到一起返回,但我們?nèi)孕柚?,它們其?shí)是三個(gè)獨(dú)立的 RPC 調(diào)用,被寫(xiě)到一起還是分散在其它地方調(diào)用,不影響結(jié)果。Promise.all 只是在前端匯總 promises 結(jié)果,不包含接口相關(guān)的 merge & batch & stream 等作用。
從 Network Request 面板中,我們看到了一個(gè)被標(biāo)記為 Stream 的請(qǐng)求,它里面包含了上面 3 個(gè) RPC 調(diào)用的所有信息。
在 Network Response 面板中,我們則看到了一個(gè) Newline delimited JSON 響應(yīng),即用換行符分隔的streaming JSON 格式。每一個(gè)被 batch & stream 起來(lái)的 RPC 函數(shù)調(diào)用返回?cái)?shù)據(jù)后,都將立即產(chǎn)生一條 JSON 結(jié)果發(fā)送給前端,每一行對(duì)應(yīng)一次 RPC 調(diào)用。
7.7 自動(dòng)緩存和去重
除了 merge & batch & stream 以外,自動(dòng)的緩存和去重(cache & dedup)對(duì)于渲染優(yōu)化也很重要。
在前端界面中,兩個(gè)組件依賴(lài)同一份接口數(shù)據(jù)的情況很常見(jiàn)。傳統(tǒng)方式是,手動(dòng)去重,即兩個(gè)組件都不包含接口調(diào)用,而是 lift up 到公共的祖先級(jí)組件統(tǒng)一處理后通過(guò) props/context 等方式將同一份數(shù)據(jù)傳送給這兩個(gè)組件。
這種方式的缺點(diǎn)是:
- 兩個(gè)組件都無(wú)法被獨(dú)立使用和復(fù)用,因?yàn)樗鼈兊臄?shù)據(jù)請(qǐng)求邏輯都被挪出去了,內(nèi)聚性被打破
- 組件優(yōu)化不足。只有枝葉組件各自發(fā)起請(qǐng)求時(shí),Streaming 渲染得到了更優(yōu)條件。越是頂層的組件里懸停,子組件渲染越是受到阻塞
這就是為什么 React 目前致力于接管 data-fetching 層,讓開(kāi)發(fā)者手動(dòng)去管理缺少將系統(tǒng)性?xún)?yōu)化。
對(duì)于 RPC-BFF 而言,支持 cache & dedup 變得重要。
改造前面的 tryBatch 代碼,使其包含 4 次 RPC 調(diào)用,有 4 個(gè)返回值,但有 2 次的參數(shù)和函數(shù)名是重復(fù)的。
但我們的請(qǐng)求卻只包含了 3 次 RPC調(diào)用及其響應(yīng),其中 2 次的重復(fù)調(diào)用被合并為一次。
但并不影響每個(gè) RPC 調(diào)用拿到它對(duì)應(yīng)的 4 個(gè)調(diào)用結(jié)果。
通過(guò)這種 cache & dedup 機(jī)制,每個(gè)組件的數(shù)據(jù)請(qǐng)求都可以?xún)?nèi)聚于組件代碼內(nèi)部,而不必被迫 lift up 到父級(jí)組件做請(qǐng)求托管了。
值得提醒的是,不是所有相同參數(shù)的 RPC 調(diào)用都能被緩存和去重。特別是對(duì)于 mutation 性質(zhì)的請(qǐng)求來(lái)說(shuō),連續(xù)調(diào)用兩次相同參數(shù)的 createTodo,應(yīng)當(dāng)是創(chuàng)建兩條而非一條。
因此,RPC-BFF 支持在前端傳遞第二個(gè) options 參數(shù)給 RPC 函數(shù),可以關(guān)閉 cache 特性。
如上圖所示,關(guān)閉 cache 后,即便是相同的 RPC 調(diào)用,也不會(huì)被緩存和去重。
options 選項(xiàng),不僅可以關(guān)閉 cache,還可以關(guān)閉 batch, stream 等特性。
如上所示,options.batch 是 cache 和 stream 的前提,我們將所有 RPC 調(diào)用的 batch 選項(xiàng)都關(guān)閉。
其結(jié)果是所有 RPC 函數(shù)調(diào)用退化為樸素形態(tài),每一個(gè) RPC 調(diào)用對(duì)應(yīng)獨(dú)立的一次 HTTP 請(qǐng)求。
通過(guò)靈活的選項(xiàng)配置,我們可以按需決定 RPC-BFF 里的函數(shù)調(diào)用的優(yōu)化策略。
八、總結(jié)
在這篇文章中,我們介紹了 BFF 的起源、模式和技術(shù)選型,并根據(jù)界面渲染優(yōu)化需求,了解到 Streaming BFF 的重要性,同時(shí)也給出了我們當(dāng)前探索的技術(shù)方向——RPC-BFF。
我們對(duì)比了開(kāi)源社區(qū)的一些流行方案,并根據(jù)我們自身的場(chǎng)景做了分析,盡管最終采用了自研的方式,但在調(diào)研和實(shí)驗(yàn)開(kāi)源技術(shù)的過(guò)程中,也讓我們學(xué)習(xí)到了很多知識(shí),使得 RPC-BFF 的設(shè)計(jì)和實(shí)現(xiàn)能夠吸收開(kāi)源社區(qū)的技術(shù)成果。
我們最終達(dá)到了預(yù)期的技術(shù)目標(biāo),實(shí)現(xiàn)了:
- Validation: BFF 端可以在運(yùn)行時(shí)驗(yàn)證參數(shù)結(jié)構(gòu)和返回值結(jié)構(gòu)的合法性
- Type-infer: 支持從 Schema 中靜態(tài)類(lèi)型推導(dǎo)出 TypeScript 類(lèi)型,避免重復(fù)定義
- Introspection: RPC-BFF 服務(wù)可以通過(guò)內(nèi)省請(qǐng)求曝露出其函數(shù)列表的契約結(jié)構(gòu)
- Code-generation: 支持為調(diào)用方生成類(lèi)型代碼和調(diào)用代碼,解耦項(xiàng)目依賴(lài)
- Merge: 前端多次 RPC 調(diào)用可以自動(dòng)合并為一次 HTTP 請(qǐng)求
- Batch: 一次 HTTP 請(qǐng)求支持包含多個(gè) RPC 調(diào)用
- Stream: 多個(gè) RPC 調(diào)用可以流式響應(yīng),一份份發(fā)給前端,避免阻塞
- Dedup: 重復(fù)的 RPC 調(diào)用可以被去重合并
- Cache: 重復(fù)的 RPC 調(diào)用可復(fù)用緩存結(jié)果
- Type-safe: 前端可復(fù)用和對(duì)齊 BFF 端的類(lèi)型
- Code as documentation: 代碼即文檔,RPC 接口的文檔描述通過(guò)代碼生成,以代碼注釋的形態(tài)直接作用于前端項(xiàng)目中
目前我們的 RPC-BFF 技術(shù)方案已經(jīng)在內(nèi)部試點(diǎn)項(xiàng)目中落地并上線(xiàn)平穩(wěn)運(yùn)行,接下來(lái)將會(huì)推廣和迭代,并持續(xù)挖掘 RPC-BFF 技術(shù)方向上的優(yōu)化潛力。
以上,希望能給大家?guī)?lái)幫助。