GraphQL-BFF:微服務背景下的前後端數據交互方案
- 2019 年 10 月 10 日
- 筆記
前言
隨著多終端、多平台、多業務形態、多技術選型等各方面的發展,前後端的數據交互,日益複雜。
同一份數據,可能以多種不同的形態和結構,在多種場景下被消費。
在理想情況下,這些複雜性可以全部由後端承擔。前端只管從後端介面里,拿到已然整合完善的數據。
然而,不管是因為後端的領域模型,還是因為微服務架構。作為前端,我們感受到的是,後端提供的介面,越發不夠前端友好。我們必須自行組合多個後端介面,才能獲取到完整的數據結構。
面向領域模型的後端需求,跟面向頁面呈現的前端需求,出現了不可調和的矛盾。
在這種背景下,本著誰受益誰開發的原則。我們最後選擇使用 Node.js 搭建專門服務於前端頁面呈現的後端,亦即 Backend-For-Frontend,簡稱 BFF。
我們面臨了很多不同的技術選型,主要圍繞在權衡 RESTful API 和 GraphQL。
正如標題所示,我們最終選用的是 GraphQL as BFF。
本文將介紹我們對 GraphQL 所作的考察、探索、權衡、技術選型與設計等多方面的內容,希望能給大家帶來一些啟發。
一、GraphQL 模式出現的必然性
面向前端頁面的數據聚合層,其介面很容易在迭代過程中,變得愈加複雜;最終發展成一個超級介面。
它有很多調用方,各種不同的調用場景,甚至多個不同版本的介面並存,同時提供數據服務。
所有這些複雜性,都會反映到介面參數上。
介面調用的場景越多,它對介面參數結構的表達能力,要求越高。如果只有一個 boolean 類型的參數,只能滿足 true | false 兩種場景罷了。
以產品詳情介面為例,一種很自然的請求參數結構如下:

裡面包含 ChannelCode 渠道資訊,IsOp 身份資訊,MarketingInfo 營銷相關的資訊,PlatformId 平台資訊,QueryNode 查詢的節點資訊,以及 Version 版本資訊。最核心的參數 ProductId,被大量場景相關的參數所圍繞。
審視一下 QueryNode 參數,很容易可以發現,它正是 GraphQL 的雛形。只不過它用的是更複雜的 JSON 來描述查詢欄位,而 GraphQL 用更簡潔的查詢語句,完成同樣的目的。
並且,QueryNode 參數,只支援一個層級的欄位篩選;而 GraphQL 則支援多層級的篩選。
GraphQL 可以看作是 QueryNode 這種形式的參數設計的專業化。相比用 JSON 來描述查詢結果,GraphQL 設計了一個更完整的 DSL,把欄位、結構、參數等,都整合到一起。
仿照格林斯潘第十定律:
任何C或Fortran程式複雜到一定程度之後,都會包含一個臨時開發的、不合規範的、充滿程式錯誤的、運行速度很慢的、只有一半功能的Common Lisp實現。 https://zh.wikipedia.org/wiki/%E6%A0%BC%E6%9E%97%E6%96%AF%E6%BD%98%E7%AC%AC%E5%8D%81%E5%AE%9A%E5%BE%8B
或許可以說:
任何介面設計複雜到一定程度後,都會包含一個臨時開發的、不合規範的、只有一半功能的 GraphQL 實現。
從 SearchParams, FormData 到 JSON,再到 GraphQL 查詢語句,我們看到不斷有新的數據通訊方式出現,滿足不同的場景和複雜度的要求。
站在這個層面上看,GraphQL 模式的出現,有一定的必然性。
二、GraphQL 語言設計中的必然性
作為一個查詢相關的 DSL,GraphQL 的語言設計,也不是隨意的。
我們可以做一個思想實驗。
假設你是一名架構師,你接到一項任務,設計一門前端友好的查詢語言。要求:
- 查詢語法跟查詢結果相近
- 能精確查詢想要的欄位
- 能合併多個請求到一個查詢語句
- 無介面版本管理問題
- 程式碼即文檔
我們知道查詢結果是 JSON 數據格式。而 JSON 是一個 key-value pair 風格的數據表示,因此可以從結果倒推出查詢語句。

上圖是一個查詢結果。很顯然,它的查詢語句不可能包含 value 部分。我們刪去 value 後,它變成下面這樣。

查詢語句跟查詢結果擁有相同的 key 及其層次結構關係。這是我們想要的。
我們可以再進一步,將冗餘的雙引號,逗號等部分刪掉。

我們得到了一個精簡的寫法,它已經是一段合法的 GraphQL 查詢語句了。
其中的設計思路和過程是如此簡單直接,很難想像還有別的方案比目前這個更滿足要求。
當然,只有欄位和層級,並不足夠。符合這種結構的數據太多了,不可能把整個資料庫都查詢出來。我們還需要設計參數傳遞的方式,以便能縮小數據範圍。

上圖是一個自然而然的做法。用括弧表示函數調用,裡面可以添加參數,可謂經典的設計。
它跟 ES2015 里的 (Method Definitions Shorthand) 也高度相似。如下所示:

前面演示的 GraphQL 參數寫法,參數值用的是字面量 userId: 123。這不是一個特別安全的做法,開發者會在程式碼里,用拼接字元串的方式將字面量值注入到查詢語句,也就給了惡意攻擊者注入程式碼的機會。
我們需要設計一個參數變數語法,明確參數位置和數量。

