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