本文首先介紹了 GraphQL,再通過 MongoDB + graphql + graph-pack 的組合實戰應用 GraphQL,詳細闡述如何使用 GraphQL 來進行增刪改查和資料訂閱推送,並附有使用示例,邊用邊學印象深刻~
如果希望將 GraphQL 應用到前後端分離的生產環境,請期待後續文章。
本文例項程式碼:Github
0. 什麼是 GraphQL
GraphQL 是一種面向資料的 API 查詢風格。
傳統的 API 拿到的是前後端約定好的資料格式,GraphQL 對 API 中的資料提供了一套易於理解的完整描述,客戶端能夠準確地獲得它需要的資料,沒有任何冗餘,也讓 API 更容易地隨著時間推移而演進,還能用於構建強大的開發者工具。
1. 概述
前端的開發隨著 SPA 框架全面普及,元件化開發也隨之成為大勢所趨,各個元件分別管理著各自的狀態,元件化給前端仔帶來便利的同時也帶來了一些煩惱。比如,元件需要負責把非同步請求的狀態分發給子元件或通知給父元件,這個過程中,由元件間通訊帶來的結構複雜度、來源不明的資料來源、不知從何訂閱的資料響應會使得資料流變得雜亂無章,也使得程式碼可讀性變差,以及可維護性的降低,為以後專案的迭代帶來極大困難。
試想一下你都開發完了,產品告訴你要大改一番,從介面到元件結構都得改,後端也罵罵咧咧不願配合讓你從好幾個 API 裡取資料自己組合,這酸爽 ?
在一些產品鏈複雜的場景,後端需要提供對應 WebApp、WebPC、APP、小程式、快應用等各端 API,此時 API 的粒度大小就顯得格外重要,粗粒度會導致移動端不必要的流量損耗,細粒度則會造成函式爆炸 (Function Explosion);在此情景下 Facebook 的工程師於 2015 年開源了 GraphQL 規範,讓前端自己描述自己希望的資料形式,服務端則返回前端所描述的資料結構。
簡單使用可以參照下面這個圖:
比如前端希望返回一個 ID 為 233
的使用者的名稱和性別,並查詢這個使用者的前十個僱員的名字和 Email,再找到這個人父親的電話,和這個父親的狗的名字(別問我為什麼有這麼奇怪的查詢 ?),那麼我們可以通過 GraphQL 的一次 query 拿到全部資訊,無需從好幾個非同步 API 裡面來回找:
query {
user (id : "233") {
name
gender
employee (first: 10) {
name
email
}
father {
telephone
dog {
name
}
}
}
}
複製程式碼
返回的資料格式則剛好是前端提供的資料格式,不多不少,是不是心動了 ?
2. 幾個重要概念
這裡先介紹幾個對理解 GraphQL 比較重要的概念,其他類似於指令、聯合型別、內聯片段等更復雜的用法,參考 GraphQL 官網文件 ~
2.1 操作型別 Operation Type
GraphQL 的操作型別可以是 query
、mutation
或 subscription
,描述客戶端希望進行什麼樣的操作
- query 查詢:獲取資料,比如查詢,CRUD 中的 R
- mutation 變更:對資料進行變更,比如增加、刪除、修改,CRUD 中的 CUD
- substription 訂閱:當資料發生更改,進行訊息推送
這些操作型別都將在後文實際用到,比如這裡進行一個查詢操作
query {
user { id }
}
複製程式碼
2.2 物件型別和標量型別 Object Type & Scalar Type
如果一個 GraphQL 服務接受到了一個 query
,那麼這個 query
將從 Root Query
開始查詢,找到物件型別(Object Type)時則使用它的解析函式 Resolver 來獲取內容,如果返回的是物件型別則繼續使用解析函式獲取內容,如果返回的是標量型別(Scalar Type)則結束獲取,直到找到最後一個標量型別。
- 物件型別:使用者在 schema 中定義的
type
- 標量型別:GraphQL 中內建有一些標量型別
String
、Int
、Float
、Boolean
、ID
,使用者也可以定義自己的標量型別
比如在 Schema 中宣告
type User {
name: String!
age: Int
}
複製程式碼
這個 User
物件型別有兩個欄位,name 欄位是一個為 String
的非空標量,age 欄位為一個 Int
的可空標量。
2.3 模式 Schema
如果你用過 MongoOSE,那你應該對 Schema 這個概念很熟悉,翻譯過來是『模式』。
它定義了欄位的型別、資料的結構,描述了介面資料請求的規則,當我們進行一些錯誤的查詢的時候 GraphQL 引擎會負責告訴我們哪裡有問題,和詳細的錯誤資訊,對開發除錯十分友好。
Schema 使用一個簡單的強型別模式語法,稱為模式描述語言(Schema Definition Language, SDL),我們可以用一個真實的例子來展示一下一個真實的 Schema 檔案是怎麼用 SDL 編寫的:
# src/schema.graphql
# Query 入口
type Query {
hello: String
users: [User]!
user(id: String): [User]!
}
# Mutation 入口
type Mutation {
createUser(id: ID!, name: String!, email: String!, age: Int,gender: Gender): User!
updateUser(id: ID!, name: String, email: String, age: Int, gender: Gender): User!
deleteUser(id: ID!): User
}
# Subscription 入口
type Subscription {
subsUser(id: ID!): User
}
type User implements UserInterface {
id: ID!
name: String!
age: Int
gender: Gender
email: String!
}
# 列舉型別
enum Gender {
MAN
WOMAN
}
# 介面型別
interface UserInterface {
id: ID!
name: String!
age: Int
gender: Gender
}
複製程式碼
這個簡單的 Schema 檔案從 Query、Mutation、Subscription 入口開始定義了各個物件型別或標量型別,這些欄位的型別也可能是其他的物件型別或標量型別,組成一個樹形的結構,而使用者在向服務端傳送請求的時候,沿著這個樹選擇一個或多個分支就可以獲取多組資訊。
注意:在 Query 查詢欄位時,是並行執行的,而在 Mutation 變更的時候,是線性執行,一個接著一個,防止同時變更帶來的競態問題,比如說我們在一個請求中傳送了兩個 Mutation,那麼前一個將始終在後一個之前執行。
2.4 解析函式 Resolver
前端請求資訊到達後端之後,需要由解析函式 Resolver 來提供資料,比如這樣一個 Query:
query {
hello
}
複製程式碼
那麼同名的解析函式應該是這樣的
Query: {
hello (parent, args, context, info) {
return ...
}
}
複製程式碼
解析函式接受四個引數,分別為
parent
:當前上一個解析函式的返回值args
:查詢中傳入的引數context
:提供給所有解析器的上下文資訊info
:一個儲存與當前查詢相關的欄位特定資訊以及 schema 詳細資訊的值
解析函式的返回值可以是一個具體的值,也可以是 Promise 或 Promise 陣列。
一些常用的解決方案如 Apollo 可以幫省略一些簡單的解析函式,比如一個欄位沒有提供對應的解析函式時,會從上層返回物件中讀取和返回與這個欄位同名的屬性。
2.5 請求格式
GraphQL 最常見的是通過 HTTP 來傳送請求,那麼如何通過 HTTP 來進行 GraphQL 通訊呢
舉個栗子,如何通過 Get/Post 方式來執行下面的 GraphQL 查詢呢
query {
me {
name
}
}
複製程式碼
Get 是將請求內容放在 URL 中,Post 是在 content-type: application/json
情況下,將 JSON 格式的內容放在請求體裡
# Get 方式
http://myapi/graphql?query={me{name}}
# Post 方式的請求體
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}
複製程式碼
返回的格式一般也是 JSON 體
# 正確返回
{
"data": { ... }
}
# 執行時發生錯誤
{
"errors": [ ... ]
}
複製程式碼
如果執行時發生錯誤,則 errors 陣列裡有詳細的錯誤資訊,比如錯誤資訊、錯誤位置、拋錯現場的呼叫堆疊等資訊,方便進行定位。
3. 實戰
這裡使用 MongoDB + graph-pack 進行一下簡單的實戰,並在實戰中一起學習一下,詳細程式碼參見 Github ~
MongoDB 是一個使用的比較多的 NoSQL,可以方便的在社群找到很多現成的解決方案,報錯了也容易找到解決方法。
graph-pack 是整合了 Webpack + Express + Prisma + Babel + Apollo-server + Websocket 的支援熱更新的零配置 GraphQL 服務環境,這裡將其用來演示 GraphQL 的使用。
3.1 環境部署
首先我們把 MongoDB 啟起來,這個過程就不贅述了,網上很多教程;
搭一下 graph-pack 的環境
npm i -S graphpack
複製程式碼
在 package.json
的 scripts
欄位加上:
"scripts": {
"dev": "graphpack",
"build": "graphpack build"
}
複製程式碼
建立檔案結構:
.
├── src
│ ├── db // 資料庫操作相關
│ │ ├── connect.js // 資料庫操作封裝
│ │ ├── index.js // DAO 層
│ │ └── setting.js // 配置
│ ├── resolvers // resolvers
│ │ └── index.js
│ └── schema.graphql // schema
└── package.json
複製程式碼
這裡的 schema.graphql
是 2.3 節的示例程式碼,其他實現參見 Github,主要關注 src/db
、src/resolvers
、src/schema.graphql
這三個地方
src/db
:資料庫操作層,包括 DAO 層和 Service 層(如果對分層不太瞭解可以看一下最後一章)src/resolvers
:Resolver 解析函式層,給 GraphQL 的 Query、Mutation、Subscription 請求提供 resolver 解析函式src/schema.graphql
:Schema 層
然後 npm run dev
,瀏覽器開啟 http://localhost:4000/
就可以使用 GraphQL Playground 開始除錯了,左邊是請求資訊欄,左下是請求引數欄和請求頭設定欄,右邊是返回引數欄,詳細用法可以參考 Prisma 文件
3.2 Query
首先我們來試試 hello world
,我們在 schema.graphql
中寫上 Query 的一個入口 hello
,它接受 String 型別的返回值
# src/schema.graphql
# Query 入口
type Query {
hello: String
}
複製程式碼
在 src/resolvers/index.js
中補充對應的 Resolver,這個 Resolver 比較簡單,直接返回的 String
// src/resolvers/index.js
export default {
Query: {
hello: () => 'Hello world!'
}
}
複製程式碼
我們在 Playground 中進行 Query
# 請求
query {
hello
}
# 返回值
{
"data": {
"hello": "Hello world!"
}
}
複製程式碼
Hello world 總是如此愉快,下面我們來進行稍微複雜一點的查詢
查詢入口 users
查詢所有使用者列表,返回一個不可空但長度可以為 0 的陣列,陣列中如果有元素,則必須為 User 型別;另一個查詢入口 user
接受一個字串,查詢 ID 為這個字串的使用者,並返回一個 User 型別的可空欄位
# src/schema.graphql
# Query 入口
type Query {
user(id: String): User
users: [User]!
}
type User {
id: ID!
name: String!
age: Int
email: String!
}
複製程式碼
增加對應的 Resolver
// src/resolvers/index.js
import Db from '../db'
export default {
Query: {
user: (parent, { id }) => Db.user({ id }),
users: (parent, args) => Db.users({})
}
}
複製程式碼
這裡的兩個方法 Db.user
、Db.users
分別是查詢對應資料的函式,返回的是 Promise,如果這個 Promise 被 resolve,那麼傳給 resolve 的資料將被作為結果返回。
然後進行一次查詢就可以查詢我們所希望的所有資訊
# 請求
query {
user(id: "2") {
id
name
email
age
}
users {
id
name
}
}
# 返回值
{
"data": {
"user": {
"id": "2",
"name": "李四",
"email": "mmmmm@qq.com",
"age": 18
},
"users": [{
"id": "1",
"name": "張三"
},{
"id": "2",
"name": "李四"
}]
}
}
複製程式碼
注意這裡,返回的陣列只希望拿到 id
、name
這兩個欄位,因此 GraphQL 並沒有返回多餘的資料,怎麼樣,是不是很貼心呢
3.3 Mutation
知道如何查詢資料,還得了解增加、刪除、修改,畢竟這是 CRUD 工程師必備的幾板斧,不過這裡只介紹比較複雜的修改,另外兩個方法可以看一下 Github 上。
# src/schema.graphql
# Mutation 入口
type Mutation {
updateUser(id: ID!, name: String, email: String, age: Int): User!
}
type User {
id: ID!
name: String!
age: Int
email: String!
}
複製程式碼
同理,Mutation 也需要 Resolver 來處理請求
// src/resolvers/index.js
import Db from '../db'
export default {
Mutation: {
updateUser: (parent, { id, name, email, age }) => Db.user({ id })
.then(existUser => {
if (!existUser)
throw new Error('沒有這個id的人')
return existUser
})
.then(() => Db.updateUser({ id, name, email, age }))
}
}
複製程式碼
Mutation 入口 updateUser 拿到引數之後首先進行一次使用者查詢,如果沒找到則拋錯,這個錯將作為 error 資訊返回給使用者,Db.updateUser
這個函式返回的也是 Promise,不過是將改變之後的資訊返回
# 請求
mutation UpdataUser ($id: ID!, $name: String!, $email: String!, $age: Int) {
updateUser(id: $id, name: $name, email: $email, age: $age) {
id
name
age
}
}
# 引數
{"id": "2", "name": "王五", "email": "xxxx@qq.com", "age": 19}
# 返回值
{
"data": {
"updateUser": {
"id": "2",
"name": "王五",
"age": 19
}
}
}
複製程式碼
這樣完成了對資料的更改,且拿到了更改後的資料,並給定希望的欄位。
3.4 Subscription
GraphQL 還有一個有意思的地方就是它可以進行資料訂閱,當前端發起訂閱請求之後,如果後端發現資料改變,可以給前端推送實時資訊,我們用一下看看。
照例,在 Schema 中定義 Subscription 的入口
# src/schema.graphql
# Subscription 入口
type Subscription {
subsUser(id: ID!): User
}
type User {
id: ID!
name: String!
age: Int
email: String!
}
複製程式碼
補充上它的 Resolver
// src/resolvers/index.js
import Db from '../db'
const { PubSub, withFilter } = require('apollo-server')
const pubsub = new PubSub()
const USER_UPDATE_CHANNEL = 'USER_UPDATE'
export default {
Mutation: {
updateUser: (parent, { id, name, email, age }) => Db.user({ id })
.then(existUser => {
if (!existUser)
throw new Error('沒有這個id的人')
return existUser
})
.then(() => Db.updateUser({ id, name, email, age }))
.then(user => {
pubsub.publish(USER_UPDATE_CHANNEL, { subsUser: user })
return user
})
},
Subscription: {
subsUser: {
subscribe: withFilter(
(parent, { id }) => pubsub.asyncIterator(USER_UPDATE_CHANNEL),
(payload, variables) => payload.subsUser.id === variables.id
),
resolve: (payload, variables) => {
console.log('? 接收到資料: ', payload)
}
}
}
}
複製程式碼
這裡的 pubsub
是 apollo-server 裡負責訂閱和釋出的類,它在接受訂閱時提供一個非同步迭代器,在後端覺得需要釋出訂閱的時候向前端釋出 payload。withFilter
的作用是過濾掉不需要的訂閱訊息,詳細用法參照訂閱過濾器。
首先我們釋出一個訂閱請求
# 請求
subscription subsUser($id: ID!) {
subsUser(id: $id) {
id
name
age
email
}
}
# 引數
{ "id": "2" }
複製程式碼
我們用剛剛的資料更新操作來進行一次資料的更改,然後我們將獲取到並列印出 pubsub.publish
釋出的 payload,這樣就完成了資料訂閱。
在 graph-pack 中資料推送是基於 websocket 來實現的,可以在通訊的時候開啟 Chrome DevTools 看一下。
4. 總結
目前前後端的結構大概如下圖。後端通過 DAO 層與資料庫連線實現資料持久化,服務於處理業務邏輯的 Service 層,Controller 層接受 API 請求呼叫 Service 層處理並返回;前端通過瀏覽器 URL 進行路由命中獲取目標檢視狀態,而頁面檢視是由元件巢狀組成,每個元件維護著各自的元件級狀態,一些稍微複雜的應用還會使用集中式狀態管理的工具,比如 Vuex、Redux、Mobx 等。前後端只通過 API 來交流,這也是現在前後端分離開發的基礎。
如果使用 GraphQL,那麼後端將不再產出 API,而是將 Controller 層維護為 Resolver,和前端約定一套 Schema,這個 Schema 將用來生成介面文件,前端直接通過 Schema 或生成的介面文件來進行自己期望的請求。
經過幾年一線開發者的填坑,已經有一些不錯的工具鏈可以使用於開發與生產,很多語言也提供了對 GraphQL 的支援,比如 JavaScript/Nodejs、Java、PHP、Ruby、Python、Go、C# 等。
一些比較有名的公司比如 Twitter、IBM、Coursera、Airbnb、Facebook、Github、攜程等,內部或外部 API 從 RESTful 轉為了 GraphQL 風格,特別是 Github,它的 v4 版外部 API 只使用 GraphQL。據一位在 Twitter 工作的大佬說矽谷不少一線二線的公司都在想辦法轉到 GraphQL 上,但是同時也說了 GraphQL 還需要時間發展,因為將它使用到生產環境需要前後端大量的重構,這無疑需要高層的推動和決心。
正如尤雨溪所說,為什麼 GraphQL 兩三年前沒有廣泛使用起來呢,可能有下面兩個原因:
- GraphQL 的 field resolve 如果按照 naive 的方式來寫,每一個 field 都對資料庫直接跑一個 query,會產生大量冗餘 query,雖然網路層面的請求數被優化了,但資料庫查詢可能會成為效能瓶頸,這裡面有很大的優化空間,但並不是那麼容易做。FB 本身沒有這個問題,因為他們內部資料庫這一層也是抽象掉的,寫 GraphQL 介面的人不需要顧慮 query 優化的問題。
- GraphQL 的利好主要是在於前端的開發效率,但落地卻需要服務端的全力配合。如果是小公司或者整個公司都是全棧,那可能可以做,但在很多前後端分工比較明確的團隊裡,要推動 GraphQL 還是會遇到各種協作上的阻力。
大約可以概括為效能瓶頸和團隊分工的原因,希望隨著社群的發展,基礎設施的完善,會漸漸有完善的解決方案提出,讓廣大前後端開發者們可以早日用上此利器。
網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~
參考:
PS:歡迎大家關注我的公眾號【前端下午茶】,一起加油吧~