我們可以選用 $xxx 這種常見的標記方法,它被很多語言採用來表示變數。沿用這種風格,可以大大減少開發者的學習成本。
前後端通訊的另一個痛點是,命名。前端經常吐槽後端的欄位名過於冗長,或者不知所云,或者拼寫錯誤,或者不符合前端表述習慣。最常見的情況是,後端欄位名以大寫字母開頭,而前端習慣 Class 或者 Component 是大寫字母開頭,實例和數據,則以小寫字母開頭。
我們期望有機會進行欄位名調整。
別名映射(Alias)語法,正是為了這個目的而出現的。

上面這種別名映射的語法,在其它語言里也很常見。如果不這樣寫,頂多就是變成:
uid as Uid 或者 uid = Uid 這類做法,差別不大。我認為選用冒號更佳,它跟 ES2015 的解構語法很接近。

至此,我們擁有了 key 層級結構,參數傳遞,變數寫法,別名映射等語法,可以編寫足夠複雜的查詢語句了。不過,還有幾個小欠缺。
比如對欄位的條件表達。假設有兩次查詢,它們唯一的差別就是,一個有 A 欄位,另一個沒有 A 欄位,其它欄位及其結構都是相同的。為了這麼小的差別 ,前端難道要編寫兩個查詢語句?
這顯然不現實,我們需要設計一個語法描述和解決這個問題。
它就是——指令(Directive)。

指令,可以對欄位做一些額外描述,比如
@include,是否包含該欄位;
@skip,是否不包含該欄位;
@deprecate,是否廢棄該欄位;
除了上述默認指令外,我們還可以支援自定義指令等功能。
指令的語法設計,在其它語言里也可以找到借鑒目標。Java,Phthon 以及 ESNext 都用了 @ 符號表示註解、裝飾器等特性。
有了指令,我們可以把兩個高度相似的查詢語句,合併到一起,然後通過條件參數來切換。這是一個不錯的做法。不過,指令是跟著單個欄位走的,它不能解決多欄位的問題。
比如,欄位 A 和欄位 B,擁有相同的總體結構,僅僅只有 1 個欄位名的差異。前端並不想編寫一樣的 key 值重複多次。
這意味著,我們需要設計一個片段語法(Fragment)。

如上所示,用 fragment 聲明一個片段,然後用三個點表示將片段在某個對象欄位里展開。我們可以只編寫一次公共結構,然後輕易地在多個對象欄位里復用。
這種設計也是一個經典做法,跟 JavaScript 里的 Spread Properties 很相近。

至此,我們得到了一個相對完整的,對前端友好的查詢語言設計。它幾乎就是 GraphQL 當前的形態。
如你所見,GraphQL 的查詢語言設計,借鑒了主流開發語言里的眾多成熟設計。使得任何擁有豐富的編程經驗的開發者,很容易上手 GraphQL。
按照同樣的要求,重新來一遍,大概率得到跟當前形態高度接近的設計。這是我理解的 GraphQL 語言設計里包含的必然性。
三、GraphQL 的組成與鏈路
查詢語法,是 GraphQL 面向前端,或者說面向數據消費端的部分。
除此之外,GraphQL 還提供了面向後端,或者說面向數據提供方的部分。它就是基於 GraphQL 的 Type System 構建的 Schema。
一個 GraphQL 服務和查詢的鏈路,大致如下:

首先,服務端編寫數據類型,構建一個數據結構之間的關聯網路。其中 Query 對象是數據消費的入口。所有查詢,都是對 Query 對象下的欄位的查詢。可以把 Query 下的欄位,理解為一個個 RESTful API。比如上圖中的,Query.post 和 Query.author,相當於 /post 和 /author 介面。
GraphQL Schema 描述了數據的類型與結構,但它只是形狀(Shape),它不包含真正的數據。我們需要編寫 Resolver 函數,在裡面去獲取真正的數據。
Resolver 的簡單形式如下

每個 Query 對象下的欄位,都有一個取值函數,它能獲取到前端傳遞過來的 query 查詢語句里包含的參數,然後以任意方式獲取數據。Resolver 函數可以是非同步的。
有了 Resolver 和 Schema,我們既定義了數據的形狀,也定義了數據的獲取方式。可以構建一個完整的 GraphQL 服務。
但它們只是類型定義和函數定義,如果沒有調用函數,就不會產生真正的數據交互。
前端傳遞的 query 查詢語句,正是觸發 Resolver 調用的源頭。

如上所示,我們發起了查詢,傳遞了參數。GraphQL 會解析我們的查詢語句,然後跟 Schema 進行數據形狀的驗證,確保我們查詢的結構是存在的,參數是足夠的,類型是一致的。任何環節出現問題,都將返回錯誤資訊。
數據形狀驗證通過後,GraphQL 將會根據 query 語句包含的欄位結構,一一觸發對應的 Resolver 函數,獲取查詢結果。也就是說,如果前端沒有查詢某個欄位,就不會觸發該欄位對應的 Resolver 函數,也就不會產生對數據的獲取行為。
此外,如果 Resolver 返回的數據,大於 Schema 里描繪的結構;那麼多出來的部分將被忽略,不會傳遞給前端。這是一個合理的設計。我們可以通過控制 Schema,來控制前端的數據訪問許可權,防止意外的將用戶帳號和密碼泄露出去。
正是如此,GraphQL 服務能實現按需獲取數據,精確傳遞數據。
四、澄清關於 GraphQL 的幾個迷思
有相當多的開發者,對 GraphQL 有各種各樣的誤解。在這裡挑選幾個重要的例子,加以澄清,幫助大家更全面的認識 GraphQL。
[4.1] GraphQL 不一定要操作資料庫
有一些開發者認為 GraphQL 需要操作資料庫,因此實現起來,幾乎等於要推翻當前後端的所有架構。這是一個重大誤解。
GraphQL 不僅可以不操作資料庫,它甚至可以不從其它地方獲取數據,而直接寫死數據在 Resolver 函數里。查看 graphql.js 的官方文檔,我們輕易可以找到案例:

