攜程基于 GraphQL 的前端 BFF 服務(wù)開(kāi)發(fā)實(shí)踐
作者:
- 工業(yè)聚,攜程高級(jí)前端開(kāi)發(fā)專家,react-lite, react-imvc, farrow 等開(kāi)源項(xiàng)目作者。
- 蘭迪咚,攜程高級(jí)前端開(kāi)發(fā)專家,對(duì)開(kāi)發(fā)框架及前端性能優(yōu)化有濃厚興趣。?
一、前言
過(guò)去兩三年,攜程度假前端團(tuán)隊(duì)一直在實(shí)踐基于 GraphQL/Node.js 的 BFF (Backend for Frontend) 方案,在度假BU多端產(chǎn)品線中廣泛落地。最終該方案不僅有效支撐前端團(tuán)隊(duì)面向多端開(kāi)發(fā) BFF 服務(wù)的需要,而且逐步承擔(dān)更多功能,特別在性能優(yōu)化等方面帶來(lái)顯著優(yōu)勢(shì)。
我們觀察到有些前端團(tuán)隊(duì)曾嘗試過(guò)基于 GraphQL 開(kāi)發(fā) BFF 服務(wù),最終宣告失敗,退回到傳統(tǒng) RESTful BFF 模式,會(huì)認(rèn)為是 GraphQL 技術(shù)自身的問(wèn)題。
這種情況通常是由于 GraphQL 的落地適配難度導(dǎo)致的,GraphQL 的復(fù)雜度容易引起誤用。因此,我們期望通過(guò)本文分享我們所理解的最佳實(shí)踐,以及一些常見(jiàn)的反模式,希望能夠給大家?guī)?lái)一些啟發(fā)。
二、GraphQL 技術(shù)棧
以下是我們 GraphQL-BFF 項(xiàng)目中所采用的核心技術(shù)棧:
graphql
- 基于 JavaScript 的 GraphQL 實(shí)現(xiàn)
koa v2
- Node.js Web Framework 框架
apollo-server-koa
- 適配 koa v2 的 Apollo Server
data-loader
- 優(yōu)化 GraphQL Resolver 內(nèi)發(fā)出的請(qǐng)求
graphql-scalars
- 提供業(yè)務(wù)中常用的 GraphQL Scalar 類型
faker
- 提供基于類型的 Mock 數(shù)據(jù)
- 結(jié)合 GraphQL Schema 可自動(dòng)生成 Mock 數(shù)據(jù)
@graphql-codegen/typescript
- 基于 GraphQL Schema 生成 TypeScript 文件
graphql-depth-limit
- 限制 GraphQL Query 的查詢深度
jest
- 單元測(cè)試框架
其他非核心或者公司特有的基礎(chǔ)模塊不再贅述。
三、GraphQL 最佳實(shí)踐
攜程度假 GraphQL 的主要應(yīng)用場(chǎng)景是 IO 密集的 BFF 服務(wù),開(kāi)發(fā)面向多端所用的 BFF 服務(wù)。
所有面向外部用戶的 GraphQL 服務(wù),我們會(huì)限制只能調(diào)用其他后端 API,以避免出現(xiàn)密集計(jì)算或者架構(gòu)復(fù)雜的情況。只有面向內(nèi)部用戶的服務(wù),才允許 GraphQL 服務(wù)直接訪問(wèn)數(shù)據(jù)庫(kù)或者緩存。
對(duì) RESTful API 服務(wù)來(lái)說(shuō),每次接口調(diào)用的開(kāi)銷基本上是穩(wěn)定的。而 GraphQL 服務(wù)提供了強(qiáng)大的查詢能力,每次查詢的開(kāi)銷,取決于 GraphQL Query 語(yǔ)句查詢的復(fù)雜度。
因此,在 GraphQL 服務(wù)中,如果包含很多 CPU 密集的任務(wù),其服務(wù)能力很容易受到 GraphQL Query 可變的查詢復(fù)雜度的影響,而變得難以預(yù)測(cè)。
將 GraphQL 服務(wù)約束在 IO 密集的場(chǎng)景中,既可以發(fā)揮出 Node.js 本身的 IO 友好的優(yōu)勢(shì),又能顯著提高 GraphQL 服務(wù)的穩(wěn)定性。
3.1 面向數(shù)據(jù)網(wǎng)絡(luò)(Data Graph),而非面向數(shù)據(jù)接口
我們注意到有相當(dāng)多 GraphQL 服務(wù),其實(shí)是披著 GraphQL 的皮,實(shí)質(zhì)還是 RESTful API 服務(wù)。并未發(fā)揮出 GraphQL 的優(yōu)勢(shì),但卻承擔(dān)著 GraphQL 的成本。
如上所示,原本 RESTful API 的接口,只是掛載到 GraphQL 的 Query 或 Mutation 的根節(jié)點(diǎn)下,未作其它改動(dòng)。
這種實(shí)踐模式,只能有限發(fā)揮 GraphQL 合并請(qǐng)求、裁剪數(shù)據(jù)集的作用。它仍然是面向數(shù)據(jù)接口,而非面向數(shù)據(jù)網(wǎng)絡(luò)的。
如此無(wú)限堆砌數(shù)據(jù)接口,最終仍然是一個(gè)發(fā)散的模型,每增加一個(gè)數(shù)據(jù)消費(fèi)場(chǎng)景需求,就追加一個(gè)接口字段。并且,當(dāng)某些接口字段的參數(shù),依賴其它接口的返回值,常常得重新發(fā)起一次 GraphQL 請(qǐng)求。
而面向數(shù)據(jù)網(wǎng)絡(luò),呈現(xiàn)的是收斂的模型。
如上所示,我們將用戶收藏的產(chǎn)品列表,放到了 User 的 favorites 字段中;將關(guān)聯(lián)的推薦產(chǎn)品列表,放到了 Product 的 recommends 字段中;構(gòu)成一種層級(jí)關(guān)聯(lián),而非并列在 Query 根節(jié)點(diǎn)下作為獨(dú)立接口字段。
相比一維的接口列表,我們構(gòu)建了高維度的數(shù)據(jù)關(guān)聯(lián)網(wǎng)絡(luò)。子字段總是可以訪問(wèn)到它所在得上下文里的數(shù)據(jù),因此很多參數(shù)是可以省略的。我們?cè)谝淮?GraphQL 查詢中,通過(guò)這些關(guān)聯(lián)字段,獲取到所需的數(shù)據(jù),而不必再次發(fā)起請(qǐng)求。
當(dāng)逐漸打通多個(gè)數(shù)據(jù)節(jié)點(diǎn)之間的關(guān)聯(lián)關(guān)系,GraphQL 服務(wù)所能提供的查詢能力可以不斷增加,最后會(huì)收斂在一個(gè)完備狀態(tài)。所有可能的查詢路徑都已被支持,新的數(shù)據(jù)消費(fèi)場(chǎng)景,也無(wú)須開(kāi)發(fā)新的接口字段,可以通過(guò)數(shù)據(jù)關(guān)聯(lián)網(wǎng)絡(luò)查詢出來(lái)。
3.2 用 union 類型做錯(cuò)誤處理
在 GraphQL 里做錯(cuò)誤處理,有相當(dāng)多的陷阱。
第一個(gè)陷阱是,通過(guò) throw error 將錯(cuò)誤拋到最頂層。
假設(shè)我們實(shí)現(xiàn)了以下 GraphQL 接口:
當(dāng)查詢 addTodo 節(jié)點(diǎn)時(shí),其 resolver 函數(shù)拋出的錯(cuò)誤,將會(huì)出現(xiàn)在頂層的 errors 數(shù)組里,而 data.addTodo 則為 null。
不僅僅在 Query/Mutation 節(jié)點(diǎn)下的字段拋錯(cuò)會(huì)出現(xiàn)在頂層的 errors 數(shù)組里,而是所有節(jié)點(diǎn)的錯(cuò)誤都會(huì)被收集起來(lái)。這種功能看似方便,實(shí)則會(huì)帶來(lái)巨大的麻煩。
我們很難通過(guò) errors 數(shù)組來(lái)查找錯(cuò)誤的節(jié)點(diǎn),盡管有 path 字段標(biāo)記錯(cuò)誤節(jié)點(diǎn)的位置,但由于以下原因,它帶來(lái)的幫助有限:
- 總是需要過(guò)濾 errors 去找到自己關(guān)心的錯(cuò)誤節(jié)點(diǎn)
- 查詢語(yǔ)句是易變的,錯(cuò)誤節(jié)點(diǎn)的位置可能會(huì)發(fā)生變化
- 任意節(jié)點(diǎn)都可能產(chǎn)生錯(cuò)誤,要處理的潛在情形太多
這個(gè)陷阱是導(dǎo)致 GraphQL 項(xiàng)目失敗的重大誘因。
錯(cuò)誤處理在 GraphQL 項(xiàng)目中,比 RESTful API 更重要。后者常常只需要處理一次,而 GraphQL 查詢語(yǔ)句可以查詢多個(gè)資源。每個(gè)資源的錯(cuò)誤處理彼此獨(dú)立,并非一個(gè)錯(cuò)誤就意味著全盤(pán)的錯(cuò)誤;每個(gè)資源所在的節(jié)點(diǎn)未必都是根節(jié)點(diǎn),可以是任意層級(jí)的節(jié)點(diǎn)。
因此,GraphQL 項(xiàng)目里的錯(cuò)誤處理發(fā)生的次數(shù)跟位置都變得多樣。如果無(wú)法有效地管理異常,將會(huì)帶來(lái)無(wú)盡的麻煩,甚至是生產(chǎn)事件。長(zhǎng)此以往,項(xiàng)目宣告失敗也在意料之內(nèi)了。
第二個(gè)陷進(jìn)是,用 Object 表達(dá)錯(cuò)誤類型。
如上所示,AddTodoResult 類型是一個(gè) Object:
- data 字段是一個(gè) Object,它包含了查詢結(jié)果
- code 字段是一個(gè) Int,它表示錯(cuò)誤碼
- message 字段是一個(gè) String,它表示錯(cuò)誤信息
這種模式,即便在 RESTful API 中也很常見(jiàn)。但是,在 GraphQL 這種錯(cuò)誤節(jié)點(diǎn)可能在任意層級(jí)的場(chǎng)景中,該模式會(huì)顯著增加節(jié)點(diǎn)的層級(jí)。每當(dāng)一個(gè)節(jié)點(diǎn)需要錯(cuò)誤處理,它就多了一層 { code, data, message },增加了整體數(shù)據(jù)復(fù)雜性。
此外,code 和 message 字段的類型都帶 !,表示非空。而 data 字段的類型不帶 !,即可能為空。這就帶來(lái)一個(gè)問(wèn)題,code 為 1 表達(dá)存在錯(cuò)誤時(shí),data 也可能不為空。從類型上,并不能保證,code 為 1 時(shí),data 一定為空。
也就是說(shuō),用 Object 表達(dá)錯(cuò)誤類型是含混的。code 和 data 的關(guān)系全靠服務(wù)端的邏輯來(lái)決定。服務(wù)端需要保證 code 和 data 的出現(xiàn)關(guān)系,一定滿足 code 為 1 時(shí),data 為空,以及 code 為 0 時(shí),data 不為空。
其實(shí),在 GraphQL 中處理錯(cuò)誤類型,有更好的方式——union type。
如上所示,AddTodoResult 類型是一個(gè) union,包含 AddTodoError 和 AddTodoSuccess 兩個(gè)類型,表示或的關(guān)系。
要么是 AddTodoError,要么是 AddTodoSuccess,但不能是兩者都是。
這正是錯(cuò)誤處理的精確表達(dá):要么出錯(cuò),要么成功。
查詢數(shù)據(jù)時(shí),我們用 ... on Type {} 的語(yǔ)法,同時(shí)查詢兩個(gè)類型下的字段。由于它們是或的關(guān)系,是互斥的,因此查詢結(jié)果總是只有一組。
失敗節(jié)點(diǎn)的查詢結(jié)果如上所示,命中了 AddTodoError 節(jié)點(diǎn),伴隨有 message 字段。
成功節(jié)點(diǎn)的查詢結(jié)果如上所示,命中了 AddTodoSuccess 節(jié)點(diǎn),伴隨有 newTodo 字段。
當(dāng)使用 graphql-to-typescript 后,我們可以看到,AddTodoResult 類型定義如下:
export type AddTodoResult =
| {
__typename: 'AddTodoError';
message: string;
}
| {
__typename: 'AddTodoSuccess';
newTodo: Todo;
};
declare const result: AddTodoResult;
if (result.__typename === 'AddTodoError') {
console.log(result.message);
} else if (result.__typename === 'AddTodoSuccess') {
console.log(result.newTodo);
}
我們可以很容易通過(guò)共同字段 __typename 區(qū)分兩種類型,不必猜測(cè) code 和 data 字段之間的可能搭配。
union type 不局限于組合兩個(gè)類型,還可以組合更多類型,表達(dá)超過(guò) 2 種的互斥場(chǎng)景。
如上所示,我們把 getUser 節(jié)點(diǎn)的可能結(jié)果,都用 union 類型組織起來(lái),表達(dá)更精細(xì)的查詢結(jié)果,可以區(qū)分更多錯(cuò)誤種類。
此外,union type 也不局限于做錯(cuò)誤處理,而是任意互斥的類型場(chǎng)景。比如獲取用戶權(quán)限,我們可以把 Admin | Owner | Normal | Guest 等多種角色,作為互斥的類型,放到 UserRole 類型中。而非用 { isAdmin, isOwner, isNormal, isGuest, ... } 這類含混形式,難以處理它們同時(shí)為 false 或同時(shí)為 true 等無(wú)效場(chǎng)景。
3.3 用 ! 表達(dá)非空類型
在開(kāi)發(fā) GraphQL 服務(wù)時(shí),有個(gè)非常容易疏忽的地方,就是忘記給非空類型標(biāo)記 !,導(dǎo)致客戶端的查詢結(jié)果在類型上處處可能為空。
客戶端判空成本高,對(duì)查詢結(jié)果的結(jié)構(gòu)也更難預(yù)測(cè)。
這個(gè)問(wèn)題在 TypeScript 項(xiàng)目中影響重大,當(dāng) graphql-to-typescript 后,客戶端會(huì)得到一份來(lái)自 graphql 生成的類型。由于服務(wù)端沒(méi)有標(biāo)記 !,令所有節(jié)點(diǎn)都是 optional 的。TypeScript 將會(huì)強(qiáng)制開(kāi)發(fā)者處理空值,前端代碼因而變得異常復(fù)雜和冗贅。
如果前端工程師不愿意消費(fèi) GraphQL 服務(wù),久而久之,GraphQL 項(xiàng)目的用戶流失殆盡,項(xiàng)目也隨之宣告失敗了。
這是反常的現(xiàn)象,GraphQL 的核心優(yōu)勢(shì)就是用戶友好的查詢接口,可以更靈活地查詢出所需的數(shù)據(jù)。因?yàn)榉?wù)端的疏忽而丟失了這份優(yōu)勢(shì),非常可惜。
善用 ! 標(biāo)記,不僅有利于前端消費(fèi)數(shù)據(jù),同時(shí)也有利于服務(wù)端開(kāi)發(fā)。
在 GraphQL 中,空值處理有個(gè)特性是,當(dāng)一個(gè)非空字段卻沒(méi)有值時(shí),GraphQL 會(huì)自動(dòng)冒泡到最近一個(gè)可空的節(jié)點(diǎn),令其為空。
Since Non-Null type fields cannot be null, field errors are propagated to be handled by the parent field. If the parent field may be null then it resolves to null, otherwise if it is a Non-Null type, the field error is further propagated to its parent field.
由于非空類型的字段不能為空,字段錯(cuò)誤被傳播到父字段中處理。如果父字段可能是null,那么它就會(huì)解析為null,否則,如果它是一個(gè)非null類型,字段錯(cuò)誤會(huì)進(jìn)一步傳播到它的父字段。
如上,在 GraphQL Specification 的 6.4.4Handling Field Errors 中,明確了如何置空的問(wèn)題。
假設(shè)我們有如下 GraphQL 接口設(shè)計(jì):
其中,只有根節(jié)點(diǎn) Query.parent 是可空的,其他節(jié)點(diǎn)都是非空的。
我們可以為 Grandchild 類型編寫(xiě)如下 GraphQL Resolver:
我們概率性地分配 null 給 ctx.result(它表示該類型的結(jié)果)。盡管 Grandchild 是非空節(jié)點(diǎn),但 resolver 里也能夠給它置空。通過(guò)置空,告訴 GraphQL 去冒泡到父節(jié)點(diǎn)。否則我們就需要在 Grandchild 的層級(jí)去控制 parent 節(jié)點(diǎn)的值。
這是很難做到,且不那么合理的。因?yàn)?nbsp;Grandchild 可以被掛到任意對(duì)象節(jié)點(diǎn)作為字段,不一定是當(dāng)前 parent。所有 Grandchild 都可以共用一個(gè) resolver 實(shí)現(xiàn)。這種情況下,Grandchild 不假設(shè)自己的父節(jié)點(diǎn),只處理自己負(fù)責(zé)的數(shù)據(jù)部分,更加內(nèi)聚和簡(jiǎn)單。
我們用如下查詢語(yǔ)句查詢 GraphQL 服務(wù):
當(dāng) Grandchild 的 value 結(jié)果為 1 時(shí),查詢結(jié)果如下:
我們得到了符合 GraphQL 類型的結(jié)果,所有數(shù)據(jù)都有值。
當(dāng) Grandchild 的 value 結(jié)果為 null 時(shí),查詢結(jié)果如下:
通過(guò)空值冒泡,Grandchild 的空值,被冒泡到 parent 節(jié)點(diǎn),令 parent 的結(jié)果也為空。這也是符合我們編寫(xiě)的 GraphQL Schema 的類型約束的。如果只有 Grandchild 的 value 為 null,反而不符合類型,因?yàn)樵摴?jié)點(diǎn)是帶 ! 的非空類型。
3.4 最佳實(shí)踐小結(jié)
在 GraphQL 中,還有很多實(shí)踐和優(yōu)化技巧可以展開(kāi),大部分可以在官方文檔或社區(qū)技術(shù)文章里可以找到的。我們列舉的是在實(shí)踐中容易出錯(cuò)和誤解的部分,分別是:
- 數(shù)據(jù)網(wǎng)絡(luò)
- 錯(cuò)誤處理
- 空值處理
深入理解上述三個(gè)方面,就能掌握住 GraphQL 的核心價(jià)值,提高 GraphQL 成功落地的概率。
在對(duì) GraphQL (以下簡(jiǎn)稱GQL) 有一定了解的基礎(chǔ)上,接下來(lái)分享一些我們具體的應(yīng)用場(chǎng)景,以及項(xiàng)目工程化的實(shí)踐。
四、GraphQL 落地
一個(gè)新的 BFF 層規(guī)劃出來(lái)之后,前端團(tuán)隊(duì)第一個(gè)關(guān)注問(wèn)題就是“我有多少代碼需要重寫(xiě)?”,這是一個(gè)很現(xiàn)實(shí)的問(wèn)題。新服務(wù)的接入應(yīng)盡量減少對(duì)原有業(yè)務(wù)的沖擊,這包括前端盡可能少的改代碼以及盡可能減少測(cè)試的回歸范圍。由于主要工作和測(cè)試都是圍繞服務(wù)返回的報(bào)文,因此首先應(yīng)該讓 response 契約盡可能穩(wěn)定。對(duì)老功能進(jìn)行改造時(shí),接口契約可以按照以下步驟柔性進(jìn)行:
- 保持原有服務(wù) response 契約不變
- 對(duì)原有契約提供剪裁能力
- 在有必要的前提下設(shè)計(jì)新的字段,并且該字段也應(yīng)能被剪裁。
假設(shè)之前有個(gè)前端直接調(diào)用的接口,得到 ProductData 這個(gè)JSON結(jié)構(gòu)的數(shù)據(jù)。
const Query = gql`
type ProductInfo {
"產(chǎn)品全部信息"
ProductData: JSON
}
extend type Query {
productInfo(params: ProductArgs!): ProductInfo
}
`
如上所示,一般情況我們可能會(huì)在一開(kāi)始設(shè)計(jì)這樣的 GQL 對(duì)象。即對(duì)服務(wù)端下發(fā)的字段不做額外的設(shè)計(jì),而直接標(biāo)注它的數(shù)據(jù)類型是JSON。這樣的好處是可以很快的對(duì)原客戶端調(diào)用的API進(jìn)行替換。
這里 ProductData 是一個(gè)“大”對(duì)象,屬性非常多,未來(lái)如果希望利用 GQL 的特性對(duì)它進(jìn)行動(dòng)態(tài)裁剪則需要將結(jié)構(gòu)進(jìn)行重新設(shè)計(jì),類似如下代碼:
const Query = gql`
type ProductStruct {
"產(chǎn)品id"
ProductId: Int
"產(chǎn)品名稱"
ProductName: String
......
}
type ProductInfo {
"產(chǎn)品全部信息"
ProductData: ProductStruct
}
extend type Query {
productInfo(params: ProductArgs!): ProductInfo
}
`
但這樣做就會(huì)引入一個(gè)嚴(yán)重的問(wèn)題:這個(gè)數(shù)據(jù)結(jié)構(gòu)的修改是無(wú)法向前兼容的,老版本的 query 語(yǔ)句查詢 ProductInfo 的時(shí)候會(huì)直接報(bào)錯(cuò)。為了解決這個(gè)問(wèn)題,我們參考 SQL 的「Select *」擴(kuò)展了一個(gè)結(jié)構(gòu)通配符「json」。
4.1 JSON:查詢通配符
const Query = gql`
type ProductStruct {
"原始數(shù)據(jù)"
json: JSON
"未來(lái)擴(kuò)展"
ProductId: Int
......
}
type ProductInfo {
"產(chǎn)品全部信息"
ProductData: ProductStruct
}
extend type Query {
productInfo(params: ProductArgs!): ProductInfo
}
`
如上,對(duì)一個(gè)節(jié)點(diǎn)提供一個(gè) json 的查詢字段,它將返回原節(jié)點(diǎn)全部?jī)?nèi)容,同時(shí)框架里對(duì)最終的 response 進(jìn)行處理,如果碰到了 json 字段則對(duì)其解構(gòu),同時(shí)刪除 json 屬性。
利用這個(gè)特性,初始接入時(shí)只需要修改 BFF 請(qǐng)求的 request 報(bào)文,而 response 和原服務(wù)是一致的,因此無(wú)需特別回歸。而未來(lái)即使需要做契約的剪切或者增加自定義字段,也只需要將 query 內(nèi)容從 {json} 改成 {ProductId, ProductName, etc....} 即可。
五、GraphQL 應(yīng)用場(chǎng)景
作為 BFF 服務(wù),在解決單一接口快速接入之后,通常會(huì)回到聚合多個(gè)服務(wù)端接口這個(gè)最初的目的,下面是常見(jiàn)幾種的串、并調(diào)用等應(yīng)用場(chǎng)景。
5.1 服務(wù)端并行
如上圖頂部的產(chǎn)品詳情和下面的B線產(chǎn)品,分別是兩個(gè)獨(dú)立的產(chǎn)品。如果需要一次性獲取,我們一般要設(shè)計(jì)一個(gè)批量接口。但利用 GQL 合并多個(gè)查詢請(qǐng)求的特性,我們可以用更好的方式一次獲取。
首先 GQL 內(nèi)只需要實(shí)現(xiàn)單一產(chǎn)品的查詢即可,非常簡(jiǎn)潔:
ProductInfo.resolve('Query', {
productInfo: async (ctx) => {
ctx.result = await productSvc.fetch(ctx.args.productId)
}
})
const ProductInfoHandle: ProductInfo = {
BasicInfo: async ctx => {
let {BasicInfo} = ctx.parent
ctx.result = {
json: BasicInfo,
...BasicInfo
}
},
.....
}
ProductInfo.resolve('ProductInfo', ProductInfoHandle);
客戶端在查詢的時(shí)候,只需要重復(fù)添加查詢語(yǔ)句,并且傳入另外一個(gè)產(chǎn)品參數(shù)。GQL 內(nèi)會(huì)分別執(zhí)行上述 resolve,如果是調(diào)用 API,則調(diào)用是并行的。
query getProductData(
$mainParams: ProductArgs!
$routeParams: ProductArgs!
) {
mainProductInfo(params: $mainParams) {
BasicInfo{json}
.....
}
routeProductInfo(params: $routeParams) {
BasicInfo{json}
.....
}
}
//主產(chǎn)品查詢請(qǐng)求
[Node] [Inject Soa Mock]: 12345/productSvc 開(kāi)始:11ms 耗時(shí): 237ms 結(jié)束: 248ms
//子產(chǎn)品查詢請(qǐng)求
[Node] [Inject Soa Mock]: 12345/productSvc 開(kāi)始: 12ms 耗時(shí): 202ms 結(jié)束: 214ms
事實(shí)上這種方式不局限在同一接口,任何客戶端希望并行的接口,都可以通過(guò)這樣的方式實(shí)現(xiàn)。即在 GQL 內(nèi)單獨(dú)實(shí)現(xiàn)查詢,然后由客戶端發(fā)起一次“總查詢”實(shí)現(xiàn)服務(wù)端聚合,這樣的方式避免了 BFF 層因?yàn)榍岸诵枨笞兏煌8S修改的困境。這種“拼積木”的方式可以用很小的成本實(shí)現(xiàn)服務(wù)的快速聚合,而且配合上面提到的“json”寫(xiě)法,未來(lái)也具備靈活的擴(kuò)展性。
5.2 服務(wù)端串行
在應(yīng)用中經(jīng)常還會(huì)有事務(wù)型(增刪改)的操作夾在這些“查”之中。比如:
mutation TicketInfo(
$ticketParams: TicketArgs!
$shoppingParams: ShoppingArgs!
) {
//查詢門(mén)票 并 添加到購(gòu)物車
ticketInfo(params: $ticketParams) {
ticketData {json}
}
//根據(jù)“更新后”的購(gòu)物車內(nèi)的商品 獲取價(jià)格明細(xì)
shoppingInfo(params: $shoppingParams) {
priceDetail {json}
}
}
如上所示,獲取價(jià)格明細(xì)的接口調(diào)用必須串行在「添加購(gòu)物車」之后,這樣才不會(huì)造成商品遺漏。而此例中的「mutation」操作符可以使各查詢之間串行執(zhí)行,如下:
//查詢門(mén)票
[Node] [Inject Soa Mock]: 12345/getTicketSvc 開(kāi)始: 16ms 耗時(shí): 111ms 結(jié)束: 127ms
//添加到購(gòu)物車
[Node] [Inject Soa Mock]: 12345/updateShoppingSvc 128ms 耗時(shí): 200ms 結(jié)束: 328ms
//根據(jù)「更新后」的購(gòu)物車內(nèi)的商品 獲取價(jià)格明細(xì)
[Node] [Inject Soa Mock]: 12345/getShoppingSvc 開(kāi)始: 330ms 耗時(shí): 110ms 結(jié)束: 440ms
同時(shí),在 GQL 代碼里也應(yīng)按照前端查詢的操作符來(lái)決定是否執(zhí)行“事務(wù)性”操作。
async function recommendExtraResource(ctx){
//查詢門(mén)票
const extraResource = await getTicketSvc.fetch()
const { operation } = ctx.info.operation;
if (operation === 'mutation'){
//添加到購(gòu)物車內(nèi)
await updateShoppingSvc.fetch(extraResource)
}
ctx.result = extraResource
}
ExtraResource.resolve('Query', { recommendExtraResource });
ExtraResource.resolve('Mutation', { recommendExtraResource });
這樣的設(shè)計(jì)使查詢就變得非常靈活。如前端僅需要查詢可用門(mén)票和價(jià)格明細(xì)并不需要默認(rèn)添加到購(gòu)物車內(nèi),僅需要將 mutation 換成 query 即可,服務(wù)端無(wú)需為此做任何調(diào)整。而且因?yàn)闆](méi)有執(zhí)行更新,且操作符變成了 query,兩個(gè)獲取數(shù)據(jù)的接口調(diào)用又會(huì)變成并行,提高了響應(yīng)速度。
//查詢門(mén)票
[Node] [Inject Soa Mock]: 12345/getTicketSvc 開(kāi)始: 16ms 耗時(shí): 111ms 結(jié)束: 127ms
//根據(jù)「當(dāng)時(shí)」的購(gòu)物車內(nèi)的商品 獲取價(jià)格明細(xì)
[Node] [Inject Soa Mock]: 12345/getShoppingSvc 開(kāi)始: 18ms 耗時(shí): 104ms 結(jié)束: 112ms
5.3 父子查詢中的重復(fù)請(qǐng)求
我們經(jīng)常會(huì)碰到一個(gè)接口的入?yún)?,依賴另外一個(gè)接口的 response。這種將串行調(diào)用從客戶端移到服務(wù)端的做法可以有效的降低端到端的次數(shù),是 BFF 層常見(jiàn)的優(yōu)化手段。但是如果我們有多個(gè)節(jié)點(diǎn)一起查詢時(shí),可能會(huì)出現(xiàn)同一個(gè)接口被調(diào)用多次的問(wèn)題。對(duì)應(yīng)這種情況,我們可以使用 GQL 的 data-loader。
ProductInfo.resolve('Query', {
productInfo: async (ctx) => {
let productLoader = new DataLoader(async RequestType => {
// RequestType 為數(shù)組,通過(guò)子節(jié)點(diǎn)的 load 方法,去重后得到。
let response = await productSvc.fetch({ RequestType })
return Array(RequestType.length).fill(response)
})
ctx.result = { productLoader }
}
})
ExtendInfo.resolve('Product',{
extendInfo: async (ctx) => {
const BasicInfo = await ctx.parent.productLoader.load("BasicInfo")
ctx.result = await extendSvc.fetch(BasicInfo)
}
})
如上,在父節(jié)點(diǎn)的 resolve 里構(gòu)造 loader,通過(guò) ctx.result 傳遞給子節(jié)點(diǎn)。子節(jié)點(diǎn)調(diào)用 load(arg) 方法將參數(shù)添加到 loader 里,父節(jié)點(diǎn)的 loader 根據(jù)“積累”的參數(shù),發(fā)起真正的請(qǐng)求,并將結(jié)果分別下發(fā)對(duì)應(yīng)地子節(jié)點(diǎn)。在這個(gè)過(guò)程中可以實(shí)現(xiàn)相同的請(qǐng)求合并只發(fā)一次。
六、工程化實(shí)踐
6.1 異常處理
在 GQL 關(guān)聯(lián)查詢中父節(jié)點(diǎn)失敗導(dǎo)致子節(jié)點(diǎn)異常的情況很常見(jiàn)。而這個(gè)父子關(guān)系是由前端 query 報(bào)文決定的,因此需要我們?cè)诜?wù)端處理異常的時(shí)候,清晰地通過(guò)日志等方式準(zhǔn)確描述原因,上圖可以看出 imEnterInfo 節(jié)點(diǎn)異常是由于依賴的 BasicInfo 節(jié)點(diǎn)為空,而根因是依賴的 API 返回錯(cuò)誤。這樣的異常處理設(shè)計(jì)對(duì)排查 GQL 的問(wèn)題非常有幫助。
6.2 虛擬路徑
由于 GQL 唯一入口的特性,服務(wù)捕獲到的訪問(wèn)路徑都是 /basename/graphql,導(dǎo)致定位錯(cuò)誤很困難。因此我們擴(kuò)展了虛擬路徑,前端查詢的時(shí)候使用類似「/basename/graphql/productInfo」。這樣無(wú)論是日志、還是 metric 等平臺(tái)等都可以區(qū)分于其他查詢。
并且這個(gè)虛擬路徑對(duì) GQL 自身不會(huì)造成影響,前端甚至可以利用這個(gè)虛擬路徑來(lái)測(cè)試 query 的節(jié)點(diǎn)和 BFF 響應(yīng)時(shí)長(zhǎng)的關(guān)系。如:H5 平臺(tái)修改了首屏 query 的內(nèi)容之后將請(qǐng)求路徑改成 “/basename/graphql/productInfo_h5”,這樣就可以通過(guò)性能監(jiān)控95線等方式,對(duì)比看出這個(gè)“h5”版本對(duì)比其他版本性能是否有所下降。
在很多優(yōu)化首屏的實(shí)踐中,利用 GQL 動(dòng)態(tài)查詢,靈活剪切契約等是非常有效的手段。并且在過(guò)程中,服務(wù)端并不需要跟隨前端調(diào)整代碼。降低工作量的同時(shí),也保證了其他平臺(tái)的穩(wěn)定性。
6.3 監(jiān)控運(yùn)維
GQL 的特性也確實(shí)造成了現(xiàn)有的運(yùn)維工具很難分析出哪個(gè)節(jié)點(diǎn)可以安全廢棄(刪除代碼)。因此需要我們?cè)?resolve 里面對(duì)節(jié)點(diǎn)進(jìn)行了埋點(diǎn)。
6.4 單元測(cè)試
我們利用 jest 搭建了一個(gè)測(cè)試框架來(lái)對(duì) GQL BFF 進(jìn)行單元測(cè)試。與一般單測(cè)不同的是,我們選擇在當(dāng)前運(yùn)行環(huán)境內(nèi)單獨(dú)起一個(gè)服務(wù)進(jìn)程,并且引入“@apollo/client”來(lái)模擬客戶端對(duì)服務(wù)進(jìn)行查詢,并校驗(yàn)結(jié)果。
其他諸如 CI/CD、接口數(shù)據(jù) mock、甚至服務(wù)的心跳檢測(cè)等更多的屬于 node.js 的解決方案,就不在這里贅述了。
七、總結(jié)
鑒于篇幅原因,只能分享部分我們應(yīng)用 GraphQL 開(kāi)發(fā) BFF 服務(wù)的思考與實(shí)踐。由前端團(tuán)隊(duì)開(kāi)發(fā)維護(hù)一套完整的服務(wù)層,在設(shè)計(jì)和運(yùn)維方面還是有不小的挑戰(zhàn),但是能賦予前端團(tuán)隊(duì)更大的靈活自主性,對(duì)于研發(fā)迭代效率的提升也是顯著的。