干货 | 万字长文全面解析GraphQL,携程微服务背景下的前后端数据交互方案
作者簡(jiǎn)介
古映杰,攜程研發(fā)高級(jí)經(jīng)理,負(fù)責(zé)前端框架和基礎(chǔ)設(shè)施的設(shè)計(jì)、研發(fā)與維護(hù)。開源項(xiàng)目react-lite和react-imvc作者。
前言
隨著多終端、多平臺(tái)、多業(yè)務(wù)形態(tài)、多技術(shù)選型等各方面的發(fā)展,前后端的數(shù)據(jù)交互,日益復(fù)雜。
同一份數(shù)據(jù),可能以多種不同的形態(tài)和結(jié)構(gòu),在多種場(chǎng)景下被消費(fèi)。
在理想情況下,這些復(fù)雜性可以全部由后端承擔(dān)。前端只管從后端接口里,拿到已然整合完善的數(shù)據(jù)。
然而,不管是因?yàn)楹蠖说念I(lǐng)域模型,還是因?yàn)槲⒎?wù)架構(gòu)。作為前端,我們感受到的是,后端提供的接口,越發(fā)不夠前端友好。我們必須自行組合多個(gè)后端接口,才能獲取到完整的數(shù)據(jù)結(jié)構(gòu)。
面向領(lǐng)域模型的后端需求,跟面向頁(yè)面呈現(xiàn)的前端需求,出現(xiàn)了不可調(diào)和的矛盾。
我們?cè)?jīng)試圖用 RESTful 風(fēng)格的無(wú)線 API 聚合層來(lái)解決問(wèn)題。這一層完全由后端工程師開發(fā)和迭代(前端作為下游等待聚合接口的契約),他們既要理解背后對(duì)接的微服務(wù)體系,又要理解每個(gè)前端頁(yè)面的展現(xiàn)需求。
隨著頁(yè)面數(shù)量的增加,接口調(diào)用方越來(lái)越多,后端 API 聚合層的邏輯越來(lái)越重。聚合層自身成為新的瓶頸所在。實(shí)際情況是,作為中間層的后端,既難以充分對(duì)接背后的微服務(wù)體系,也難以充分理解前端的頁(yè)面展現(xiàn)需求。
他們的整體定位很尷尬,代碼里既有 UI 展示相關(guān)的話術(shù)等信息,又有大量接口相關(guān)的業(yè)務(wù)邏輯處理。每次發(fā)布迭代,都要跟上下游做大量的溝通和聯(lián)調(diào)。
我們最終意識(shí)到,讓最了解頁(yè)面數(shù)據(jù)邏輯的前端接管中間層,讓后端回到他們擅長(zhǎng)的領(lǐng)域模型,可能是更好的做法。可以減少大家溝通成本,提高前后端的專業(yè)化程度,降低聯(lián)調(diào)壓力,加快彼此的開發(fā)效率。
在這種背景下,我們最后選擇使用 Node.js 搭建專門服務(wù)于前端頁(yè)面呈現(xiàn)的后端,亦即 Backend-For-Frontend,簡(jiǎn)稱 BFF。
我們面臨了很多不同的技術(shù)選型,主要圍繞在權(quán)衡 RESTful API 和 GraphQL。
正如標(biāo)題所示,我們最終選用的是 GraphQL as BFF。
本文將介紹我們對(duì) GraphQL 所作的考察、探索、權(quán)衡、技術(shù)選型與設(shè)計(jì)等多方面的內(nèi)容,希望能給大家?guī)?lái)一些啟發(fā)。
一、GraphQL 模式出現(xiàn)的必然性
面向前端頁(yè)面的數(shù)據(jù)聚合層,其接口很容易在迭代過(guò)程中,變得愈加復(fù)雜;最終發(fā)展成一個(gè)超級(jí)接口。
它有很多調(diào)用方,各種不同的調(diào)用場(chǎng)景,甚至多個(gè)不同版本的接口并存,同時(shí)提供數(shù)據(jù)服務(wù)。
所有這些復(fù)雜性,都會(huì)反映到接口參數(shù)上。
接口調(diào)用的場(chǎng)景越多,它對(duì)接口參數(shù)結(jié)構(gòu)的表達(dá)能力,要求越高。如果只有一個(gè) boolean 類型的參數(shù),只能滿足 true | false 兩種場(chǎng)景罷了。
以產(chǎn)品詳情接口為例,一種很自然的請(qǐng)求參數(shù)結(jié)構(gòu)如下:
里面包含 ChannelCode 渠道信息,IsOp 身份信息,MarketingInfo 營(yíng)銷相關(guān)的信息,PlatformId 平臺(tái)信息,QueryNode 查詢的節(jié)點(diǎn)信息,以及 Version 版本信息。最核心的參數(shù) ProductId,被大量場(chǎng)景相關(guān)的參數(shù)所圍繞。
審視一下 QueryNode 參數(shù),很容易可以發(fā)現(xiàn),它正是 GraphQL 的雛形。只不過(guò)它用的是更復(fù)雜的 JSON 來(lái)描述查詢字段,而 GraphQL 用更簡(jiǎn)潔的查詢語(yǔ)句,完成同樣的目的。
并且,QueryNode 參數(shù),只支持一個(gè)層級(jí)的字段篩選;而 GraphQL 則支持多層級(jí)的篩選。
GraphQL 可以看作是 QueryNode 這種形式的參數(shù)設(shè)計(jì)的專業(yè)化。相比用 JSON 來(lái)描述查詢結(jié)果,GraphQL 設(shè)計(jì)了一個(gè)更完整的 DSL,把字段、結(jié)構(gòu)、參數(shù)等,都整合到一起。
仿照格林斯潘第十定律:
任何C或Fortran程序復(fù)雜到一定程度之后,都會(huì)包含一個(gè)臨時(shí)開發(fā)的、不合規(guī)范的、充滿程序錯(cuò)誤的、運(yùn)行速度很慢的、只有一半功能的Common Lisp實(shí)現(xiàn)。
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
或許可以說(shuō):
任何接口設(shè)計(jì)復(fù)雜到一定程度后,都會(huì)包含一個(gè)臨時(shí)開發(fā)的、不合規(guī)范的、只有一半功能的 GraphQL 實(shí)現(xiàn)。
從 SearchParams, FormData 到 JSON,再到 GraphQL 查詢語(yǔ)句,我們看到不斷有新的數(shù)據(jù)通訊方式出現(xiàn),滿足不同的場(chǎng)景和復(fù)雜度的要求。
站在這個(gè)層面上看,GraphQL 模式的出現(xiàn),有一定的必然性。
二、GraphQL 語(yǔ)言設(shè)計(jì)中的必然性
作為一個(gè)查詢相關(guān)的 DSL,GraphQL 的語(yǔ)言設(shè)計(jì),也不是隨意的。
我們可以做一個(gè)思想實(shí)驗(yàn)。
假設(shè)你是一名架構(gòu)師,你接到一項(xiàng)任務(wù),設(shè)計(jì)一門前端友好的查詢語(yǔ)言。要求:
1)查詢語(yǔ)法跟查詢結(jié)果相近
2)能精確查詢想要的字段
3)能合并多個(gè)請(qǐng)求到一個(gè)查詢語(yǔ)句
4)無(wú)接口版本管理問(wèn)題
5)代碼即文檔
我們知道查詢結(jié)果是 JSON 數(shù)據(jù)格式。而 JSON 是一個(gè) key-value pair 風(fēng)格的數(shù)據(jù)表示,因此可以從結(jié)果倒推出查詢語(yǔ)句。
上圖是一個(gè)查詢結(jié)果。很顯然,它的查詢語(yǔ)句不可能包含 value 部分。我們刪去 value 后,它變成下面這樣。
查詢語(yǔ)句跟查詢結(jié)果擁有相同的 key 及其層次結(jié)構(gòu)關(guān)系。這是我們想要的。
我們可以再進(jìn)一步,將冗余的雙引號(hào),逗號(hào)等部分刪掉。
我們得到了一個(gè)精簡(jiǎn)的寫法,它已經(jīng)是一段合法的 GraphQL 查詢語(yǔ)句了。
其中的設(shè)計(jì)思路和過(guò)程是如此簡(jiǎn)單直接,很難想象還有別的方案比目前這個(gè)更滿足要求。
當(dāng)然,只有字段和層級(jí),并不足夠。符合這種結(jié)構(gòu)的數(shù)據(jù)太多了,不可能把整個(gè)數(shù)據(jù)庫(kù)都查詢出來(lái)。我們還需要設(shè)計(jì)參數(shù)傳遞的方式,以便能縮小數(shù)據(jù)范圍。
上圖是一個(gè)自然而然的做法。用括號(hào)表示函數(shù)調(diào)用,里面可以添加參數(shù),可謂經(jīng)典的設(shè)計(jì)。
它跟 ES2015 里的 (Method Definitions Shorthand) 也高度相似。如下所示:
前面演示的 GraphQL 參數(shù)寫法,參數(shù)值用的是字面量 userId: 123。這不是一個(gè)特別安全的做法,開發(fā)者會(huì)在代碼里,用拼接字符串的方式將字面量值注入到查詢語(yǔ)句,也就給了惡意攻擊者注入代碼的機(jī)會(huì)。
我們需要設(shè)計(jì)一個(gè)參數(shù)變量語(yǔ)法,明確參數(shù)位置和數(shù)量。
我們可以選用 $xxx 這種常見(jiàn)的標(biāo)記方法,它被很多語(yǔ)言采用來(lái)表示變量。沿用這種風(fēng)格,可以大大減少開發(fā)者的學(xué)習(xí)成本。
前后端通訊的另一個(gè)痛點(diǎn)是,命名。前端經(jīng)常吐槽后端的字段名過(guò)于冗長(zhǎng),或者不知所云,或者拼寫錯(cuò)誤,或者不符合前端表述習(xí)慣。最常見(jiàn)的情況是,后端字段名以大寫字母開頭,而前端習(xí)慣 Class 或者 Component 是大寫字母開頭,實(shí)例和數(shù)據(jù),則以小寫字母開頭。
我們期望有機(jī)會(huì)進(jìn)行字段名調(diào)整。
別名映射(Alias)語(yǔ)法,正是為了這個(gè)目的而出現(xiàn)的。
上面這種別名映射的語(yǔ)法,在其它語(yǔ)言里也很常見(jiàn)。如果不這樣寫,頂多就是變成:
uid as Uid 或者 uid = Uid 這類做法,差別不大。我認(rèn)為選用冒號(hào)更佳,它跟 ES2015 的解構(gòu)語(yǔ)法很接近。
至此,我們擁有了 key 層級(jí)結(jié)構(gòu),參數(shù)傳遞,變量寫法,別名映射等語(yǔ)法,可以編寫足夠復(fù)雜的查詢語(yǔ)句了。不過(guò),還有幾個(gè)小欠缺。
比如對(duì)字段的條件表達(dá)。假設(shè)有兩次查詢,它們唯一的差別就是,一個(gè)有 A 字段,另一個(gè)沒(méi)有 A 字段,其它字段及其結(jié)構(gòu)都是相同的。為了這么小的差別 ,前端難道要編寫兩個(gè)查詢語(yǔ)句?
這顯然不現(xiàn)實(shí),我們需要設(shè)計(jì)一個(gè)語(yǔ)法描述和解決這個(gè)問(wèn)題。
它就是——指令(Directive)。
指令,可以對(duì)字段做一些額外描述,比如?
@include,是否包含該字段;
@skip,是否不包含該字段;
@deprecate,是否廢棄該字段;
除了上述默認(rèn)指令外,我們還可以支持自定義指令等功能。
指令的語(yǔ)法設(shè)計(jì),在其它語(yǔ)言里也可以找到借鑒目標(biāo)。Java,Phthon 以及 ESNext 都用了 @ 符號(hào)表示注解、裝飾器等特性。
有了指令,我們可以把兩個(gè)高度相似的查詢語(yǔ)句,合并到一起,然后通過(guò)條件參數(shù)來(lái)切換。這是一個(gè)不錯(cuò)的做法。不過(guò),指令是跟著單個(gè)字段走的,它不能解決多字段的問(wèn)題。
比如,字段 A 和字段 B,擁有相同的總體結(jié)構(gòu),僅僅只有 1 個(gè)字段名的差異。前端并不想編寫一樣的 key 值重復(fù)多次。
這意味著,我們需要設(shè)計(jì)一個(gè)片段語(yǔ)法(Fragment)。
如上所示,用 fragment 聲明一個(gè)片段,然后用三個(gè)點(diǎn)表示將片段在某個(gè)對(duì)象字段里展開。我們可以只編寫一次公共結(jié)構(gòu),然后輕易地在多個(gè)對(duì)象字段里復(fù)用。
這種設(shè)計(jì)也是一個(gè)經(jīng)典做法,跟 JavaScript 里的 Spread Properties 很相近。
至此,我們得到了一個(gè)相對(duì)完整的,對(duì)前端友好的查詢語(yǔ)言設(shè)計(jì)。它幾乎就是 GraphQL 當(dāng)前的形態(tài)。
如你所見(jiàn),GraphQL 的查詢語(yǔ)言設(shè)計(jì),借鑒了主流開發(fā)語(yǔ)言里的眾多成熟設(shè)計(jì)。使得任何擁有豐富的編程經(jīng)驗(yàn)的開發(fā)者,很容易上手 GraphQL。
按照同樣的要求,重新來(lái)一遍,大概率得到跟當(dāng)前形態(tài)高度接近的設(shè)計(jì)。這是我理解的 GraphQL 語(yǔ)言設(shè)計(jì)里包含的必然性。
三、GraphQL 的組成與鏈路
查詢語(yǔ)法,是 GraphQL 面向前端,或者說(shuō)面向數(shù)據(jù)消費(fèi)端的部分。
除此之外,GraphQL 還提供了面向后端,或者說(shuō)面向數(shù)據(jù)提供方的部分。它就是基于 GraphQL 的 Type System 構(gòu)建的 Schema。
一個(gè) GraphQL 服務(wù)和查詢的鏈路,大致如下:
首先,服務(wù)端編寫數(shù)據(jù)類型,構(gòu)建一個(gè)數(shù)據(jù)結(jié)構(gòu)之間的關(guān)聯(lián)網(wǎng)絡(luò)。其中 Query 對(duì)象是數(shù)據(jù)消費(fèi)的入口。所有查詢,都是對(duì) Query 對(duì)象下的字段的查詢。可以把 Query 下的字段,理解為一個(gè)個(gè) RESTful API。比如上圖中的,Query.post 和 Query.author,相當(dāng)于 /post 和 /author 接口。
GraphQL Schema 描述了數(shù)據(jù)的類型與結(jié)構(gòu),但它只是形狀(Shape),它不包含真正的數(shù)據(jù)。我們需要編寫 Resolver 函數(shù),在里面去獲取真正的數(shù)據(jù)。
Resolver 的簡(jiǎn)單形式如下
每個(gè) Query 對(duì)象下的字段,都有一個(gè)取值函數(shù),它能獲取到前端傳遞過(guò)來(lái)的 query 查詢語(yǔ)句里包含的參數(shù),然后以任意方式獲取數(shù)據(jù)。Resolver 函數(shù)可以是異步的。
有了 Resolver 和 Schema,我們既定義了數(shù)據(jù)的形狀,也定義了數(shù)據(jù)的獲取方式。可以構(gòu)建一個(gè)完整的 GraphQL 服務(wù)。
但它們只是類型定義和函數(shù)定義,如果沒(méi)有調(diào)用函數(shù),就不會(huì)產(chǎn)生真正的數(shù)據(jù)交互。
前端傳遞的 query 查詢語(yǔ)句,正是觸發(fā) Resolver 調(diào)用的源頭。
如上所示,我們發(fā)起了查詢,傳遞了參數(shù)。GraphQL 會(huì)解析我們的查詢語(yǔ)句,然后跟 Schema 進(jìn)行數(shù)據(jù)形狀的驗(yàn)證,確保我們查詢的結(jié)構(gòu)是存在的,參數(shù)是足夠的,類型是一致的。任何環(huán)節(jié)出現(xiàn)問(wèn)題,都將返回錯(cuò)誤信息。
數(shù)據(jù)形狀驗(yàn)證通過(guò)后,GraphQL 將會(huì)根據(jù) query 語(yǔ)句包含的字段結(jié)構(gòu),一一觸發(fā)對(duì)應(yīng)的 Resolver 函數(shù),獲取查詢結(jié)果。也就是說(shuō),如果前端沒(méi)有查詢某個(gè)字段,就不會(huì)觸發(fā)該字段對(duì)應(yīng)的 Resolver 函數(shù),也就不會(huì)產(chǎn)生對(duì)數(shù)據(jù)的獲取行為。
此外,如果 Resolver 返回的數(shù)據(jù),大于 Schema 里描繪的結(jié)構(gòu);那么多出來(lái)的部分將被忽略,不會(huì)傳遞給前端。這是一個(gè)合理的設(shè)計(jì)。我們可以通過(guò)控制 Schema,來(lái)控制前端的數(shù)據(jù)訪問(wèn)權(quán)限,防止意外的將用戶賬號(hào)和密碼泄露出去。?
正是如此,GraphQL 服務(wù)能實(shí)現(xiàn)按需獲取數(shù)據(jù),精確傳遞數(shù)據(jù)。
四、澄清關(guān)于 GraphQL 的幾個(gè)迷思
有相當(dāng)多的開發(fā)者,對(duì) GraphQL 有各種各樣的誤解。在這里挑選幾個(gè)重要的例子,加以澄清,幫助大家更全面的認(rèn)識(shí) GraphQL。
4.1 GraphQL 不一定要操作數(shù)據(jù)庫(kù)?
有一些開發(fā)者認(rèn)為 GraphQL 需要操作數(shù)據(jù)庫(kù),因此實(shí)現(xiàn)起來(lái),幾乎等于要推翻當(dāng)前后端的所有架構(gòu)。這是一個(gè)重大誤解。
GraphQL 不僅可以不操作數(shù)據(jù)庫(kù),它甚至可以不從其它地方獲取數(shù)據(jù),而直接寫死數(shù)據(jù)在 Resolver 函數(shù)里。查看 graphql.js 的官方文檔,我們輕易可以找到案例:
上圖定義了一個(gè) schema,只有一個(gè)類型為 String 的 hello 字段,它的 resolver 函數(shù)里,無(wú)視所有參數(shù),直接 return 一個(gè) hello world 字符串。
可以看到,GraphQL 只是關(guān)于 schema 和 resolver 的一一對(duì)應(yīng)和調(diào)用,它并未對(duì)數(shù)據(jù)的獲取方式和來(lái)源等做任何假設(shè)。
4.2?GraphQL 跟 RESTful API 不是對(duì)立的
在網(wǎng)絡(luò)上,有相當(dāng)多的 GraphQL 文章,將它跟 RESTful API 對(duì)立起來(lái),仿佛要么全盤 GraphQL,要么全盤 RESTful API。這也是一個(gè)重大誤解。
GraphQL 和 RESTful API 不僅不對(duì)立,還是互相協(xié)作的關(guān)系。
在前面關(guān)于 Resolver 函數(shù)的圖片中,我們看到,可以在 GraphQL Schema 的 Resolver 函數(shù)里,調(diào)用 RESTful API 去獲取數(shù)據(jù)。
當(dāng)然,也可以調(diào)用 RPC 或者 ORM 等方式,從別的數(shù)據(jù)接口或者數(shù)據(jù)庫(kù)里獲取數(shù)據(jù)。
因此,實(shí)現(xiàn)一個(gè) GraphQL 服務(wù),并不需要挑戰(zhàn)當(dāng)前整個(gè)后端體系。它具有高度靈活的適配能力,可以低侵入性的嵌入當(dāng)前系統(tǒng)中。
4.3?GraphQL 不一定是一個(gè)后端服務(wù)
盡管絕大多數(shù) GraphQL,都以 server 的形式存在。?但是,GraphQL 作為一門語(yǔ)言,它并沒(méi)有限制在后端場(chǎng)景。
上圖還是前面展示過(guò)的 graphql.js 的官方文檔,最下面一行,就是一個(gè)普通的函數(shù)調(diào)用,它發(fā)起了一次 graphql 查詢,其 response 結(jié)果如下:
這段代碼,不只能在 node.js 里運(yùn)行,在瀏覽器里也可以運(yùn)行(可訪問(wèn):https://codesandbox.io/s/hidden-water-zfq2t?查看運(yùn)行結(jié)果)
因此,我們完全可以將 GraphQL 用在純前端,去實(shí)現(xiàn) State Management 狀態(tài)管理。Relay 等框架,即包含了用在前端的 graphql。
4.4 GraphQL 不一定需要?Schema
這是一個(gè)有趣的事實(shí),GraphQL 語(yǔ)言設(shè)計(jì)里的兩個(gè)組成部分:
1)數(shù)據(jù)提供方編寫 GraphQL Schema;
2)數(shù)據(jù)消費(fèi)方編寫 GraphQL Query;
這種組合,是官方提供的最佳實(shí)踐。但它并不是一個(gè)實(shí)踐意義上的最低配置。
GraphQL Type System 是一個(gè)靜態(tài)的類型系統(tǒng)。我們可以稱之為靜態(tài)類型 GraphQL。此外,社區(qū)還有一種動(dòng)態(tài)類型的 GraphQL 實(shí)踐。
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
它跟靜態(tài)類型的 GraphQL 差別在于,沒(méi)有了基于 Schema 的數(shù)據(jù)形狀驗(yàn)證階段,而是直接無(wú)腦地根據(jù) query 查詢語(yǔ)句里的字段,去觸發(fā) Resolver 函數(shù)。
它也不管 Resolver 函數(shù)返回的數(shù)據(jù)類型對(duì)不對(duì),獲取到什么,就是什么。一個(gè)字段,不必先定義好,才能被前端消費(fèi),它可以動(dòng)態(tài)的計(jì)算出來(lái)。
在某些場(chǎng)景下,動(dòng)態(tài)類型的 GraphQL 有一定的便利性。不過(guò),它同時(shí)喪失了 GraphQL 的部分精髓,這塊后面將會(huì)詳細(xì)描述。
值得一提的是,不管是靜態(tài)類型的 GraphQL 還是動(dòng)態(tài)類型的 GraphQL,都是既可以運(yùn)行在服務(wù)端,也可以運(yùn)行在前端。
4.5?GraphQL 不一定返回 JSON?數(shù)據(jù)格式
這是另一個(gè)有趣的事實(shí)。最初我們演示了,如何基于 JSON 數(shù)據(jù)結(jié)果,反推出 GraphQL 查詢語(yǔ)法的設(shè)計(jì)。而現(xiàn)在,我們卻說(shuō) GraphQL 可以不返回 JSON 數(shù)據(jù)格式。
沒(méi)錯(cuò)。當(dāng)一個(gè)新事物出現(xiàn)之后,隨著它的不斷發(fā)展,它可以脫離其初衷,衍生出不同的形態(tài)。
上圖還是來(lái)自 graphql-anywhere 里的例子。
在這里,它實(shí)現(xiàn)了一個(gè) gqlToReact 的 Resolver,可以把一個(gè) graphql 查詢轉(zhuǎn)換為 ReactElement 結(jié)構(gòu)。
不只是動(dòng)態(tài)類型的 GraphQL 有這個(gè)能力,靜態(tài)類型的 GraphQL 也有可能實(shí)現(xiàn)一樣的效果。
不過(guò)這種做法,目前僅僅停留在能力演示階段。其妙用還有待社區(qū)去挖掘和探索。
五、GraphQL 的幾種使用模式
到目前為止,我們見(jiàn)識(shí)到了 GraphQL 的高自由度和靈活性。在搭建 GraphQL Server 時(shí),也可以根據(jù)實(shí)際需求和場(chǎng)景,采用不同的模式。
5.1 RESTful-Like 模式
這個(gè)模式就是簡(jiǎn)單粗暴的把 RESTful API 服務(wù),替換成 GraphQL 實(shí)現(xiàn)。之前有多少 RESTful 服務(wù),重構(gòu)后就有多少 GraphQL 服務(wù)。它是一個(gè)簡(jiǎn)單的一對(duì)一關(guān)系。
默認(rèn)情況下,面向兩個(gè) GraphQL 服務(wù)發(fā)起的查詢是兩次請(qǐng)求,而不是一次。舉個(gè)例子:
前端需要產(chǎn)品數(shù)據(jù)時(shí),從之前調(diào)用產(chǎn)品相關(guān)的 RESTful API,變成查詢產(chǎn)品相關(guān)的 GraphQL。不過(guò),需要訂單相關(guān)的數(shù)據(jù)時(shí),可能要查詢另一個(gè) GraphQL 服務(wù)。
有一些公司拿 GraphQL 小試牛刀時(shí),采取了這個(gè)做法;將 GraphQL 用在特定服務(wù)里。
不過(guò),這種模式難以發(fā)揮 GraphQL 合并請(qǐng)求和關(guān)聯(lián)請(qǐng)求的能力。只是起到了按需查詢,精確查詢字段的作用,價(jià)值有限。
因此,他們?cè)趯?shí)踐后,發(fā)現(xiàn)收效甚微;認(rèn)為 GraphQL 不過(guò)如此,還不如 RESTful API 架構(gòu)簡(jiǎn)單和成熟。
其實(shí)這是一種選型上的失誤。
5.2 GraphQL as an API Gateway 模式
在這個(gè)模式里,GraphQL 接管了前端的一整塊數(shù)據(jù)查詢需求。
前端不再直接調(diào)用具體的 RESTful 等接口,而是通過(guò) GraphQL 去間接獲取產(chǎn)品、訂單、搜索等數(shù)據(jù)。
在 GraphQL 這個(gè)中間層里,我們將各個(gè)微服務(wù),按照它們的數(shù)據(jù)關(guān)聯(lián),整合成一個(gè)基于 GraphQL Schema 的數(shù)據(jù)關(guān)系網(wǎng)絡(luò)。前端可以通過(guò) GraphQL 查詢語(yǔ)句,同時(shí)發(fā)起對(duì)多個(gè)微服務(wù)的數(shù)據(jù)的獲取、篩選、裁剪等行為。
值得一提的是,作為 API Gateway 的 GraphQL 服務(wù),可以在其 Resolver 內(nèi),向前面提到的 RESTful-like 的 GraphQL 發(fā)起查詢請(qǐng)求。
如此,既避免了前端需要一對(duì)多的問(wèn)題,也解決了 API Gateway GraphQL 需要請(qǐng)求 RESTful 全量數(shù)據(jù)接口的內(nèi)部冗余問(wèn)題。讓服務(wù)到服務(wù)之間的數(shù)據(jù)調(diào)用,也可以做到更精確。
GraphQL 服務(wù)是一個(gè)對(duì)數(shù)據(jù)消費(fèi)方友好的模式。而數(shù)據(jù)消費(fèi)方,既可以是前端,也可以是其它服務(wù)。
當(dāng)數(shù)據(jù)消費(fèi)方是其它服務(wù)時(shí),通過(guò) GraphQL 查詢語(yǔ)句,彼此之間可以更精確獲取數(shù)據(jù),避免冗余的數(shù)據(jù)傳輸和接口調(diào)用。
當(dāng)數(shù)據(jù)消費(fèi)方是前端時(shí),由于前端需要跟多個(gè)數(shù)據(jù)提供方打交道,如果每個(gè)數(shù)據(jù)提供方都是單獨(dú)的 GraphQL,那并不能得到本質(zhì)上的改善。此時(shí)若有一個(gè) Gateway 角色的 GraphQL,可以真正減少前端調(diào)用的復(fù)雜度。
5.2.1 兩類 GraphQL API Gateway 服務(wù)
同樣是 API Gateway 角色的 GraphQL 服務(wù),在實(shí)現(xiàn)方式上也有不同的分類。
1)包含大量真實(shí)的數(shù)據(jù)操作和處理的 GraphQL
2)轉(zhuǎn)發(fā)數(shù)據(jù)請(qǐng)求,聚合數(shù)據(jù)結(jié)果的 GraphQL
第一類,是傳統(tǒng)意義上的后端服務(wù);第二類,則是我們今天的重點(diǎn),GraphQL as BFF。
這兩類 GraphQL 服務(wù)的要求是不同的,前者可能包含大量 CPU 密集的計(jì)算,而后者總體而言主要是 Network I/O 相關(guān)的行為。
許多公司并不提倡使用 Node.js 構(gòu)建第一種服務(wù),不管是構(gòu)建 RESTful 還是 GraphQL。我們也一樣。
因此,后面我們討論的 GraphQL,如果沒(méi)有特別聲明,都可以理解為上面所說(shuō)的第二種類型。
5.3 GraphQL as a Backend Framework
在澄清關(guān)于 GraphQL 的迷思時(shí),我們指出,GraphQL 可以不作為 Server。
這意味著,一個(gè)包含 GraphQL 實(shí)現(xiàn)的 Server,不一定通過(guò) GraphQL 查詢語(yǔ)句進(jìn)行前后端數(shù)據(jù)交互,它可以繼續(xù)沿用 RESTful API 風(fēng)格。
也就是說(shuō),我們可以把 GraphQL 當(dāng)作一個(gè)服務(wù)端開發(fā)框架,然后在 RESTful 的各個(gè)接口里,發(fā)起 graphql 查詢。
不管是前端還是其它后端服務(wù),都不必知道 GraphQL 的存在。前端的調(diào)用方式,還是 RESTful API,在 RESTful 服務(wù)內(nèi)部,它自己向自己發(fā)起了 GraphQL 查詢。
那么,這個(gè)模式有什么好處跟價(jià)值?
設(shè)想一下,你用 RESTful API 風(fēng)格實(shí)現(xiàn) BFF。由于 PC 端和移動(dòng)端的場(chǎng)景不同,它們對(duì)同一份數(shù)據(jù)的消費(fèi)方式差異很大。
在 PC 端,它可以一次請(qǐng)求全量數(shù)據(jù)。
在移動(dòng)端,因?yàn)樗聊恍?#xff0c;它要分多次去請(qǐng)求數(shù)據(jù)。首屏一次,非首屏一次,滾動(dòng)按需加載 N 次,多個(gè) 2 級(jí)頁(yè)面里 M 次。
我們要么實(shí)現(xiàn)一個(gè)超級(jí)接口,根據(jù)請(qǐng)求參數(shù)適配不同場(chǎng)景(即實(shí)現(xiàn)一個(gè)半吊子的 GraphQL);要么實(shí)現(xiàn)多個(gè)功能相似,但又不同的 RESTful 接口。
其中的差異太大了,所以很多公司索性就把 BFF 分成,PC-BFF 和 Mobile-BFF 兩個(gè) BFF 服務(wù)。
我們可以把 PC-BFF 和 Mobile-BFF 整合成一個(gè) GraphQL-BFF 服務(wù)。即便前后端不通過(guò) GraphQL 查詢語(yǔ)句進(jìn)行交互,我們也可以在各個(gè)接口里,編寫相對(duì)簡(jiǎn)單的查詢語(yǔ)句,代替更高成本的接口實(shí)現(xiàn)。
也即是說(shuō),使用 GraphQL 搭建 BFF,如果出現(xiàn)前后端分工、溝通等方面的矛盾。我們可以將 GraphQL 服務(wù)降級(jí)為 RESTful 服務(wù),無(wú)非就是把需要前端編寫的查詢語(yǔ)句,寫死在后端接口里面罷了。
如果實(shí)現(xiàn)的是 RESTful 服務(wù),要轉(zhuǎn)換成 GraphQL 服務(wù),就沒(méi)有那么簡(jiǎn)單了。
有了這種優(yōu)雅降級(jí)的能力,我們可以更加放心大膽的推動(dòng) GraphQL-BFF 方案。
六、GraphQL 精髓
理解 GraphQL 的精髓所在,可以幫助我們更正確地實(shí)踐 GraphQL。
首先來(lái)想一下,GraphQL 為什么要叫 GraphQL,其中的 Graph 體現(xiàn)在什么地方?
GraphQL 的查詢語(yǔ)句,看起來(lái)是 JSON 寫法的一種簡(jiǎn)化。而 JSON 是一個(gè) Tree 樹形數(shù)據(jù)結(jié)構(gòu)。為什么不叫 TreeQL,而是 GraphQL 呢?
6.1?Tree VS Graph
一個(gè)重要的前置知識(shí)是,什么是 Tree,什么是 Graph,它們有什么關(guān)系?
下圖是一個(gè) Tree 的結(jié)構(gòu)示意圖。
Tree 有且只有一個(gè) Root 節(jié)點(diǎn),并且對(duì)于每個(gè)非 Root 節(jié)點(diǎn),有且只有一個(gè)父節(jié)點(diǎn);它們組成了一個(gè)層次結(jié)構(gòu)。其中任意兩個(gè)節(jié)點(diǎn),有且只有一條連接路徑;沒(méi)有循環(huán),也沒(méi)有遞歸引用。
下圖是一個(gè) Graph 的結(jié)構(gòu)示意圖。
而 Graph 里的節(jié)點(diǎn)之間,可能存在不只一種連接路徑,可能存在循環(huán),可能存在遞歸引用,可能沒(méi)有 Root 節(jié)點(diǎn)。它們組成了一個(gè)網(wǎng)絡(luò)結(jié)構(gòu)。
我們可以把 Graph 這種網(wǎng)絡(luò)結(jié)構(gòu),通過(guò)裁剪連接路徑,把它壓縮成任意節(jié)點(diǎn)只有唯一連接路徑的簡(jiǎn)化形態(tài)。如此網(wǎng)絡(luò)結(jié)構(gòu)退化成層次結(jié)構(gòu),它變成了 Tree。
也就是說(shuō),Graph 是比 Tree 更復(fù)雜的數(shù)據(jù)結(jié)構(gòu),后者是它的簡(jiǎn)化形式。擁有 Graph,我們可以按照不同的裁剪方式,衍生出不同的 Tree。而 Tree 里包含的信息,如果不增加其它額外數(shù)據(jù),不足以構(gòu)建足夠復(fù)雜的 Graph 結(jié)構(gòu)。
6.2?GraphQL 里的 Graph 結(jié)構(gòu)
在 GraphQL 里,承擔(dān)構(gòu)建網(wǎng)絡(luò)結(jié)構(gòu)的,并非 GraphQL 查詢語(yǔ)句,而是基于 GraphQL Type System 構(gòu)建的 Schema。
上圖是一個(gè) GraphQL Schema,定義了 A, B, C, D 和 E 五種數(shù)據(jù)類型,它們分別掛載到?入口類型 Query 里的 a, b, c, d 和 e 字段里。
A, B, C, D, E 里面,包含著遞歸的結(jié)構(gòu)。A 里面包含 B 和 C,B 里面包含 C 和 D,D 里面包含 E,E 里面又包含 A,又回到了 A。
這是一個(gè)復(fù)雜的關(guān)系網(wǎng)絡(luò)。要構(gòu)建遞歸關(guān)聯(lián),并不需要這么復(fù)雜。直接 A 里包含 B,和 B 里包含 A 也行,此處是一個(gè)演示。
有了這個(gè)基于數(shù)據(jù)類型的 Graph 關(guān)系網(wǎng)絡(luò),我們可以實(shí)現(xiàn)從 Graph 中派生出 JSON Tree 的能力。
上圖是一個(gè) GraphQL 的查詢語(yǔ)句,它是一個(gè)包含很多 key 的層次結(jié)構(gòu),亦即一個(gè) Tree。
它從根節(jié)點(diǎn)里取 a 字段,然后向下分層,找到了 e。而 e 節(jié)點(diǎn)里也包含一個(gè)跟根節(jié)點(diǎn)同類型的 a 字段,因此它可以繼續(xù)向下分層,重來(lái)一遍,又到了 e 節(jié)點(diǎn),此時(shí)它只取了 data 字段,查詢中止。
我編寫了一個(gè)簡(jiǎn)單的 Resolver 函數(shù),用來(lái)演示查詢結(jié)果。
它很簡(jiǎn)單。Query 里返回跟字段名一樣的字母,任何子節(jié)點(diǎn)的數(shù)據(jù),都是拼接父節(jié)點(diǎn)的字母串。如此我們可以從查詢結(jié)果看出數(shù)據(jù)流動(dòng)的層次。
查詢結(jié)果如下:
第一個(gè) e 節(jié)點(diǎn)的 data 字段里,拿到了父節(jié)點(diǎn)里的 data 數(shù)據(jù),其父節(jié)點(diǎn)的 data 數(shù)據(jù)又是通過(guò)它的父節(jié)點(diǎn)里獲取的,因此有一個(gè)數(shù)據(jù)鏈條。
而第二個(gè) e 節(jié)點(diǎn)同理,它有兩段鏈條。
只要不編寫后續(xù)字段,我們可以停留在任意節(jié)點(diǎn)的 data 字段里。
也就是說(shuō),我們用作為 Tree 的 Query 語(yǔ)句,去裁剪了作為 Graph 的 Schema 數(shù)據(jù)關(guān)聯(lián)網(wǎng)絡(luò),得到了我們想要的 JSON 結(jié)構(gòu)。
通過(guò)這個(gè)角度,我們可以理解為什么 GraphQL 不允許 Query 語(yǔ)句停留在 Object 類型,一定要明確的寫出對(duì)象內(nèi)部的字段,直到所有 Leaf Node 都是 Scalar 類型。
這不僅僅是一個(gè)所謂的最佳實(shí)踐,這也是 Graph 本身的特征。對(duì)象節(jié)點(diǎn)里,可能通過(guò)循環(huán)或者遞歸關(guān)系,拓展出無(wú)限大的數(shù)據(jù)結(jié)構(gòu)。Query 語(yǔ)句必須寫清楚,才能幫助 GraphQL 去裁剪掉不必要的數(shù)據(jù)關(guān)聯(lián)路徑。
6.3?Graph 網(wǎng)絡(luò)結(jié)構(gòu)的實(shí)際價(jià)值
前面的 A, B, C, D, E 案例,并不能直觀的讓大家感受到,Graph 網(wǎng)絡(luò)結(jié)構(gòu)的實(shí)際價(jià)值。它看起來(lái)像一個(gè)連線游戲。
放到 Facebook 的社交網(wǎng)絡(luò)場(chǎng)景下,其必要性和價(jià)值就凸顯了。
假設(shè)我們要一次性獲取用戶的好友的好友的好友的好友的好友,基于 RESTful API 我們有什么特別好的方法嗎?很難說(shuō)。
而 Graph 這種遞歸關(guān)聯(lián)的結(jié)構(gòu),實(shí)現(xiàn)這種查詢輕而易舉。
我們定義了一個(gè) User 類型,掛到 Query 入口上的 user 字段里。User 類型的 friends 字段又是一個(gè) User 類型的列表。這樣就構(gòu)建了一個(gè)遞歸關(guān)聯(lián)。
getFriends 查詢語(yǔ)句,可以不斷地從任意用戶開始,關(guān)聯(lián)其 friends,得到 friends 數(shù)組結(jié)果。任意一個(gè) friend 也是 User,它也有自己的 friends。查詢語(yǔ)句在最外層的 friends 停了下來(lái),它只查詢了 id 和 name 字段。
看到這里,另一個(gè)經(jīng)典的關(guān)于 GraphQL 的誤解出現(xiàn)了:只有像 Facebook,Twitter 這類社交關(guān)系網(wǎng)絡(luò),才適合 GraphQL,而我們的場(chǎng)景下,GraphQL 并不適用。
其實(shí)不然,社交關(guān)系網(wǎng)絡(luò)里使用 GraphQL 特別有效,不意味著其它場(chǎng)景下,GraphQL 不能帶來(lái)收益。
設(shè)想一個(gè)電商平臺(tái)的場(chǎng)景,它有用戶、產(chǎn)品和訂單這組鐵三角,其它庫(kù)存、價(jià)格,優(yōu)惠券,收藏等先不提。在最簡(jiǎn)單的場(chǎng)景下,GraphQL 依然可以發(fā)揮作用。
我們構(gòu)建了 User,Product 和 Order 三個(gè)類型,它們彼此之間有字段上的遞歸關(guān)聯(lián)關(guān)系,是一個(gè) Graph 結(jié)構(gòu)。在 Query 入口類型上,分別有 user, product 和 order 三個(gè)字段。
據(jù)此,我們可以實(shí)現(xiàn)從 user, product 和 order 任意維度出發(fā),通過(guò)它們的關(guān)聯(lián)關(guān)系,實(shí)現(xiàn)豐富而靈活的查詢。
比如,查看用戶的所有訂單及其跟訂單相關(guān)的產(chǎn)品,Query 語(yǔ)句如下:
我們查詢了 id 為 123 的用戶,他的名字和訂單列表,對(duì)于每個(gè)訂單,我們獲取該訂單的創(chuàng)建時(shí)間,購(gòu)買價(jià)格和關(guān)聯(lián)產(chǎn)品,對(duì)于訂單關(guān)聯(lián)的產(chǎn)品,我們獲取了產(chǎn)品 id,產(chǎn)品標(biāo)題,產(chǎn)品描述和產(chǎn)品價(jià)格。
當(dāng)我們的后端人員組織架構(gòu)是按照領(lǐng)域模型來(lái)劃分時(shí),用戶,產(chǎn)品和訂單,通常是 3 個(gè)團(tuán)隊(duì),他們各自提供領(lǐng)域相關(guān)的接口。通過(guò) GraphQL 我們可以很容易將它們整合到一起。
再比如,查看一個(gè)產(chǎn)品下的所有訂單及其關(guān)聯(lián)用戶,Query 語(yǔ)句如下:
我們查詢了 id 為 123 的產(chǎn)品,它的產(chǎn)品標(biāo)題,產(chǎn)品描述和價(jià)格,以及關(guān)聯(lián)的訂單。對(duì)于每個(gè)關(guān)聯(lián)訂單,我們查詢了訂單的創(chuàng)建時(shí)間,購(gòu)買價(jià)格以及下訂單的用戶,對(duì)于下訂單的用戶,我們查詢了他的用戶 id 和名稱。
如你所見(jiàn),只要構(gòu)建出了 Graph 結(jié)構(gòu)的數(shù)據(jù)網(wǎng)絡(luò),它不像 Tree 那樣有唯一的 Root 節(jié)點(diǎn)。從任意入口出發(fā),它都可以通過(guò)關(guān)聯(lián)路徑,不斷的衍生出數(shù)據(jù),得到 JSON 結(jié)果。
我們不必疲于編寫面向產(chǎn)品詳情頁(yè)的接口,面向訂單詳情頁(yè)的接口,面向用戶信息的接口。我們編寫了一個(gè)數(shù)據(jù)關(guān)系網(wǎng)絡(luò),就足以適配不同的場(chǎng)景。
此處演示的,只是用戶,產(chǎn)品和訂單這三個(gè)資源的關(guān)系網(wǎng)絡(luò),已經(jīng)可以看出 GraphQL 的適用性。在實(shí)際場(chǎng)景中,我們能搭建出更復(fù)雜的數(shù)據(jù)網(wǎng)絡(luò),它具備更強(qiáng)大的數(shù)據(jù)表達(dá)能力,可以給我們的業(yè)務(wù)帶來(lái)更多收益。
七、我們的 GraphQL-BFF 實(shí)踐模式
在掌握上述關(guān)于 GraphQL 的綱領(lǐng)知識(shí)后,我們來(lái)看一下在實(shí)踐中 ,GraphQL-BFF 的一種實(shí)際做法。
首先是技術(shù)選型,我們主要采用了如下技術(shù)棧。
開發(fā)語(yǔ)言選用了 TypeScript,跑在 Node.js v10.x 版本上,服務(wù)端框架是 Koa v2.x 版本,使用 apollo-server-koa 模塊去運(yùn)行 GraphQL 服務(wù)。
Apollo-GraphQL 是 Node.js 社區(qū)里,比較知名和成熟的 GraphQL 框架。做了很多的細(xì)節(jié)工作,也有一些相對(duì)前沿的探索,比如 Apollo Federation 架構(gòu)等。
不過(guò),有兩點(diǎn)值得一提:
1)Apollo-GraphQL 屬于 GraphQL 社區(qū)的一部分,而非 Facebook 官方的 GraphQL 開發(fā)團(tuán)隊(duì)。Apollo-GraphQL 在官方 GraphQL 的基礎(chǔ)上進(jìn)行了帶有他們自身理念特點(diǎn)的封裝和設(shè)計(jì)。像 Apollo Federation 這類目前看來(lái)比較激進(jìn)的方案,即使是 GraphQL 官方的開發(fā)人員,對(duì)此也持保留態(tài)度。
2)Apollo-GraphQL 的重心是前文所說(shuō)的第一類 API Gateway 角色的 GraphQL 服務(wù),本文探討的是第二類。因此,Apollo-GraphQL 里有很多功能對(duì)我們來(lái)說(shuō)沒(méi)必要,有一些功能的使用方式,跟我們的場(chǎng)景也不契合。
我們主要使用的是 Apollo-GraphQL 的 graphql-tools 和 apollo-server-koa 兩個(gè)模塊,并在此基礎(chǔ)上,進(jìn)行了符合我們場(chǎng)景的設(shè)計(jì)和改編。
7.1?我們的 GraphQL-BFF 架構(gòu)設(shè)計(jì)
GraphQL-BFF 的核心思路是,將多個(gè) services 整合成一個(gè)中心化 data graph。
每個(gè) service 的數(shù)據(jù)結(jié)構(gòu)契約,都放入了一個(gè)大而全的 GraphQL Schema 里;如果不做任何模塊化和解耦,開發(fā)體驗(yàn)將會(huì)非常糟糕。每個(gè)團(tuán)隊(duì)成員,都去修改同一份 Schema 文件。
這明顯是不合理的。GraphQL-BFF 的開發(fā)模式,應(yīng)該跟 service 的領(lǐng)域模型,有一一對(duì)應(yīng)的關(guān)系。然后通過(guò)某種形式,多個(gè) services 自然整合到一起。
因此,我們?cè)O(shè)計(jì)了 GraphQL-Service 的概念。
7.1.1?GraphQL-Service
GraphQL-Service 是一個(gè)由 Schema + Resolver 兩部分組成的 JS 模塊,它對(duì)應(yīng)基于領(lǐng)域模型的后端的某個(gè) Servcie。每個(gè) GraphQL-Service 應(yīng)該可以按照模塊化的方式編寫,跟其它 GraphQL-Service 組合起來(lái)后,構(gòu)建出更大的 GraphQL-Server。
GraphQL-Service 通過(guò) GraphQL 的 Type Extensions 構(gòu)建數(shù)據(jù)關(guān)聯(lián)關(guān)系。
如上所示,我們的 UserService 里面,只涉及到了 User 相關(guān)的類型處理。它定義了自己的基本字段,id 和 name。通過(guò) extend type 定義了它在 Order 和 Product 數(shù)據(jù)里的關(guān)聯(lián)字段,以及定義在 Query 里的入口字段。
從 User Schema 里我們可以看到,User 有兩類查詢路徑。
1)通過(guò)根節(jié)點(diǎn) Query 以傳遞參數(shù)的方式,獲取到 User 信息。
2)通過(guò) Product 或 Order 節(jié)點(diǎn),以數(shù)據(jù)關(guān)聯(lián)的方式,獲取到 User 信息。
上圖是 OrderService 的 Schema,它也只涉及了 Order 相關(guān)的類型邏輯。同樣是通過(guò) extend type 定義了在 User 和 Product 里的關(guān)聯(lián)字段,以及定義了在根節(jié)點(diǎn) Query 里的入口字段。
Order 數(shù)據(jù)跟 User 一樣,有兩種消費(fèi)路徑。一種通過(guò) Query 節(jié)點(diǎn),另一種是通過(guò)數(shù)據(jù)關(guān)聯(lián)節(jié)點(diǎn)。
前面我們演示 User, Order 和 Product 鐵三角關(guān)系時(shí),是在同一個(gè) Schema 里編寫它們的關(guān)聯(lián)。我們把多個(gè) GraphQL-Service 的 Schema 整合到一起后,可以生成同樣的結(jié)果:
上圖不是我們手動(dòng)編寫的,而是 merge 多個(gè) GraphQL-Service 的 Schema 后生成的結(jié)果。可以看出來(lái),跟之前手寫的版本,總體上是一樣的。
有了解耦的 Schema 并不足夠,它只定義了數(shù)據(jù)類型及其關(guān)聯(lián)。我們需要 Resolver 去定義數(shù)據(jù)的具體獲取方式,Resolver 也需要解耦。
7.1.2?GraphQL-Resolver
不管是在官方的 GraphQL 文檔里,還是 Apollo-GraphQL 的文檔里,Resolver 都是以普通函數(shù)的形態(tài)出現(xiàn)。
這在簡(jiǎn)單場(chǎng)景下,沒(méi)有什么問(wèn)題。正如在簡(jiǎn)單場(chǎng)景下,用 node.js 的 http.createServer 就可以創(chuàng)建一個(gè) server。
如上,設(shè)置狀態(tài)碼,設(shè)置響應(yīng)的 Content-Type,返回內(nèi)容即可。
然而,在更復(fù)雜的真實(shí)項(xiàng)目中,我們實(shí)際上需要 express、koa 等服務(wù)端框架,用中間件的模式編寫我們的服務(wù)端處理邏輯,由框架將它們整合為一個(gè)requestListener 函數(shù),注冊(cè)到 http.createServer(requestListener) 里。
在 GraphQL Server 里,雖然 endpoint 只有 /graphql 一個(gè),但不代表它只需要一組 Koa 中間件。
正如一開始我們指出的,每個(gè)超級(jí)接口里都包含一半功能的 GraphQL 實(shí)現(xiàn)。GraphQL 是往超級(jí)接口的方向更進(jìn)一步,不能簡(jiǎn)單地以普通接口的眼光去看待它。
在 Query 下的每個(gè)字段,都可能對(duì)應(yīng) 1 到多個(gè)內(nèi)部服務(wù)的 API 的調(diào)用和處理。只用普通函數(shù)構(gòu)成的 resolverMap,不足以充分表達(dá)其中的邏輯復(fù)雜度。
不管是用 endpoint 來(lái)表示資源,還是用 GraphQL Field 字段來(lái)表示資源,它們只是外在形式略有不同,不會(huì)改變業(yè)務(wù)邏輯的復(fù)雜度。
因此,采用比普通函數(shù)具有更好的表達(dá)能力的中間件,組合出一個(gè)個(gè) Resolver,再整合到一個(gè) ResolverMap 里。可以更好的解決之前解決不了,或者很難的問(wèn)題。
所謂的架構(gòu)能力,體現(xiàn)在理解我們面對(duì)的問(wèn)題的復(fù)雜度及其本質(zhì)特征,并能選擇和設(shè)計(jì)出合適的程序表達(dá)模型。
后面我們將演示,正確的架構(gòu),如何輕易地克服之前難以解決的問(wèn)題。
7.1.3?用 koa-compose 組織我們的 Resolver
或許很多同學(xué)并不清楚,express 或 koa 里的中間件模式,可以脫離作為服務(wù)端框架的它們而單獨(dú)使用。正如 GraphQL 可以單獨(dú)不作為 server,在任意支持 JavaScript 運(yùn)行的地方使用一樣。
我們將使用 koa-compose 這個(gè) npm 模塊,去構(gòu)造我們的 Resolver。
前文里提到的 gql 函數(shù),接受一個(gè) Schema 返回一個(gè) GraphQL-Service,每個(gè) GraphQL-Service 都有一個(gè) resolve 方法:
resolve 方法,接受兩個(gè)參數(shù)。第一個(gè)是 typeName,對(duì)應(yīng) GraphQL-Schema 里的 Object Type 的類型名稱;第二個(gè)是 fieldHandlers,每個(gè) handler 支持中間件模式,最終它們將被 koa-compose 整合成一個(gè) Resolver。
以 UserService 為例,其 Resolver 寫起來(lái)如下:
作為普通函數(shù)的 Resolver 接收的所有參數(shù),都被整合到了 ctx 里面。ctx.result 則是該字段的最終輸出,類似于 koa server 里的 ctx.body。我們刻意采用了 ctx.result 這個(gè)不同于 ctx.body 的屬性,明確區(qū)分我們處理的是一個(gè)接口還是一個(gè)字段。
在簡(jiǎn)單場(chǎng)景下,中間件模式的 Resolver 跟普通函數(shù)的 Resolver,僅僅是參數(shù)的數(shù)量和返回值的方式不同。并不會(huì)增加大量的代碼復(fù)雜度。
當(dāng)我們多個(gè)字段要復(fù)用相同的邏輯時(shí),編寫成中間件,然后將 handler 變成數(shù)組形式即可。(在代碼里我們用 json 模擬了數(shù)據(jù)庫(kù)表,所以是同步代碼,實(shí)際項(xiàng)目里,它可以是異步的調(diào)用接口或者查詢數(shù)據(jù)庫(kù))。
上面的 logger,只是一個(gè)簡(jiǎn)單案例。除此之外,我們可以編寫 requireLogin 中間件,決定一個(gè)字段是否只對(duì)登陸用戶可用。我們可以編寫不同的工具型中間件,注入 ctx.fetch, ctx.post, ctx.xxx 等方法,以供后續(xù)中間件使用。
每個(gè) GraphQL Field 字段,都擁有獨(dú)立的一組中間件和 ctx 對(duì)象,跟其他字段互相不影響。我們同時(shí),可以把所有字段共享的中間件,放到 koa server 里的中間件里。
如上圖所示,綠框是 endpoint,可以編寫 koa server 層面的 middleware。而藍(lán)框是 GraphQL Field 字段,可以編寫 Resolver 層面的 middleware。endpoint 層面的 middleware 對(duì) ctx 的修改,會(huì)影響到后面所有字段。
也就是說(shuō),我們可以像上面那樣。掛接口層面的 logger,可以知道整個(gè) graphql 查詢的耗時(shí)。編寫一個(gè)中間件,在 next 之前,掛載一些方法,供后續(xù)中間件使用;在 next 之后,拿到 graphql 的查詢結(jié)果,進(jìn)行額外的處理。
7.2?解決 mock 難題
GraphQL 是天生 mock 友好的模式,因?yàn)槠?Schema 里已經(jīng)指明了所有數(shù)據(jù)的類型及其關(guān)聯(lián);很容易可以通過(guò) faker data 之類的手段,自動(dòng)根據(jù)類型生成假數(shù)據(jù)。
然而,在實(shí)踐中,實(shí)現(xiàn) GraphQL Mocking 還是有不少的挑戰(zhàn)。
如上圖所示,在 Apollo GraphQL 里,mock 看似很簡(jiǎn)單,只需要在創(chuàng)建服務(wù)時(shí),設(shè)置 mock 為 true,或者提供一個(gè) mock resolver 即可。但是,一個(gè)全局的,跟著服務(wù)創(chuàng)建走的 mock,太過(guò)粗暴。
mock 的價(jià)值在于提供更好的數(shù)據(jù)靈活性以加速開發(fā)效率。它既可以在沒(méi)有數(shù)據(jù)時(shí),提供假數(shù)據(jù);也可以在真數(shù)據(jù)的接口有問(wèn)題時(shí),不用重啟服務(wù),也能降級(jí)為假數(shù)據(jù)。它既可以是整個(gè) GraphQL 查詢級(jí)別的 mock,也可以是字段級(jí)別的 mock。
作為超級(jí)接口的 GraphQL 服務(wù),全局的,在啟動(dòng)階段就固化的 mocking,意義不大。
Apollo GraphQL 的 mocking 實(shí)踐問(wèn)題,正是它采用普通函數(shù)來(lái)描述 Resolver 所帶來(lái)的;它很難簡(jiǎn)單的通過(guò)拓展某個(gè) resolver 而支持 mocking。它不得不在創(chuàng)建服務(wù)時(shí),額外新增一個(gè) mock resolver map 去承擔(dān) mocking 職能。
而我們的 composed resolver 處理動(dòng)態(tài) mocking 卻異常簡(jiǎn)單。
它不僅可以在運(yùn)行時(shí)動(dòng)態(tài)確定,它不僅可以細(xì)化到字段級(jí)別,它甚至可以跟著某次查詢走 mock 邏輯(通過(guò)添加 @mock 指令)。
上圖是默認(rèn)情況下,基于 faker 這個(gè) npm 包,根據(jù)數(shù)據(jù)類型生成的 mock data。
在我們的設(shè)計(jì)里,默認(rèn)的 mocking,其內(nèi)部實(shí)現(xiàn)方式很簡(jiǎn)單。我們先是編寫了上圖,根據(jù) GraphQL Type 調(diào)用 faker 模塊對(duì)應(yīng)的方法,生成假數(shù)據(jù)。
然后在 createResolver 這個(gè)將中間件整合成 resolver 的函數(shù)里,先判斷中間件里是否存在自定義的 mock handler 函數(shù),如果沒(méi)有,就追加前面編寫的 mocker 處理函數(shù)。
我們還提供了 mock 中間件,讓開發(fā)者能指定 mock 數(shù)據(jù)來(lái)源,比如指定 mock json 文件。
mock 中間件,接收字符串參數(shù)時(shí),它會(huì)搜尋本地的 mock 目錄下是否有同名文件,作為當(dāng)前字段的返回值。它也接收函數(shù)作為參數(shù),在該函數(shù)里,我們可以手動(dòng)編寫更復(fù)雜的 mock 數(shù)據(jù)邏輯。
有趣的地方是,mock/user.json 文件里,只包含上圖紅框的數(shù)據(jù),其關(guān)聯(lián)出來(lái)的 collections 字段,是真實(shí)的。這是合理的做法,mock 應(yīng)該跟著 resolver 走。關(guān)聯(lián)字段擁有自己的 resolver,可能調(diào)用自己的接口;不應(yīng)該因?yàn)楦腹?jié)點(diǎn)是 mock 的,子節(jié)點(diǎn)也進(jìn)入 mock 模式。
如此,我們可以在父節(jié)點(diǎn) resolver 對(duì)應(yīng)的后端接口掛掉后,mock 它,讓沒(méi)掛掉的子節(jié)點(diǎn) resolver 正常運(yùn)行。如果我們希望子節(jié)點(diǎn) resolver 也進(jìn)入 mock。很簡(jiǎn)單,添加一個(gè) @mock 指令即可。
如上所示,user 字段和 collections 字段的 resolver 都進(jìn)入了 mock 模式。
自定義 mock resolver 函數(shù)的方式如上圖所示,mock 中間件保證了,只有在該字段進(jìn)入 mock 模式時(shí),才執(zhí)行 mock resolver function。并且,mock resolver function 內(nèi)部依然有機(jī)會(huì)通過(guò)調(diào)用 next 函數(shù),觸發(fā)后面的真實(shí)數(shù)據(jù)獲取邏輯。
以上所有這些靈活性,都來(lái)自于我們選用了表達(dá)能力和可組合性更好的中間件模式,代替普通該函數(shù),承擔(dān) resolver 的職能。
總結(jié)
至此,我們得到了一個(gè)簡(jiǎn)單而靈活的實(shí)踐模式。我們用 Schema 去構(gòu)建 Data Graph 數(shù)據(jù)關(guān)聯(lián)圖,我們用 Middleware 去構(gòu)建 Resolver Map,它們都具備很強(qiáng)的表達(dá)能力。
在開發(fā) GraphQL-BFF 時(shí),我們的 GraphQL-Service 跟后端基于領(lǐng)域模型的 Service,具有總體上的一一對(duì)應(yīng)關(guān)系。不會(huì)產(chǎn)生后端數(shù)據(jù)層解耦后,在 GraphQL 層重新耦合的尷尬現(xiàn)象。
關(guān)于 GraphQL 還有很多話題可以討論,比如 batching , caching 等。這部分內(nèi)容在網(wǎng)絡(luò)上很多 GraphQL 的文檔和教程里都可以找到,這里我們不再贅述。
總的而言,根據(jù)我們對(duì) GraphQL 的考察和實(shí)踐,我們認(rèn)為它可以比 RESTful API 更好的解決我們面對(duì)的問(wèn)題。
我們對(duì) GraphQL 的期望,不僅僅停留在 BFF 層。我們希望通過(guò)積累在 BFF 層使用 GraphQL 的成功經(jīng)驗(yàn),幫助我們摸索出在 Micro Frontend 架構(gòu)上使用 GraphQL 模式的合理設(shè)計(jì)。
如前面所演示的,像 User,Product 和 Order 這種公共級(jí)別的數(shù)據(jù)類型,不可能只由一個(gè)團(tuán)隊(duì)去維護(hù),它們需要被其它團(tuán)隊(duì)所拓展。使得我們可以通過(guò)用戶,找到它關(guān)聯(lián)的訂單,收藏,優(yōu)惠券等由其它團(tuán)隊(duì)維護(hù)的數(shù)據(jù)。
放到 Micro Frontend 架構(gòu)上,一個(gè)支付按鈕,也夾雜了多種類型的數(shù)據(jù),產(chǎn)品信息,用戶信息,庫(kù)存信息,UI 展示信息,交互狀態(tài)信息等等,綜合了這些信息,支付按鈕被點(diǎn)擊時(shí),才得到了充分的數(shù)據(jù),可以決定是否去支付。
樸素 Micro Frontend 的設(shè)計(jì),用 Vue, React, Angular 不同框架,分別維護(hù)不同組件,通過(guò) router/message-passing 等方式互相通訊。在我看來(lái),這是對(duì)后端微服務(wù)架構(gòu)的拙劣模仿。
后端服務(wù),各自部署在獨(dú)立環(huán)境中,對(duì)體積不敏感;因而可以采用不同的語(yǔ)言和技術(shù)棧。這不意味著將它簡(jiǎn)單的放到前端里一樣成立。無(wú)法共享前端開發(fā)的基礎(chǔ)設(shè)施,這不是微前端,這是一種人員組織架構(gòu)上的混亂。
GraphQL 讓我們看到,基于領(lǐng)域模型的微前端架構(gòu),可能是更好的方向。一個(gè)簡(jiǎn)單的支付按鈕,也綜合了多個(gè)領(lǐng)域模型,由多個(gè)開發(fā)者有組織的協(xié)同開發(fā)。并不因?yàn)樗砻嫔峡雌饋?lái)是一個(gè) Button 組件,就由某個(gè)團(tuán)隊(duì)單獨(dú)維護(hù)。
當(dāng)然,探索 GraphQL 的其它方向的前提是,GraphQL-BFF 架構(gòu)得到成功的驗(yàn)證。就現(xiàn)階段的實(shí)踐成果來(lái)看,我們對(duì)此充滿了信心。
盡管我們的代碼暫無(wú)開源計(jì)劃,不過(guò)相信這篇文章,足夠完整和清楚地介紹了我們的 GraphQL-BFF 方案。希望它能給大家?guī)?lái)一點(diǎn)幫助。
總結(jié)
以上是生活随笔為你收集整理的干货 | 万字长文全面解析GraphQL,携程微服务背景下的前后端数据交互方案的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: SpringBoot是如何解析HTTP参
- 下一篇: 华为发布会: 牛逼鸿蒙,吹水的大会