上圖定義了一個 schema,只有一個類型為 String 的 hello 欄位,它的 resolver 函數里,無視所有參數,直接 return 一個 hello world 字元串。
可以看到,GraphQL 只是關於 schema 和 resolver 的一一對應和調用,它並未對數據的獲取方式和來源等做任何假設。
[4.2] GraphQL 跟 RESTful API 不是對立的
在網路上,有相當多的 GraphQL 文章,將它跟 RESTful API 對立起來,彷彿要麼全盤 GraphQL,要麼全盤 RESTful API。這也是一個重大誤解。
GraphQL 和 RESTful API 不僅不對立,還是互相協作的關係。
在前面關於 Resolver 函數的圖片中,我們看到,可以在 GraphQL Schema 的 Resolver 函數里,調用 RESTful API 去獲取數據。
當然,也可以調用 RPC 或者 ORM 等方式,從別的數據介面或者資料庫里獲取數據。
因此,實現一個 GraphQL 服務,並不需要挑戰當前整個後端體系。它具有高度靈活的適配能力,可以低侵入性的嵌入當前系統中。
[4.3] GraphQL 不一定是一個後端服務
儘管絕大多數 GraphQL,都以 server 的形式存在。 但是,GraphQL 作為一門語言,它並沒有限制在後端場景。

上圖還是前面展示過的 graphql.js 的官方文檔,最下面一行,就是一個普通的函數調用,它發起了一次 graphql 查詢,其 response 結果如下:

這段程式碼,不只能在 node.js 里運行,在瀏覽器里也可以運行(可訪問:https://codesandbox.io/s/hidden-water-zfq2t 查看運行結果)
因此,我們完全可以將 GraphQL 用在純前端,去實現 State Management 狀態管理。Relay 等框架,即包含了用在前端的 graphql。
[4.4] GraphQL 不一定需要 Schema
這是一個有趣的事實,GraphQL 語言設計里的兩個組成部分:
1)數據提供方編寫 GraphQL Schema;
2)數據消費方編寫 GraphQL Query;
這種組合,是官方提供的最佳實踐。但它並不是一個實踐意義上的最低配置。
GraphQL Type System 是一個靜態的類型系統。我們可以稱之為靜態類型 GraphQL。此外,社區還有一種動態類型的 GraphQL 實踐。
graphql-anywhere: Run a GraphQL query anywhere, without a GraphQL server or a schema. https://github.com/apollographql/apollo-client/tree/master/packages/graphql-anywhere
它跟靜態類型的 GraphQL 差別在於,沒有了基於 Schema 的數據形狀驗證階段,而是直接無腦地根據 query 查詢語句里的欄位,去觸發 Resolver 函數。
它也不管 Resolver 函數返回的數據類型對不對,獲取到什麼,就是什麼。一個欄位,不必先定義好,才能被前端消費,它可以動態的計算出來。
在某些場景下,動態類型的 GraphQL 有一定的便利性。不過,它同時喪失了 GraphQL 的部分精髓,這塊後面將會詳細描述。
值得一提的是,不管是靜態類型的 GraphQL 還是動態類型的 GraphQL,都是既可以運行在服務端,也可以運行在前端。
[4.5] GraphQL 不一定返回 JSON 數據格式
這是另一個有趣的事實。最初我們演示了,如何基於 JSON 數據結果,反推出 GraphQL 查詢語法的設計。而現在,我們卻說 GraphQL 可以不返回 JSON 數據格式。
沒錯。當一個新事物出現之後,隨著它的不斷發展,它可以脫離其初衷,衍生出不同的形態。

上圖還是來自 graphql-anywhere 里的例子。
在這裡,它實現了一個 gqlToReact 的 Resolver,可以把一個 graphql 查詢轉換為 ReactElement 結構。
不只是動態類型的 GraphQL 有這個能力,靜態類型的 GraphQL 也有可能實現一樣的效果。
不過這種做法,目前僅僅停留在能力演示階段。其妙用還有待社區去挖掘和探索。
五、GraphQL 的幾種使用模式
到目前為止,我們見識到了 GraphQL 的高自由度和靈活性。在搭建 GraphQL Server 時,也可以根據實際需求和場景,採用不同的模式。
[5.1] RESTful-Like 模式
這個模式就是簡單粗暴的把 RESTful API 服務,替換成 GraphQL 實現。之前有多少 RESTful 服務,重構後就有多少 GraphQL 服務。它是一個簡單的一對一關係。
默認情況下,面向兩個 GraphQL 服務發起的查詢是兩次請求,而不是一次。舉個例子:
前端需要產品數據時,從之前調用產品相關的 RESTful API,變成查詢產品相關的 GraphQL。不過,需要訂單相關的數據時,可能要查詢另一個 GraphQL 服務。
有一些公司拿 GraphQL 小試牛刀時,採取了這個做法;將 GraphQL 用在特定服務里。
不過,這種模式難以發揮 GraphQL 合併請求和關聯請求的能力。只是起到了按需查詢,精確查詢欄位的作用,價值有限。
因此,他們在實踐後,發現收效甚微;認為 GraphQL 不過如此,還不如 RESTful API 架構簡單和成熟。
其實這是一種選型上的失誤。
[5.2] GraphQL as an API Gateway 模式
在這個模式里,GraphQL 接管了前端的一整塊數據查詢需求。

