隨着多終端、多平臺、多業務形態、多技術選型等各方面的發展,先後端的數據交互,日益複雜。前端
同一份數據,可能以多種不一樣的形態和結構,在多種場景下被消費。node
在理想狀況下,這些複雜性能夠所有由後端承擔。前端只管從後端接口裏,拿到已然整合完善的數據。git
然而,無論是由於後端的領域模型,仍是由於微服務架構。做爲前端,咱們感覺到的是,後端提供的接口,愈加不夠前端友好。咱們必須自行組合多個後端接口,才能獲取到完整的數據結構。github
面向領域模型的後端需求,跟面向頁面呈現的前端需求,出現了不可調和的矛盾。數據庫
這種背景下,本着誰受益誰開發的原則。咱們最後選擇使用 Node.js 搭建專門服務於前端頁面呈現的後端,亦即 Backend-For-Frontend,簡稱 BFF。express
咱們面臨了不少不一樣的技術選型,主要圍繞在權衡 RESTful API 和 GraphQL。npm
面向前端頁面的數據聚合層,其接口很容易在迭代過程當中,變得越發複雜;最終發展成一個超級接口。編程
它有不少調用方,各類不一樣的調用場景,甚至多個不一樣版本的接口並存,同時提供數據服務。json
全部這些複雜性,都會反映到接口參數上。後端
接口調用的場景越多,它對接口參數結構的表達能力,要求越高。若是隻有一個 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/格林斯潘第十定律
或許能夠說:
任何接口設計複雜到必定程度後,都會包含一個臨時開發的、不合規範的、只有一半功能的 GraphQL 實現。
從 SearchParams, FormData 到 JSON,再到 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 的 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 須要操做數據庫,所以實現起來,幾乎等於要推翻當先後端的全部架構。這是一個重大誤解。
GraphQL 不只能夠不操做數據庫,它甚至能夠不從其它地方獲取數據,而直接寫死數據在 Resolver 函數裏。查看 graphql.js 的官方文檔,咱們輕易能夠找到案例:
上圖定義了一個 schema,只有一個類型爲 String 的 hello 字段,它的 resolver 函數裏,無視全部參數,直接 return 一個 hello world 字符串。
能夠看到,GraphQL 只是關於 schema 和 resolver 的一一對應和調用,它並未對數據的獲取方式和來源等作任何假設。
在網絡上,有至關多的 GraphQL 文章,將它跟 RESTful API 對立起來,彷彿要麼全盤 GraphQL,要麼全盤 RESTful API。這也是一個重大誤解。
GraphQL 和 RESTful API 不只不對立,仍是互相協做的關係。
在前面關於 Resolver 函數的圖片中,咱們看到,能夠在 GraphQL Schema 的 Resolver 函數裏,調用 RESTful API 去獲取數據。
固然,也能夠調用 RPC 或者 ORM 等方式,從別的數據接口或者數據庫裏獲取數據。
所以,實現一個 GraphQL 服務,並不須要挑戰當前整個後端體系。它具備高度靈活的適配能力,能夠低侵入性的嵌入當前系統中。
儘管絕大多數 GraphQL,都以 server 的形式存在。可是,GraphQL 做爲一門語言,它並無限制在後端場景。
上圖仍是前面展現過的 graphql.js 的官方文檔,最下面一行,就是一個普通的函數調用,它發起了一次 graphql 查詢,其 response 結果以下:
這段代碼,不僅能在 node.js 裏運行,在瀏覽器裏也能夠運行(可訪問:https://codesandbox.io/s/hidden-water-zfq2t 查看運行結果)
所以,咱們徹底能夠將 GraphQL 用在純前端,去實現 State Management 狀態管理。Relay 等框架,即包含了用在前端的 graphql。
這是一個有趣的事實,GraphQL 語言設計裏的兩個組成部分:
數據提供方編寫 GraphQL Schema;
數據消費方編寫 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,都是既能夠運行在服務端,也能夠運行在前端。
這是另外一個有趣的事實。最初咱們演示了,如何基於 JSON 數據結果,反推出 GraphQL 查詢語法的設計。而如今,咱們卻說 GraphQL 能夠不返回 JSON 數據格式。
沒錯。當一個新事物出現以後,隨着它的不斷髮展,它能夠脫離其初衷,衍生出不一樣的形態。
上圖仍是來自 graphql-anywhere 裏的例子。
在這裏,它實現了一個 gqlToReact 的 Resolver,能夠把一個 graphql 查詢轉換爲 ReactElement 結構。
不僅是動態類型的 GraphQL 有這個能力,靜態類型的 GraphQL 也有可能實現同樣的效果。
不過這種作法,目前僅僅停留在能力演示階段。其妙用還有待社區去挖掘和探索。
到目前爲止,咱們見識到了 GraphQL 的高自由度和靈活性。在搭建 GraphQL Server 時,也能夠根據實際需求和場景,採用不一樣的模式。
這個模式就是簡單粗暴的把 RESTful API 服務,替換成 GraphQL 實現。以前有多少 RESTful 服務,重構後就有多少 GraphQL 服務。它是一個簡單的一對一關係。
默認狀況下,面向兩個 GraphQL 服務發起的查詢是兩次請求,而不是一次。舉個例子:
前端須要產品數據時,從以前調用產品相關的 RESTful API,變成查詢產品相關的 GraphQL。不過,須要訂單相關的數據時,可能要查詢另外一個 GraphQL 服務。
有一些公司拿 GraphQL 小試牛刀時,採起了這個作法;將 GraphQL 用在特定服務裏。
不過,這種模式難以發揮 GraphQL 合併請求和關聯請求的能力。只是起到了按需查詢,精確查詢字段的做用,價值有限。
所以,他們在實踐後,發現收效甚微;認爲 GraphQL 不過如此,還不如 RESTful API 架構簡單和成熟。
其實這是一種選型上的失誤。
在這個模式裏,GraphQL 接管了前端的一整塊數據查詢需求。
前端再也不直接調用具體的 RESTful 等接口,而是經過 GraphQL 去間接獲取產品、訂單、搜索等數據。
在 GraphQL 這個中間層裏,咱們將各個微服務,按照它們的數據關聯,整合成一個基於 GraphQL Schema 的數據關係網絡。前端能夠經過 GraphQL 查詢語句,同時發起對多個微服務的數據的獲取、篩選、裁剪等行爲。
值得一提的是,做爲 API Gateway 的 GraphQL 服務,能夠在其 Resolver 內,向前面提到的 RESTful-like 的 GraphQL 發起查詢請求。
如此,既避免了前端須要一對多的問題,也解決了 API Gateway GraphQL 須要請求 RESTful 全量數據接口的內部冗餘問題。讓服務到服務之間的數據調用,也能夠作到更精確。
GraphQL 服務是一個對數據消費方友好的模式。而數據消費方,既能夠是前端,也能夠是其它服務。
當數據消費方是其它服務時,經過 GraphQL 查詢語句,彼此之間能夠更精確獲取數據,避免冗餘的數據傳輸和接口調用。
當數據消費方是前端時,因爲前端須要跟多個數據提供方打交道,若是每一個數據提供方都是單獨的 GraphQL,那並不能獲得本質上的改善。此時如有一個 Gateway 角色的 GraphQL,能夠真正減小前端調用的複雜度。
一樣是 API Gateway 角色的 GraphQL 服務,在實現方式上也有不一樣的分類。
包含大量真實的數據操做和處理的 GraphQL
轉發數據請求,聚合數據結果的 GraphQL
第一類,是傳統意義上的後端服務;第二類,則是咱們今天的重點,GraphQL as BFF。
這兩類 GraphQL 服務的要求是不一樣的,前者可能包含大量 CPU 密集的計算,然後者整體而言主要是 Network I/O 相關的行爲。
許多公司並不提倡使用 Node.js 構建第一種服務,無論是構建 RESTful 仍是 GraphQL。咱們也同樣。
所以,後面咱們討論的 GraphQL,若是沒有特別聲明,均可以理解爲上面所說的第二種類型。
在澄清關於 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,其中的 Graph 體如今什麼地方?
GraphQL 的查詢語句,看起來是 JSON 寫法的一種簡化。而 JSON 是一個 Tree 樹形數據結構。爲何不叫 TreeQL,而是 GraphQL 呢?
一個重要的前置知識是,什麼是 Tree,什麼是 Graph,它們有什麼關係?
下圖是一個 Tree 的結構示意圖。
Tree 有且只有一個 Root 節點,而且對於每一個非 Root 節點,有且只有一個父節點;它們組成了一個層次結構。其中任意兩個節點,有且只有一條鏈接路徑;沒有循環,也沒有遞歸引用。
下圖是一個 Graph 的結構示意圖。
而 Graph 裏的節點之間,可能存在不僅一種鏈接路徑,可能存在循環,可能存在遞歸引用,可能沒有 Root 節點。它們組成了一個網絡結構。
咱們能夠把 Graph 這種網絡結構,經過裁剪鏈接路徑,把它壓縮成任意節點只有惟一鏈接路徑的簡化形態。如此網絡結構退化成層次結構,它變成了 Tree。
也就是說,Graph 是比 Tree 更復雜的數據結構,後者是它的簡化形式。擁有 Graph,咱們能夠按照不一樣的裁剪方式,衍生出不一樣的 Tree。而 Tree 裏包含的信息,若是不增長其它額外數據,不足以構建足夠複雜的 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 去裁剪掉沒必要要的數據關聯路徑。
前面的 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 的綱領知識後,咱們來看一下在實踐中 ,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 兩個模塊,並在此基礎上,進行了符合咱們場景的設計和改編。
GraphQL-BFF 的核心思路是,將多個 services 整合成一箇中心化 data graph。
每一個 service 的數據結構契約,都放入了一個大而全的 GraphQL Schema 裏;若是不作任何模塊化和解耦,開發體驗將會很是糟糕。每一個團隊成員,都去修改同一份 Schema 文件。
這明顯是不合理的。GraphQL-BFF 的開發模式,應該跟 service 的領域模型,有一一對應的關係。而後經過某種形式,多個 services 天然整合到一塊兒。
所以,咱們設計了 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 也須要解耦。
無論是在官方的 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 裏。能夠更好的解決以前解決不了,或者很難的問題。
所謂的架構能力,體如今理解咱們面對的問題的複雜度及其本質特徵,並能選擇和設計出合適的程序表達模型。
後面咱們將演示,正確的架構,如何輕易地克服以前難以解決的問題。
或許不少同窗並不清楚,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 的查詢結果,進行額外的處理。
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 的指望,不只僅停留在 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 方案。但願它能給你們帶來一點幫助。