在現在的公司使用GraphQL有一段時間了。
現公司從創立之后的很長一段時間內是純PHP的技術棧,前端、后端都在PHP代碼中糅合在一起。新功能越加越多,頁面越來越復雜之后,那些混在在PHP代碼中的HTML代碼越來越不可維護,於是終於有公司里的程序員看不下去,開始了技術革命,將PHP代碼抽象成一個個微服務提供API,前端則采用Node+React,解放了前端工程師的生產力,使得新界面的開發越來越順利,前端程序員也越發不用關心后端的實現了。
故事說到這里聽起來皆大歡喜,然而時間長了,新的問題出現了——我們的微服務需要的調整越來越多。PM永遠想要嘗試新的點子,我們的新需求仍然是更加“花里胡哨”的頁面,原先一個頁面調用的微服務,在新的需求下需要新的數據,於是和原來比,要做的工作反而多了:在純PHP的框架下,PHP后端代碼和HTML前端代碼都在同一個文件中,新需求也可能需要改一個(套)文件;然而在新的架構下,我們即需要調整微服務(PHP文件),又需要去調整前端代碼(JS文件),還需要更改兩者之間的協議(Apache Thrift),並且還需要嚴格的遵守Release的順序和向前兼容的問題。
GraphQL就是在這樣的背景下被引入到我們的技術棧之中,關於GraphQL的介紹網上有很多博文,在這里就不展開描述,個人覺得對於我們的產品開發中最有利的兩點:
1. 降低了后端API的調整頻度。所謂的新“需求”,有很多時候其實就是將數據轉移,比如將本來在A頁面展示的數據挪到B頁面,或者將A和B頁面合並成一個頁面,抑或是A頁面拆成B和C兩個頁面。在GraphQL引入之前,這樣的展示層面的增刪改都必將導致后端API的變化,但在GraphQL引入之后,前端程序員只需在Node端調整查詢語句,就可以自己定制出自己需要的API。
2. 增加了前端的靈活性和可調試性。前端可以根據需求,理論上可以將整個數據庫的數據在一個頁面上實現任意的組合,並且由於有graphiql等強大的工具,可以邊實現新的頁面,邊調整自己的查詢語言,在出現問題時也可以通過直接執行查詢語句來看是否后端返回的數據有問題。
比如有一款社交網絡的應用,我們后端有一個getUserByUserId的API,可以查詢一個用戶的信息(ID,用戶名,朋友們的ID),如果我們要做一個頁面來顯示一個用戶的三度好友樹,如果不使用GraphQL的解決方案,需要創建一個新的API,在API中先通過getUserByUserId去查詢一個用戶的信息和所有好友ID,再通過getUserByUserId去獲得每個朋友的信息和好友ID,如此循環最后返回。
而如果使用GraphQL的解決方案,我們只需要定義用戶和API的Schema:
type User {
userId: Int userName: String friends: [User] }
extend type Query {
getUserByUserId(userId: Int): User //根據用戶Id查詢單個用戶
getUsersByUserIds(userIds: [Int]): [User] //根據多個用戶Id查詢多個用戶
}
而對應的Resolver邏輯為
export default {
Query: {
getUserByUserId: async (root, args, context) => await context.service.getUserByUserId(args.userId).then(response => response.user), getUsersByUserIds: async (root, args, context) => await context.service.getUsersByUserIds(args.userIds).then(response => response.users), } User: { userId: (root) => root.userId, userName: (root) => root.userName, friends: (root, args, context) => await context.service.getUsersByUserIds(root.friendIds).then(response => response.users), } }
而getUserByUserId的返回格式為以下的格式,getUserByUserIds的話則是以下格式的列表形式
context.service.getUserByUserId(10001) { "userId": 10001, "userName": "Sample User Name", "friendIds": [ 10002, 10003, 10004 ] }
這里我們提供了兩個API,一個是單數形式getUserByUserId,一個是復數形式getUsersByUsersId,實際實現中單數形式的API可以坍縮成復數形式API只有一個參數的調用,所以可以繼續簡化其實現。為什么不只創建單數形式的API呢?這在之后的實戰問題中會描述。
這樣,我們如果要實現上面所描述的三度好友頁面,只需要定義兩個API——getUserByUserId和getUsersByUserIds和下面的一條GraphQL查詢語句
query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) { userId userName friends { //朋友 userId userName friends { //朋友的朋友 userId userName } } } }
這個查詢會返回給我們這樣的結果
{ "userId": 10001, "userName": "Sample User Name", "friends": [ { "userId": 10002, "userName": "Sample User Name 2", "friends": [ { "userId": 10003, "userName": "Sample User Name 3" }, { "userId": 10004, "userName": "Sample User Name 4" }, .... ] }, { "userId": 10005, "userName": "Sample User Name 5", "friends": [ { "userId": 10006, "userName": "Sample User Name 6" }, { "userId": 10007, "userName": "Sample User Name 7" }, .... ] }, .... ] }
這樣我們只用了一個API(單復數共用一個實現的話)就組合出了這樣的一個復合API,如果將來想要實現四度好友,五度好友,則可以在以上面的查詢基礎上繼續嵌套,仍舊不需要增加后台的API代碼。
這個示例只是最簡單的示例,理論上如果你的服務的所有實體數據之間都有聯結關系,那么只需要你實現每個實體數據根據ID的自查詢API和實體之間的聯結查詢API,那么用GraphQL就可以將所有的實體連接成一張圖(Graph),你可以通過GraphQL查詢語句來構建這張圖中的任何子圖。
-------------------------- 我是和諧的分割線 --------------------------
正如每顆硬幣都有正反面,在實際使用GraphQL的時候我們也遇到了很多問題,特別是性能上的問題。拿以上這個三度好友的GraphQL查詢來舉例,它有哪些問題呢?
1. 過度查詢(Overfetching)
在一個頁面中我們可能只會用一個實體的某幾個屬性,那么我們在后端的查詢最好只需要選取需要的字段。而我們在實現GraphQL和后端服務的橋接時,不論GraphQL的查詢語句請求了幾個字段,后端服務永遠會查詢實體的所有字段並返回,而GraphQL的引擎則會根據查詢語句只提取需要的字段作為返回結果。但是在這個過程中,不必要的字段占用了數據庫的傳輸以及前后端網絡傳輸的帶寬。
比如上面的例子,如果我們的頁面只要求獲得用戶ID並不要求返回用戶名,那么我們的query可以改成以下的模式
query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) {
userId
friends {
userId
friends {
userId
}
}
}
}
表面上來看我們確實沒有去查詢userName,但實際上由於我們的API會返回所有的userId, userName, friendIds,所以這個查詢和前面那個例子的查詢開銷上是一樣的。
解決方案:針對整個問題,我們在GraphQL的Resolver層面做了一些改造,在查詢被執行的時候從GraphQL引擎獲得當前的查詢語句請求的字段,並將字段作為隱藏參數傳遞給后端服務,后端服務根據傳進來的字段進行數據庫查詢的優化。解決方案的偽代碼如下。
export default { User: { userId: (root) => root.userId, userName: (root) => root.userName, friends: (root, args, context, info) => await context.service.getUsersByUserIds(root.friendIds, info.fields).then(response => response.users), // 傳入GraphQL查詢中的field } }
而服務器端的API返回值也隨着調用傳參也變化
context.service.getUserByUserId(10001,["userId"]) { "userId": 10001 }
2. 重復查詢(Repeated Query)
一個較為復雜的頁面中可能一個實體在頁面的不同位置都有展現,比如上面那個查詢,用戶的一度好友們的二度好友們,很有可能互相之間也是好友,那么我們的兩層嵌套查詢中,有部分的查詢實際上是可以避免的。
解決方案:這一點暫時沒有很完美的解決方案,我們目前可以做到的是在上層Query中已經查詢到的數據,如果下層Query也要查詢,那么通過緩存的方式,使的下層的Query不去訪問API,但是如果本身是不同的Query,暫時沒有辦法做跨請求的緩存。緩存實現的偽代碼如下。
export default { User: { userId: (root) => root.userId, userName: (root) => root.userName, friends: (root, args, context, info) => { let cachedUsers = root.friendIds.map(id => context.cache.users[id]).filter(x => !!x); //找出所有緩存的用戶 let idsToFetch = root.friendIds.filter(id => !context.cache.users[id]); //取得未緩存的用戶ID return context.service.getUsersByUserIds(idsToFetch, info.fields).then(response => { //查詢未緩存的用戶信息 for(let user in response.users) { context.cache.users[user.id] = user;//將結果存儲到緩存中 } return response.users.concat(cachedUsers)://合並緩存結果和返回結果 }); } } }
3. N+1查詢 (N+1 Query)
N+1查詢是GraphQL使用中最可能也是最經常遇到的性能問題,當出現查詢嵌套並且在內部嵌套的數據是列表類型時最容易出現這樣的性能問題。還是以上面的查詢為例,如果系統中每個用戶平均有10個好友,那么以上的三度好友查詢一共進行了多少次后端API的調用?答案是1 + 1 + 10 = 12次, 為什么是12次呢?
1. 第一次調用getUserByUserId, 獲得了目標用戶的ID和用戶名信息以及平均10個朋友的ID
2. 第二次調用getUsersByUserIds,獲得了10個目標的用戶民信息已經他們10*10個朋友的ID
3. 對於2中獲得的10批朋友ID,我們需要分別調用10次getUsersByUserIds,去獲得者100個朋友的用戶名信息
第二次調用獲得了N批朋友ID,每批朋友信息的查詢帶來了N次的額外查詢,所以我們將這種Pattern稱為N+1查詢。這里我們可以看出為什么我們一開始在定義API的時候一定要定義復數形式的API,這樣一開始我們就考慮到了會有批(Batch)查詢的的需求,否則的話如果只有單查詢的接口,我們則需要1 + 10 + 10 * 10 = 111次API查詢。但是12次查詢也是非常大的消耗,並且收到前段和后端通信的並發限制,這最后的10次通信可能需要分批進行,那么最終會導致服務器端的返回速度收到了極大的限制。
解決方案:N+1 Query的問題沒有一個非常好的解決方案,我們目前的做法是在GraphQL的Resolver邏輯中插入了自己的邏輯,當我們遇到這種多層嵌套查詢的時候,在第N層去嘗試等待其他的resolver,拿上面的例子,我們的第二次調用后,獲得了10批朋友的ID,那么在第三層的結構進行resolve邏輯的時候,我們會收集所有需要調用getUsersByUserIds的參數,將其合並成一次調用,這次調用返回的Promise在全局共享,同時在運行時將每個resolver的邏輯替換成合並后調用的結果中找出自己需要的結果並返回。為了更好的幫助大家理解,可以參照下圖。

GraphQL為前后端的API提供了一種便利的解決方案,但同時收到自身設計的限制,會有各種各樣的問題需要針對具體的應用場景去優化,在使用之前不妨先問問自己:我到底需不需要GraphQL。