前端不再直接調用具體的 RESTful 等介面,而是通過 GraphQL 去間接獲取產品、訂單、搜索等數據。
在 GraphQL 這個中間層里,我們將各個微服務,按照它們的數據關聯,整合成一個基於 GraphQL Schema 的數據關係網路。前端可以通過 GraphQL 查詢語句,同時發起對多個微服務的數據的獲取、篩選、裁剪等行為。
值得一提的是,作為 API Gateway 的 GraphQL 服務,可以在其 Resolver 內,向前面提到的 RESTful-like 的 GraphQL 發起查詢請求。
如此,既避免了前端需要一對多的問題,也解決了 API Gateway GraphQL 需要請求 RESTful 全量數據介面的內部冗餘問題。讓服務到服務之間的數據調用,也可以做到更精確。
GraphQL 服務是一個對數據消費方友好的模式。而數據消費方,既可以是前端,也可以是其它服務。
當數據消費方是其它服務時,通過 GraphQL 查詢語句,彼此之間可以更精確獲取數據,避免冗餘的數據傳輸和介面調用。
當數據消費方是前端時,由於前端需要跟多個數據提供方打交道,如果每個數據提供方都是單獨的 GraphQL,那並不能得到本質上的改善。此時若有一個 Gateway 角色的 GraphQL,可以真正減少前端調用的複雜度。
[5.2.1] 兩類 GraphQL API Gateway 服務
同樣是 API Gateway 角色的 GraphQL 服務,在實現方式上也有不同的分類。
1)包含大量真實的數據操作和處理的 GraphQL
2)轉發數據請求,聚合數據結果的 GraphQL
第一類,是傳統意義上的後端服務;第二類,則是我們今天的重點,GraphQL as BFF。
這兩類 GraphQL 服務的要求是不同的,前者可能包含大量 CPU 密集的計算,而後者總體而言主要是 Network I/O 相關的行為。
許多公司並不提倡使用 Node.js 構建第一種服務,不管是構建 RESTful 還是 GraphQL。我們也一樣。
因此,後面我們討論的 GraphQL,如果沒有特別聲明,都可以理解為上面所說的第二種類型。
[5.3] GraphQL as a Backend Framework
在澄清關於 GraphQL 的迷思時,我們指出,GraphQL 可以不作為 Server。
這意味著,一個包含 GraphQL 實現的 Server,不一定通過 GraphQL 查詢語句進行前後端數據交互,它可以繼續沿用 RESTful API 風格。
也就是說,我們可以把 GraphQL 當作一個服務端開發框架,然後在 RESTful 的各個介面里,發起 graphql 查詢。
不管是前端跟其它後端服務,都不必知道 GraphQL 的存在。前端的調用方式,還是 RESTful API,在 RESTful 服務內部,它自己向自己發起了 GraphQL 查詢。
那麼,這個模式有什麼好處跟價值?
設想一下,你用 RESTful API 風格實現 BFF。由於 PC 端和移動端的場景不同,它們對同一份數據的消費方式差異很大。
在 PC 端,它可以一次請求全量數據。
在移動端,因為它螢幕小,它要分多次去請求數據。首屏一次,非首屏一次,滾動按需載入 N 次,多個 2 級頁面里 M 次。
我們要麼實現一個超級介面,根據請求參數適配不同場景(即實現一個半吊子的 GraphQL);要麼實現多個功能相似,但又不同的 RESTful 介面。
其中的差異太大了,所以很多公司索性就把 BFF 分成,PC-BFF 和 Mobile-BFF 兩個 BFF 服務。

我們可以把 PC-BFF 和 Mobile-BFF 整合成一個 GraphQL-BFF 服務。即便前後端不通過 GraphQL 查詢語句進行交互,我們也可以在各個介面里,編寫相對簡單的查詢語句,代替更高成本的介面實現。
也即是說,使用 GraphQL 搭建 BFF,如果出現前後端分工、溝通等方面的矛盾。我們可以將 GraphQL 服務降級為 RESTful 服務,無非就是把需要前端編寫的查詢語句,寫死在後端介面裡面罷了。
如果實現的是 RESTful 服務,要轉換成 GraphQL 服務,就沒有那麼簡單了。
有了這種優雅降級的能力,我們可以更加放心大膽的推動 GraphQL-BFF 方案。
六、GraphQL 精髓
理解 GraphQL 的精髓所在,可以幫助我們更正確地實踐 GraphQL。
首先來想一下,GraphQL 為什麼要叫 GraphQL,其中的 Graph 體現在什麼地方?
GraphQL 的查詢語句,看起來是 JSON 寫法的一種簡化。而 JSON 是一個 Tree 樹形數據結構。為什麼不叫 TreeQL,而是 GraphQL 呢?
[6.1] Tree VS Graph
一個重要的前置知識是,什麼是 Tree,什麼是 Graph,它們有什麼關係?
下圖是一個 Tree 的結構示意圖。

