攜程基於 GraphQL 的前端 BFF 服務開發實踐

作者簡介

 

工業聚,攜程高級前端開發專家,react-lite, react-imvc, farrow 等開源項目作者。

蘭迪咚,攜程高級前端開發專家,對開發框架及前端性能優化有濃厚興趣。

一、前言

過去兩三年,攜程度假前端團隊一直在實踐基於 GraphQL/Node.js 的 BFF (Backend for Frontend) 方案,在度假 BU 多端產品線中廣泛落地。最終該方案不僅有效支撐前端團隊面向多端開發 BFF 服務的需要,而且逐步承擔更多功能,特別在性能優化等方面帶來顯著優勢。

我們觀察到有些前端團隊曾嘗試過基於 GraphQL 開發 BFF 服務,最終宣告失敗,退回到傳統 RESTful BFF 模式,會認爲是 GraphQL 技術自身的問題。

這種情況通常是由於 GraphQL 的落地適配難度導致的,GraphQL 的複雜度容易引起誤用。因此,我們期望通過本文分享我們所理解的最佳實踐,以及一些常見的反模式,希望能夠給大家帶來一些啓發。

二、GraphQL 技術棧

以下是我們 GraphQL-BFF 項目中所採用的核心技術棧:

其他非核心或者公司特有的基礎模塊不再贅述。

三、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 字段標記錯誤節點的位置,但由於以下原因,它帶來的幫助有限:

這個陷阱是導致 GraphQL 項目失敗的重大誘因。

錯誤處理在 GraphQL 項目中,比 RESTful API 更重要。後者常常只需要處理一次,而 GraphQL 查詢語句可以查詢多個資源。每個資源的錯誤處理彼此獨立,並非一個錯誤就意味着全盤的錯誤;每個資源所在的節點未必都是根節點,可以是任意層級的節點。

因此,GraphQL 項目裏的錯誤處理發生的次數跟位置都變得多樣。如果無法有效地管理異常,將會帶來無盡的麻煩,甚至是生產事件。長此以往,項目宣告失敗也在意料之內了。

第二個陷進是,用 Object 表達錯誤類型。

如上所示,AddTodoResult 類型是一個 Object

這種模式,即便在 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 契約儘可能穩定。對老功能進行改造時,接口契約可以按照以下步驟柔性進行:

假設之前有個前端直接調用的接口,得到 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 的實踐和交流。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3nkoyZ9pF_LwPu_okX-s2g