此文是作者考慮 GraphQL 在 Node.js 架構中的落地方案後所得。從最初考慮可以(以內建中介軟體)加入基礎服務並提供完整的構建、釋出、監控支援,到最終選擇不改動基礎服務以提供獨立包適配,不限制實現技術選型,交由業務團隊自由選擇的輕量方式落地。中間經歷瞭解除誤解,對收益疑惑,對最初定位疑惑,最終完成利弊權衡的過程。
文章會從解除誤解,技術選型,利弊權衡的角度,結合智聯招聘的開發現狀進行交流分享。
文章會以 JavaScript 生態和 JavaScript 客戶端呼叫與服務端開發體驗為例。
對入門知識不做詳細闡述,可自行查閱學習指南中文(https://graphql.cn/learn/)/英文(https://graphql.org/learn/),規範中文(https://spec.graphql.cn/)/英文(https://github.com/graphql/graphql-spec/tree/master/spec),中文文件有些滯後,但不影響了解 GraphQL。
全貌
GraphQL 是一種 API 規範。不是拿來即用的庫或框架。不同對 GraphQL 的實現在客戶端的用法幾乎沒有區別,但在服務端的開發方式則天差地別。
GraphQL 模型
一套執行中的 GraphQL 分為三層:
- 左側是客戶端和發出的 Document 和其他引數。
- 中間是主要由 Schema 和 Resolver 組成的 GraphQL 引擎服務。
- 右側是 Resolver 對接的資料來源。
僅僅有客戶端是無法工作的。
初識
GraphQL 的實現能讓客戶端獲取以結構化的方式,從服務端結構化定義的資料中只獲取想要的部分的能力。
符合 GraphQL 規範的實現我稱之為 GraphQL 引擎。
這裡的服務端不僅指網路服務,用 GraphQL 作為中間層資料引擎提供本地資料的獲取也是可行的,GraphQL 規範並沒有對資料來源和獲取方式加以限制。
- 操作模型:GraphQL 規範中對資料的操作做了定義,有三種,query(查詢)、mutation(變更)、subscription(訂閱)。
客戶端
我們把客戶端呼叫時傳送的資料稱為 Query Document
(查詢文件),是段結構化的字串,形如:
# 客戶端傳送
query {
contractedAuthor: {
name
articles {
time
title
}
}
updateTime
}
# 或
mutation {
# xxxxxx
}
需要注意的是 Query Document
名稱中的 Query 和操作模型中的 query 是沒有關係的,像上述示例所示,Query Document
也可以包含 mutation 操作。所以為了避免誤解,後文將把 Query Document
(查詢文件)稱為 Document 或文件。一個 Document 中可包含單個或多個操作,每個操作都可以查詢補丁數量的跟欄位。
其中 query 下的 updateTime、contractedAuthor 這種操作下的第一層欄位又稱之為 root field
(根欄位)。其他具體規範請自行查閱文件。
Schema
服務端使用名為 GraphQL Schema Language
(或 Schema Definition Language
、SDL
)的語言定義 Schema 來描述服務端資料。
# 服務端 schema
type Query {
contractedAuthor: Author
unContractedAuthor: Author
updateTime: String
}
type Mutation{
# xxx
}
type Subscription {
# xxx
}
type Author {
name: String
articles: [Article]
}
type Article {
time: String
title: String
content: String
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
可以看到,由於 GraphQL 是語言無關的,所以 SDL 帶有自己簡單的型別系統。具體與 JavaScript、Go 其他語言的型別如何結合,要看各語言的實現。
從上面的 Schema 中我們可以得到如下的一個資料結構,這就是服務可提供的完整的資料的 Graph
(圖):
{
query: {
contractedAuthor: {
name: String
articles: [{
time: String
title: String
content: String
}]
}
unContractedAuthor: {
name: String
articles: [{
time: String
title: String
content: String
}]
}
updateTime: String
}
mutation: {
# xxx
}
subscription: {
# xxx
}
}
在 Schema 定義中存在三種特殊的型別 Query、Mutation、Subscription,也稱之為 root types
(根型別),與 Document 中的操作模型一一對應的。
結合 Document 和 Schema,可以直觀的感受到 Document 和 Schema 結構的一致,且 Document 是 Schema 結構的一部分,那麼資料就會按照 Document 的這部分返回,會得到如下的資料:
{
errors: [],
data: {
contractedAuthor: {
name: 'zpfe',
articles: [
{
time: '2020-04-10',
title: '深入理解GraphQL'
},
{
time: '2020-04-11',
title: 'GraphQL深入理解'
}
]
},
updateTime: '2020-04-11'
}
}
預期資料會返回在 data 中,當有錯誤時,會出現 errors 欄位並按照規範規定的格式展示錯誤。
跑起來的 Schema
現在 Document 和 Schema 結構對應上了,那麼資料如何來呢?
-
Selection Sets
選擇集:query { contractedAuthor: { name articles { time title } honour { time name } } updateTime }
如上的查詢中存在以下選擇集:
# 頂層 { contractedAuthor updateTime } # 二層 { name articles honour } # articles:三層 1 { time title } # honour:三層 2 { time name }
-
Field
欄位:型別中的每個屬性都是一個欄位。
省略一些如校驗、合併的細節,資料獲取的過程如下:
- 執行請求:GraphQL 引擎拿到 Document 並解析並處理之後,得到一個新的結構化的 Document(當然原本的 Document 也是結構化的,只不過是個字串)。
- 執行操作:引擎會首先分析客戶端的目標操作,如是 query 時,則會去 Schema 中找到 Query 型別部分執行,由前文所說 Query、Mutation、Subscription 是特殊的操作型別,所以如 query、mutation、subscription 欄位是不會出現在返回結果中的,返回結果中的第一層欄位是前文提到的
root field
(根欄位)。 - 執行選擇集:此時已經明確的知道客戶端希望獲取的
Selection Sets
(選擇集)。query 操作下,引擎一般會以廣度優先、同層選擇集並行執行獲取選擇集資料,規範沒有明確規定。mutation 下,因為涉及到資料修改,規範規定要按照由上到下按順序、深度優先的方式獲取選擇集資料。 -
執行欄位:
-
確定了選擇集的執行順序後開始真正的欄位值的獲取,非常簡化的講,Schema 中的型別應該對其每個欄位提供一個叫做 Resolver 的解析函式用於獲取欄位的值。那麼可執行的 Schema 就形如:
type Query { contractedAuthor () => Author } type Author { name () => String articles () => [Article] } type Article { time () => String title () => String content () => String }
其中每個型別方法都是一個 Resolver。
- 在執行欄位 Resolver 之後會得欄位的值,如果值的型別為物件,則會繼續執行其下層欄位的 Resolver,如
contractedAuthor()
後得到值型別為 Author,會繼續執行name ()
和articles()
以獲取 name 和 articles 的值,直到得到型別為標量(String、Int等)的值。 - 同時雖然規範中沒有規定 Resolver 缺少的情況,但引擎實現時,一般會實現一個向父層欄位(即欄位所在物件)取與自己同名的屬性的值的 Resolver。如未提供 Artical 物件 time 欄位的 Resolver,則會直接取 artical.time。
-
至此由 Schema 和 Resolver 組合而成的可執行 Schema
就誕生了,Schema 跑了起來,GraphQl 引擎也就跑了起來。
GrahpQL 服務端開發的核心就是定義 Schema (結構)和實現相應的 Resolver(行為)。
其他定義
當然,在使用 GraphQL 的過程中,還可以:
- 使用
Variables
(變數)複用同一段 Document 來動態傳參。 - 使用
Fragments
(片段)降低 Document 的複雜度。 - 使用
Field Alias
(欄位別名)進行簡單的返回結果欄位重新命名。
這些都沒有什麼問題。
但是在 Directives
(指令)的支援和使用上,規範和實現是有衝突的。
- 規範內建指令:規範中只規定了 GraphQL 引擎需要實現 Document 中可用的 @skip(條件跳過)、@include(條件包含),在服務端 Schema 部分可用的 @deprecated(欄位已廢棄)指令。
- 自定義指令支援:在我查到的資料中,Facebook 與 graphql-js(Facebook提供實現)官方有不支援自定義指令的表態1(https://github.com/graphql/graphql-js/issues/446)2(https://github.com/graphql-rust/juniper/issues/156)3(https://github.com/graphql/graphql-js/issues/41)。在 Apollo 實現的 Graphql 生態中則是支援自定義 Schema 端可用的指令,對 Document 端的自定義指令實現暫不支援且不建議支援。
而在研究 GraphQL 時發生的的誤解在於:
- 規範、教程提到 query(查詢)時,無法確認是指客戶端側客戶端發出的
Query Document
整個操作還是,Document 中的 query 操作,亦或是服務端側定義在 Schema 中的 Query 型別。 - 或如講到 Arguments、Variables 等概念,其原則、寫法是位於三層的那部分。
實現與選型
GraphQL 的典型實現主要有以下幾種:
- graphql-js:由 Facebook 官方提供的實現。幾乎是
- Apollo GraphQL: Apollo 提供的實現和 GraphQL 生態,內容豐富,不止一套引擎,還提供了純客戶端使用(不侷限JavaScript)多種工具。
- type-graphql:強依賴 TypeScript 開發的實現,主要是輸出可執行 Schema。
graphql-js 可以說是其他實現的基礎。
可執行 Schema 的建立方式是這幾種實現最大的不同,下面將就這部分進行展示。
graphql-js
npm install --save graphql
-
建立可執行 Schema
import { graphql, GraphQLList, GraphQLSchema, GraphQLObjectType, GraphQLString, } from 'graphql' const article = new GraphQLObjectType({ fields: { time: { type: GraphQLString, description: '寫作時間', resolve (parentValue) { return parent.date } }, title: { type: GraphQLString, description: '文章標題', } } }) const author = new GraphQLObjectType({ fields: { name: { type: GraphQLString, description: '作者姓名', }, articles: { type: GraphQLList(article), description: '文章列表', resolve(parentValue, args, ctx, info) { // return ajax.get('xxxx', { query: args }) }, } }, }) const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'RootQuery', fields: { contractedAuthor: { type: author, description: '簽約作者', resolve(parentValue, args, ctx, info) { // return ajax.get('xxxx', { query: args }) }, }, }, }), })
能明確的看到,graphql-js 實現通過 GraphQLSchema 建立出的 schema 中,field 和 resolver 和他們一一對應的關係,同時此 schema 就是可執行 Schema。
-
執行
import { parse, execute, graphql } from 'graphql' import { schema } from '上面的schema' // 實際請求中,document 由 request.body 獲取 const document = ` query { contractedAuthor { name articles { title } } }` // 或使用匯入的 graphql 方法執行 const response = await execute({ schema, document: parse(document), // 其他變數引數等 })
傳入可執行 schema 和解析後的 Document 即可得到預期資料。
Apollo
Apollo 提供了完整的 GraphQL Node.js 服務框架,但是為了更直觀的感受可執行 Schema 的建立過程,使用 Apollo 提供的 graphql-tools
進行可執行 Schema 建立。
npm install graphql-tools graphql
上面是 Apollo 給出的依賴安裝命令,可以看到 graphql-tools 需要 graphql-js(graphql)作為依賴 。
-
建立可執行 Schema
import { makeExecutableSchema } from 'graphql-tools' const typeDefs = ` type Article { time: String title: String } type Author { name: String articles: [Article] } type Query { contractedAuthor: Author } schema { query: Query } ` const resolvers = { Query: { contractedAuthor (parentValue, args, ctx, info) { // return ajax.get('xxxx', { query: args }) } }, Author: { articles (parentValue, args, ctx, info) { // return ajax.get('xxxx', { query: args }) } }, Article: { time (article) { return article.date } } } const executableSchema = makeExecutableSchema({ typeDefs, resolvers, })
resolvers 部分以型別為維度,以物件方法的形式提供了 Resolver。在生成可執行 Schema 時,會將 Schema 和 Resolver 通過型別對映起來,有一定的理解成本。
type-graphql
這部分涉及 TypeScript,只做不完整的簡要展示,詳情自行查閱文件。
npm i graphql @types/graphql type-graphql reflect-metadata
可以看到 type-graphql 同樣需要 graphql-js(graphql)作為依賴 。
-
建立可執行 Schema
import 'reflect-metadata' import { buildSchemaSync } from 'type-graphql' @ObjectType({ description: "Object representing cooking recipe" }) class Recipe { @Field() title: string } @Resolver(of => Recipe) class RecipeResolver { @Query(returns => Recipe, { nullable: true }) async recipe(@Arg("title") title: string): Promise<Recipe> { // return await this.items.find(recipe => recipe.title === title); } @Query(returns => [Recipe], { description: "Get all the recipes from around the world " }) async recipes(): Promise<Recipe[]> { // return await this.items; } @FieldResolver() title(): string { return '標題' } } const schema = buildSchemaSync({ resolvers: [RecipeResolver] })
type-graphql 的核心是類,使用裝飾器註解的方式複用類生成 Schema 結構,並由
reflect-metadata
將註解資訊提取出來。如由@ObjectType()
和@Field
將類 Recipe 對映為含有 title 欄位的 schema Recipe 型別。由 @Query 註解將recipe
、recipes
方法對映為 schema query 下的根欄位。由@Resolver(of => Recipe)
和@FieldResolver()
將title()
方法對映為型別Recipe
的 title 欄位的 Resolver。
關聯與差異
同:在介紹 Apollo 和 type-graphql 時,跳過了執行部分的展示,是因為這兩種實現生成的可執行 Schema 和 graphql-js 的是通用的,檢視這兩者最終生成的可執行 Schema 可以發現其型別定義都是使用的由 graphql-js 提供的 GraphQLObjectType
等, 可以選擇使用 graphql-js 提供的執行函式(graphql、execute 函式),或 apollo-server 提供的服務執行。
異:
- 結構:直接可見的是結構上的差異,graphql-js 作為官方實現提供了結構(Schema)和行為(Resolver)不分離的建立方式,沒有直接使用 SDL 定義 Schema,好處是理解成本低,上手快;apollo 實現則使用結構和行為分離的方式定義,且使用了 SDL,結構和行為使用類名形成對應關係,有一定的理解成本,好處是 Schema 結構更直觀,且使用 SDL 定義 Schema 更快。
-
功能:
- graphql-js:graphql-js 是繞不過的基礎。提供了生成可執行 Schema 的函式和執行 Schema 生成返回值的函式(graphql、execute 函式),使用執行方法可快速將現有 API 介面快速改造為 GraphQL 介面。適合高度定製 GraphQL 服務或快速改造。
- apollo:提供了開箱即用的完整的 Node.js 服務;提供了拼接 Schema(本地、遠端)的方法,使 GraphQL 服務拆分成為可能;提供了客戶端可用的資料獲取管理工具。當遇到問題在 apollo 生態中找一找一般都會有收穫。
- type-grahpql:當使用 TypeScript 開發 GraphQL 時,一般要基於 TypeScript 對資料定義模型,也要在 Schema 中定義資料模型,此時 type-graphql 的型別複用的方式就比較適合。同時 type-grahpql 只純粹的負責生成可執行 Schema,與其他服務實現不衝突,但是這個實現的穩定性還有待觀察。
利弊
對 GraphQL 的直觀印象就是按需、無冗餘,這是顯而易見的好處,那麼在實際應用中真的這麼直觀美好麼?
- 宣告式的獲取資料:結構化的 Document 使得得到資料後,對資料的操作提供了一定便利(如果能打通服務端和客戶端的型別公用,使得客戶端在開發時提供程式碼智慧提示更好)。
- 呼叫合併:經常提到的與 RESTful 相比較優的一點是,當需要獲取多個關聯資料時,RESTful 介面往往需要多次呼叫(併發或序列),而基於 GraphQL 的介面呼叫則可以將呼叫順序體現在結構化的查詢中,一次獲取全部資料,減少了介面往返順序。但同時也有一些注意事項,要真正減少呼叫次數,要在前端應用中集中定義好應用全域性的資料結構,統一獲取,如果仍然讓業務元件就近獲取(只讓業務元件這種真正的使用方知曉資料結構),這個優勢並不存在。
- 無冗餘:按需返回資料,在網路效能上確實有一定優化。
- 文件化:GraphQL 的內省功能可以根據 Schema 生成實時更新的 API 文件,且沒有維護成本,對於呼叫方直觀且準確。
- 資料 Mock:服務端 Schema 中包含資料結構和型別,所以在此基礎上實現一個 Mock 服務並不困難,apollo-server 就有實現,可以加快前端開發介入。
- 強型別(欄位校驗):由於 JS 語言特性,強型別只能稱為欄位強型別校驗(包括入參型別和返回結果),當資料來源返回了比 Schema 多或少的欄位時,並不會引發錯誤,而就算採用了 TypeScript 由於沒有執行時校驗,也會有同樣的問題。但是欄位型別校驗也會有一定的幫助。
- 除錯:由於我們呼叫 GraphQL 介面時(如:
xxx/graphql/im
)無法像 RESTful 介面那樣(如:xxx/graphql/im/message
、xxx/graphql/im/user
)從 URL 直接分辨出業務型別,會給故障排查帶來一些不便。
上面提到的點幾乎都是出於呼叫方的視角,可以看到,作為 GraphQL 服務的呼叫方是比較舒服的。
由於智聯招聘前端架構Ada中包含基於 Node.js 的 BFF(Backends For Frontends 面向前端的後端)層,前端開發者有能力針對具體功能點開發一對一的介面,有且已經進行了資料聚合、處理、快取工作,也在 BFF 層進行過資料模型定義的嘗試,同時已經有團隊在現有 BFF 中接入了 GraphQL 能力並穩定執行了一段時間。所以也會從 GraphQL 的開發者和兩者間的角度談談成本和收益。
- BFF:GraphQL 可以完成資料聚合、欄位轉換這種符合 BFF 特徵的功能,提供了一種 BFF 的實現選擇。
- 版本控制:客戶端結構化的查詢方式可以讓服務追蹤到欄位的使用情況。且在增加欄位時,根據結構化查詢按需查詢的特點,不會影響舊的呼叫(雖然 JavaScript 對多了個欄位的事情不在意)。對於服務的迭代維護有一定便利。
- 開發成本:毫無疑問 Resolver(業務行為)的開發在哪種服務模式下都不可缺少,而 Schema 的定義一定是額外的開發成本,且主觀感受是 Schema 的開發過程還是比較耗費精力的,資料結構複雜的情況下更為如此。同時考慮到開發人員的能力差異,GraphQL 的使用也會是團隊長期的人員成本。像我們在 BFF 層已經有了完全針對功能點一對一的介面的情況下,介面一旦開發完成,後續迭代要麼徹底重寫、要麼不再改動,這種情況下是用不到 GraphQL 的版本控制優勢,將每個介面都實現為 GraphQL 介面,收益不高。
- 遷移改造:提供 GraphQL 介面有多種方式,可以完全重寫也可以定義 Schema 後在 Resolver 中呼叫現有介面,僅僅把 GraphQL 當作閘道器層。
- 呼叫合併:GraphQL 的理念就是將多個查詢合併,對應服務端,通常只會提供一個合併後的“大”的介面,那麼原本以 URL 為粒度的效能監控、請求追蹤就會有問題,可能需要改為以
root field
(根欄位)為粒度。這也是需要額外考慮的。 - 文件化:在智聯招聘所推行的開發模式中,通常 BFF 介面和前端業務是同一個人進行開發,對介面資料格式是熟知的,且介面呼叫方唯一、無複用,GraphQL 的文件化這一特性帶來的收益也有限。
- 規範:由於 GraphQL Schema 的存在,使得資料模型的定義成為了必要項。在使用 JavaScript 開發介面服務時,相對其他各種資料模型定義的嘗試,提供了定義資料模型的統一實踐和強規範,也算是收益之一。同時 Resolver 的存在強化了只在前端做 UI、互動而在 BFF 層處理邏輯的概念。
總結
綜合來看,可用的 GraphQL 服務(不考慮拿 GraphQL 做本地資料管理的情況)的重心在服務提供方。作為 GraphQL 的呼叫方是很爽的,且幾乎沒有弊端。那麼要不要上馬 GraphQL 就要重點衡量服務端的成本收益了。就我的體會而言,有以下幾種情況:
- 服務本身提供的就是針對具體功能的介面,介面只有單一的呼叫方,不存在想要獲取的資料結構不固定的情況,或者說是一次性介面,釋出完成後不用再迭代的,那麼沒必要使用 GraphQL。
- 服務本身是基礎服務,供多方呼叫,需求不一但對外有統一的輸出模型的情況下(如:Github 開放介面,無法確定每個呼叫者需求是什麼),可以使用 GraphQL。
- 在 Node.js(JavaScript)中,由於物件導向、型別的支援程度問題,開發者程式設計思維問題,實現成本比 Java 等其他語言更高,要謹慎考慮成本。
- 沒有 BFF 層時,由於 GraphQL 對於實現資料聚合、欄位轉換提供了正規化,可以考慮使用 GraphQL 服務作為 BFF 層,或者結合1、2點,將部分介面實現為 GraphQL,作為 BFF 層的一部分,其他介面還可以採取 RESTful 風格或其他風格,並不衝突。
- 當前端開發本身就要基於 Node.js 進行 BFF 層開發,團隊對規範、文件有更高優先順序的需求時,可以考慮使用 GraphQL 進行開發。