Tree 有且只有一個 Root 節點,並且對於每個非 Root 節點,有且只有一個父節點;它們組成了一個層次結構。其中任意兩個節點,有且只有一條連接路徑;沒有循環,也沒有遞歸引用。
下圖是一個 Graph 的結構示意圖。

而 Graph 里的節點之間,可能存在不只一種連接路徑,可能存在循環,可能存在遞歸引用,可能沒有 Root 節點。它們組成了一個網路結構。
我們可以把 Graph 這種網路結構,通過裁剪連接路徑,把它壓縮成任意節點只有唯一連接路徑的簡化形態。如此網路結構退化成層次結構,它變成了 Tree。
也就是說,Graph 是比 Tree 更複雜的數據結構,後者是它的簡化形式。擁有 Graph,我們可以按照不同的裁剪方式,衍生出不同的 Tree。而 Tree 里包含的資訊,如果不增加其它額外數據,不足以構建足夠複雜的 Graph 結構。
[6.2] GraphQL 里的 Graph 結構
在 GraphQL 里,承擔構建網路結構的,並非 GraphQL 查詢語句,而是基於 GraphQL Type System 構建的 Schema。

上圖是一個 GraphQL Schema,定義了 A, B, C, D 和 E 五種數據類型,它們分別掛載到 入口類型 Query 里的 a, b, c, d 和 e 欄位里。
A, B, C, D, E 裡面,包含著遞歸的結構。A 裡面包含 B 和 C,B 裡面包含 C 和 D,D 裡面包含 E,E 裡面又包含 A,又回到了 A。
這是一個複雜的關係網路。要構建遞歸關聯,並不需要這麼複雜。直接 A 里包含 B,和 B 里包含 A 也行,此處是一個演示。
有了這個基於數據類型的 Graph 關係網路,我們可以實現從 Graph 中派生出 JSON Tree 的能力。

上圖是一個 GraphQL 的查詢語句,它是一個包含很多 key 的層次結構,亦即一個 Tree。
它從跟節點裡取 a 欄位,然後向下分層,找到了 e。而 e 節點裡也包含一個跟根節點同類型的 a 欄位,因此它可以繼續向下分層,重來一遍,又到了 e 節點,此時它只取了 data 欄位,查詢中止。
我編寫了一個簡單的 Resolver 函數,用來演示查詢結果。

它很簡單。Query 里返回跟欄位名一樣的字母,任何子節點的數據,都是拼接父節點的字母串。如此我們可以從查詢結果看出數據流動的層次。
查詢結果如下:

第一個 e 節點的 data 欄位里,拿到了父節點裡的 data 數據,其父節點的 data 數據又是通過它的父節點裡獲取的,因此有一個數據鏈條。
而第二個 e 節點同理,它有兩段鏈條。
只要不編寫後續欄位,我們可以停留在任意節點的 data 欄位里。
也就是說,我們用作為 Tree 的 Query 語句,去裁剪了作為 Graph 的 Schema 數據關聯網路,得到了我們想要的 JSON 結構。
通過這個角度,我們可以理解為什麼 GraphQL 不允許 Query 語句停留在 Object 類型,一定要明確的寫出對象內部的欄位,直到所有 Leaf Node 都是 Scalar 類型。
這不僅僅是一個所謂的最佳實踐,這也是 Graph 本身的特徵。對象節點裡,可能通過循環或者遞歸關係,拓展出無限大的數據結構。Query 語句必須寫清楚,才能幫助 GraphQL 去裁剪掉不必要的數據關聯路徑。
[6.3] Graph 網路結構的實際價值
前面的 A, B, C, D, E 案例,並不能直觀的讓大家感受到,Graph 網路結構的實際價值。它看起來像一個連線遊戲。
放到 Facebook 的社交網路場景下,其必要性和價值就凸顯了。
假設我們要一次性獲取用戶的好友的好友的好友的好友的好友,基於 RESTful API 我們有什麼特別好的方法嗎?很難說。
而 Graph 這種遞歸關聯的結構,實現這種查詢輕而易舉。

我們定義了一個 User 類型,掛到 Query 入口上的 user 欄位里。Use 類型的 friends 欄位又是一個 User 類型的列表。這樣就構建了一個遞歸關聯。
getFriends 查詢語句,可以不斷地從任意用戶開始,關聯其 friends,得到 friends 數組結果。任意一個 friend 也是 User,它也有自己的 friends。查詢依據在最外層的 friends 停了下來,它只查詢了 id 和 name 欄位。
看到這裡,另一個經典的關於 GraphQL 的誤解出現了:只有像 Facebook,Twitter 這類社交關係網路,才適合 GraphQL,而我們的場景下,GraphQL 並不適用。
其實不然,社交關係網路里使用 GraphQL 特別有效,不意味著其它場景下,GraphQL 不能帶來收益。
設想一個電商平台的場景,它有用戶、產品和訂單這組鐵三角,其它庫存、價格,優惠券,收藏等先不提。在最簡單的場景下,GraphQL 依然可以發揮作用。

我們構建了 User,Product 和 Order 三個類型,它們彼此之間有欄位上的遞歸關聯關係,是一個 Graph 結構。在 Query 入口類型上,分別有 user, product 和 order 三個欄位。
據此,我們可以實現從 user, product 和 order 任意維度出發,通過它們的關聯關係,實現豐富而靈活的查詢。
比如,查看用戶的所有訂單及其跟訂單相關的產品,Query 語句如下:

我們查詢了 id 為 123 的用戶,他的名字和訂單列表,對於每個訂單,我們獲取該訂單的創建時間,購買價格和關聯產品,對於訂單關聯的產品,我們獲取了產品 id,產品標題,產品描述和產品價格。
當我們的後端人員組織架構是按照領域模型來劃分時,用戶,產品和訂單,通常是 3 個團隊,他們各自提供領域相關的介面。通過 GraphQL 我們可以很容易將它們整合到一起。
再比如,查看一個產品下的所有訂單及其關聯用戶,Query 語句如下:

我們查詢了 id 為 123 的產品,它的產品標題,產品描述和價格,以及關聯的訂單。對於每個關聯訂單,我們查詢了訂單的創建時間,購買價格以及下訂單的用戶,對於下訂單的用戶,我們查詢了他的用戶 id 和名稱。
如你所見,只要構建出了 Graph 結構的數據網路,它不像 Tree 那樣有唯一的 Root 節點。從任意入口出發,它都可以通過關聯路徑,不斷的衍生出數據,得到 JSON 結果。
我們不必疲於編寫面向產品詳情頁的介面,面向訂單詳情頁的介面,面向用戶資訊的介面。我們編寫了一個數據關係網路,就足以適配不同的場景。
此處演示的,只是用戶,產品和訂單這三個資源的關係網路,已經可以看出 GraphQL 的適用性。在實際場景中,我們能搭建出更複雜的數據網路,它具備更強大的數據表達能力,可以給我們的業務帶來更多收益。
七、我們的 GraphQL-BFF 實踐模式
在掌握上述關於 GraphQL 的綱領知識後,我們來看一下在實踐中 ,GraphQL-BFF 的一種實際做法。
首先是技術選型,我們主要採用了如下技術棧。

開發語言選用了 TypeScript,跑在 Node.js v10.x 版本上,服務端框架是 Koa v2.x 版本,使用 apollo-server-koa 模組去運行 GraphQL 服務。
Apollo-GraphQL 是 Node.js 社區里,比較知名和成熟的 GraphQL 框架。做了很多的細節工作,也有一些相對前沿的探索,比如Apollo Federation 架構等。
不過,有兩點值得一提:
1)Apollo-GraphQL 屬於 GraphQL 社區的一部分,而非 Facebook 官方的 GraphQL 開發團隊。Apollo-GraphQL 在官方 GraphQL 的基礎上進行了帶有他們自身理念特點的封裝和設計。像 Apollo Federation 這類目前看來比較激進的方案,即使是 GraphQL 官方的開發人員,對此也持保留態度。
2)Apollo-GraphQL 的重心是前文所說的第一類 API Gateway 角色的 GraphQL 服務,本文探討的是第二類。因此,Apollo-GraphQL 里有很多功能對我們來說沒必要,有一些功能的使用方式,跟我們的場景也不契合。
我們主要使用的是 Apollo-GraphQL 的 graphql-tools 和 apollo-server-koa 兩個模組,並在此基礎上,進行了符合我們場景的設計和改編。
[7.1] 我們的 GraphQL-BFF 架構設計
GraphQL-BFF 的核心思路是,將多個 services 整合成一個中心化 data graph。

每個 service 的數據結構契約,都放入了一個大而全的 GraphQL Schema 里;如果不做任何模組化和解耦,開發體驗將會非常糟糕。每個團隊成員,都去修改同一份 Schema 文件。
這明顯是不合理的。GraphQL-BFF 的開發模式,應該跟 service 的領域模型,有一一對應的關係。然後通過某種形式,多個 services 自然整合到一起。
因此,我們設計了 GraphQL-Service 的概念。
[7.1.1] GraphQL-Service
GraphQL-Service 是一個由 Schema + Resolver 兩部分組成的 JS 模組,它對應基於領域模型的後端的某個 Servcie。每個 GraphQL-Service 應該可以按照模組化的方式編寫,跟其它 GraphQL-Service 組合起來後,構建出更大的 GraphQL-Server。
GraphQL-Service 通過 GraphQL 的 Type Extensions 構建數據關聯關係。

如上所示,我們的 UserService 裡面,只涉及到了 User 相關的類型處理。它定義了自己的基本欄位,id 和 name。通過 extend type 定義了它在 Order 和 Product 數據里的關聯欄位,以及定義在 Query 里的入口欄位。
從 User Schema 里我們可以看到,User 有兩類查詢路徑。
1)通過根節點 Query 以傳遞參數的方式,獲取到 User 資訊。
2)通過 Product 或 Order 節點,以數據關聯的方式,獲取到 User 資訊。

上圖是 OrderService 的 Schema,它也只涉及了 Order 相關的類型邏輯。同樣是通過 extend type 定義了在 User 和 Product 里的關聯欄位,以及定義了在根節點 Query 里的入口欄位。
Order 數據跟 User 一樣,有兩種消費路徑。一種通過 Query 節點,另一種是通過數據關聯節點。
前面我們演示 User, Order 和 Product 鐵三角關係時,是在同一個 Schema 里編寫它們的關聯。我們把多個 GraphQL-Service 的 Schema 整合到一起後,可以生成同樣的結果:

上圖不是我們手動編寫的,而是 merge 多個 GraphQL-Service 的 Schema 後生成的結果。可以看出來,跟之前手寫的版本,總體上是一樣的。
有了解耦的 Schema 並不足夠,它只定義了數據類型及其關聯。我們需要 Resolver 去定義數據的具體獲取方式,Resolver 也需要解耦。
[7.1.2] GraphQL-Resolver
不管是在官方的 GraphQL 文檔里,還是 Apollo-GraphQL 的文檔里,Resolver 都是以普通函數的形態出現。

這在簡單場景下,沒有什麼問題。正如在簡單場景下,用 node.js 的 http.createServer 就可以創建一個 server。

如上,設置狀態碼,設置響應的 Content-Type,返回內容即可。
然而,在更複雜的真實項目中,我們實際上需要 express、koa 等服務端框架,用中間件的模式編寫我們的服務端處理邏輯,由框架將它們整合為一個requestListener 函數,註冊到 http.createServer(requestListener) 里。
在 GraphQL Server 里,雖然 endpoint 只有 /graphql 一個,但不代表它只需要一組 Koa 中間件。
正如一開始我們指出的,每個超級介面里都包含一半功能的 GraphQL 實現。GraphQL 是往超級介面的方向更進一步,不能簡單地以普通介面的眼光去看待它。
在 Query 下的每個欄位,都可能對應 1 到多個內部服務的 API 的調用和處理。只用普通函數構成的 resolverMap,不足以充分表達其中的邏輯複雜度。
不管是用 endpoint 來表示資源,還是用 GraphQL Field 欄位來表示資源,它們只是外在形式略有不同,不會改變業務邏輯的複雜度。
因此,採用比普通函數具有更好的表達能力的中間件,組合出一個個 Resolver,再整合到一個 ResolverMap 里。可以更好的解決之前解決不了,或者很難的問題。
所謂的架構能力,體現在理解我們面對的問題的複雜度及其本質特徵,並能選擇和設計出合適的程式表達模型。
後面我們將演示,正確的架構,如何輕易地克服之前難以解決的問題。
[7.1.3] 用 koa-compose 組織我們的 Resolver
或許很多同學並不清楚,express 或 koa 里的中間件模式,可以脫離作為服務端框架的它們而單獨使用。正如 GraphQL 可以單獨不作為 server,在任意支援 JavaScript 運行的地方使用一樣。
我們將使用 koa-compose 這個 npm 模組,去構造我們的 Resolver。
前文里提到的 gql 函數,接受一個 Schema 返回一個 GraphQL-Service,每個 GraphQL-Service 都有一個 resolve 方法:

resolve 方法,接受兩個參數。第一個是 typeName,對應 GraphQL-Schema 里的 Object Type 的類型名稱;第二個是 fieldHandlers,每個 handler 支援中間件模式,最終它們將被 koa-compose 整合成一個 Resolver。
以 UserService 為例,其 Resolver 寫起來如下:

作為普通函數的 Resolver 接收的所有參數,都被整合到了 ctx 裡面。ctx.result 則是該欄位的最終輸出,類似於 koa server 里的 ctx.body。我們刻意採用了 ctx.result 這個不同於 ctx.body 的屬性,明確區分我們處理的是一個介面還是一個欄位。
在簡單場景下,中間件模式的 Resolver 跟普通函數的 Resolver,僅僅是參數的數量和返回值的方式不同。並不會增加大量的程式碼複雜度。

當我們多個欄位要復用相同的邏輯時,編寫成中間件,然後將 handler 變成數組形式即可。(在程式碼里我們用 json 模擬了資料庫表,所以是同步程式碼,實際項目里,它可以是非同步的調用介面或者查詢資料庫)。
上面的 logger,只是一個簡單案例。除此之外,我們可以編寫 requireLogin 中間件,決定一個欄位是否只對登陸用戶可用。我們可以編寫不同的工具型中間件,注入 ctx.fetch, ctx.post, ctx.xxx 等方法,以供後續中間件使用。
每個 GraphQL Field 欄位,都擁有獨立的一組中間件和 ctx 對象,跟其他欄位互相不影響。我們同時,可以把所有欄位共享的中間件,放到 koa server 里的中間件里。

如上圖所示,綠框是 endpoint,可以編寫 koa server 層面的 middleware。而藍框是 GraphQL Field 欄位,可以編寫 Resolver 層面的 middleware。endpoint 層面的 middleware 對 ctx 的修改,會影響到後面所有欄位。

也就是說,我們可以像上面那樣。掛介面層面的 logger,可以知道整個 graphql 查詢的耗時。編寫一個中間件,在 next 之前,掛載一些方法,供後續中間件使用;在 next 之後,拿到 graphql 的查詢結果,進行額外的處理。
[7.2] 解決 mock 難題
GraphQL 是天生 mock 友好的模式,因為其 Schema 里已經指明了所有數據的類型及其關聯;很容易可以通過 faker data 之類的手段,自動根據類型生成假數據。
然而,在實踐中,實現 GraphQL Mocking 還是有不少的挑戰。

如上圖所示,在 Apollo GraphQL 里,mock 看似很簡單,只需要在創建服務時,設置 mock 為 true,或者提供一個 mock resolver 即可。但是,一個全局的,跟著服務創建走的 mock,太過粗暴。
mock 的價值在於提供更好的數據靈活性以加速開發效率。它既可以在沒有數據時,提供假數據;也可以在真數據的介面有問題時,不用重啟服務,也能降級為假數據。它既可以是整個 GraphQL 查詢級別的 mock,也可以是欄位級別的 mock。
作為超級介面的 GraphQL 服務,全局的,在啟動階段就固化的 mocking,意義不大。
Apollo GraphQL 的 mocking 實踐問題,正是它採用普通函數來描述 Resolver 所帶來的;它很難簡單的通過拓展某個 resolver 而支援 mocking。它不得不在創建服務時,額外新增一個 mock resolver map 去承擔 mocking 職能。
而我們的 composed resolver 處理動態 mocking 卻異常簡單。

它不僅可以在運行時動態確定,它不僅可以細化到欄位級別,它甚至可以跟著某次查詢走 mock 邏輯(通過添加 @mock 指令)。

上圖是默認情況下,基於 faker 這個 npm 包,根據數據類型生成的 mock data。

在我們的設計里,默認的 mocking,其內部實現方式很簡單。我們先是編寫了上圖,根據 GraphQL Type 調用 faker 模組對應的方法,生成假數據。

然後在 createResolver 這個將中間件整合成 resolver 的函數里,先判斷中間件里是否存在自定義的 mock handler 函數,如果沒有,就追加前面編寫的 mocker 處理函數。
我們還提供了 mock 中間件,讓開發者能指定 mock 數據來源,比如指定 mock json 文件。

mock 中間件,接收字元串參數時,它會搜尋本地的 mock 目錄下是否有同名文件,作為當前欄位的返回值。它也接收函數作為參數,在該函數里,我們可以手動編寫更複雜的 mock 數據邏輯。

有趣的地方是,mock/user.json 文件里,只包含上圖紅框的數據,其關聯出來的 collections 欄位,是真實的。這是合理的做法,mock 應該跟著 resolver 走。關聯欄位擁有自己的 resolver,可能調用自己的介面;不應該因為父節點是 mock 的,子節點也進入 mock 模式。
如此,我們可以在父節點 resolver 對應的後端介面掛掉後,mock 它,讓沒掛掉的子節點 resolver 正常運行。如果我們希望子節點 resolver 也進入 mock。很簡單,添加一個 @mock 指令即可。

如上所示,user 欄位和 collections 欄位的 resolver 都進入了 mock 模式。

自定義 mock resolver 函數的方式如上圖所示,mock 中間件保證了,只有在該欄位進入 mock 模式時,才執行 mock resolver function。並且,mock resolver function 內部依然有機會通過調用 next 函數,觸發後面的真實數據獲取邏輯。

以上所有這些靈活性,都來自於我們選用了表達能力和可組合性更好的中間件模式,代替普通該函數,承擔 resolver 的職能。
總結
至此,我們得到了一個簡單而靈活的實踐模式。我們用 Schema 去構建 Data Graph 數據關聯圖,我們用 Middleware 去構建 Resolver Map,它們都具備很強的表達能力。
在開發 GraphQL-BFF 時,我們的 GraphQL-Service 跟後端基於領域模型的 Service,具有總體上的一一對應關係。不會產生後端數據層解耦後,在 GraphQL 層重新耦合的尷尬現象。
關於 GraphQL 還有很多話題可以討論,比如 batching , caching 等。這部分內容在網路上很多 GraphQL 的文檔和教程里都可以找到,這裡我們不再贅述。
總的而言,根據我們對 GraphQL 的考察和實踐,我們認為它可以比 RESTful API 更好的解決我們面對的問題。
我們對 GraphQL 的期望,不僅僅停留在 BFF 層。我們希望通過積累在 BFF 層使用 GraphQL 的成功經驗,幫助我們摸索出在 Micro Frontend 架構上使用 GraphQL 模式的合理設計。
如前面所演示的,像 User,Product 和 Order 這種公共級別的數據類型,不可能只由一個團隊去維護,它們需要被其它團隊所拓展。使得我們可以通過用戶,找到它關聯的訂單,收藏,優惠券等由其它團隊維護的數據。
放到 Micro Frontend 架構上,一個支付按鈕,也夾雜了多種類型的數據,產品資訊,用戶資訊,庫存資訊,UI 展示資訊,交互狀態資訊等等,綜合了這些資訊,支付按鈕被點擊時,才得到了充分的數據,可以決定是否去支付。
樸素 Micro Frontend 的設計,用 Vue, React, Angular 不同框架,分別維護不同組件,通過 router/message-passing 等方式互相通訊。在我看來,這是對後端微服務架構的拙劣模仿。
後端服務,各自部署在獨立環境中,對體積不敏感;因而可以採用不同的語言和技術棧。這不意味著將它簡單的放到前端里一樣成立。無法共享前端開發的基礎設施,這不是微前端,這是一種人員組織架構上的混亂。
GraphQL 讓我們看到,基於領域模型的微前端架構,可能是更好的方向。一個簡單的支付按鈕,也綜合了多個領域模型,由多個開發者有組織的協同開發。並不因為它表面上看起來是一個 Button 組件,就由某個團隊單獨維護。
當然,探索 GraphQL 的其它方向的前提是,GraphQL-BFF 架構得到成功的驗證。就現階段的實踐成果來看,我們對此充滿了信心。
儘管我們的程式碼暫無開源計劃,不過相信這篇文章,足夠完整和清楚地介紹了我們的 GraphQL-BFF 方案。希望它能給大家帶來一點幫助。
完