總結了這麼多天,講了很多關於 GraphQL 的技巧,但是會了技術還要會心法!我當初寫文章的本意就是希望加上一些進階一點的內容,包括「如何設計好一個 Schema 」,否則貿然引入 GraphQL 只有前期嚐得到甜頭,到後期開發時真的會慘不忍睹...
我接下來會花 3 - 4 篇來講述如何設計好一個 GraphQL Schema ,主要是整合這一篇 Shopify GraphQL Design Tutorial (大推!目前網路上最完整的設計教學)、其他網路上的文章以及個人經驗。
今天想講的是關於 GraphQL Query 及 Type 的設計法則,主要分為:
很多人包括我剛開始使用 GraphQL 都會有一個迷思,就是把 GraphQL 當作一組大型 RESTFul API 來使用,變成只是形式上使用 GraphQL ,但 Schema 設計還是 RESTFul API 的方式。
舉個例,在剛接觸 GraphQL 時,套用 RESTFul 思維就會容易產生這樣的 Schema
type User {
id
email
}
type Order {
owner: ID!
"商品規格"
productVariants: [ProductVariant]
price: Int
}
type Query {
"取得使用者"
getUser(id: ID!): User
"取得訂單列表"
getOrderList(userId: ID!, size: Int): [Order]
}
而如果要取得某人的資料以及他下過的定單,就得要下兩支 Query ,這樣其實只是把 RESTful API 包成一綑送出去而已,失去了 GraphQL 將 entity (有實體的資料) 串成一個連接圖的特點。
PS 這邊的 entity 泛指那些有自己 id 、在資料庫中有自己 table 的資料,因此 user 是一種 entity ,但 name 則不是。
query {
getUser(id: 1) {
id
name
}
getOrderList(userId: 1, size: 10) {
id
productVariants { ... }
}
}
那麼這樣會有哪些壞處呢 ?
假如今天要加入商品、照片、推薦商品、店家等概念時就會讓 Query 難以管理
理想狀態下好的 GraphQL 是希望設計成像是一張連結圖一樣的形式, Client 端可以透過進入 User 得到 Order 再去得到 ProductVariant ... 。
所以 Schema 設計成這樣會更好:
type User {
...
orders (size: Int): [Order]
}
type Query {
users: [User]
user: User
}
而同樣效果 query 不但更簡潔,而且更能強調彼此之間的關係! (在 Query 中明顯表示出 orders 屬於該 user )
query {
getUser(id: 1) {
id
name
orders (size: 10) {
id
productVariants { ... }
}
}
}
建議 1: GraphQL Schema 是由許多 Type 與 Type 之間的關聯所組成。遇到 id 欄位時最好都替換成 Object type。
另外剛開始設計 GraphQL Schema 時也容易設計成以 Data Driven (資料為主體) 而非 Domain Driven (領域知識為主體) ,外表貌似將 entity 串連,但實際上就只是把資料庫裡的資料格式複製出來而已。 可參考以下的 Schema :
type Order {
id: ID!
ownerId: ID!
ownerName: String
productVariantId: ID!
productVariantName: String
price: Int
discountIds: [ID]!
discount建議s: [Discount建議]
createdAt: String
}
interface Product {
id: ID!
imageId: ID
title: String
variantIds: [ID]!
createdAt: String
}
"食物商品"
type FoodProduct implements Product {
id: ID!
imageId: ID
title: String
variantIds: [ID]!
createdAt: String
shipment: FoodShipment
storageSystem: StorageSystem
dueDate: String
}
"電子商品"
type ElectronicProduct implements Product {
id: ID!
imageId: ID!
title: String!
"商品規格"
variantIds: [ID]!
createdAt: String
shipment: NormalShipment
warrantyPeriod: String
}
是否讓人有點頭昏腦脹 ? 在 Shopify 的文章以及大部分的文章都建議以 Entity-Relationship model 的概念去設計,先明確想好 Entity 有哪些以及彼此間的關係後,有了骨架再去設計裡面的細節如 field 資料、 Scalar Type 選擇等等...
所以讓我們把一些不屬於 entity 的欄位先過濾掉,並且把一些屬於 entity 概念的 field 整合起來,這樣一來整個架構就會非常清楚明瞭!
type Order {
User
[Discount]
[ProductVariant]
}
interface Product {
Image
[ProductVariant]
}
type FoodProduct implements Product {
Image
[ProductVariant]
StorageSystem
FoodShipment
}
type ElectronicProduct implements Product {
Image
[ProductVariant]
NormalShipment
}
有了一個骨架幫忙釐清我們 types 的結構以及 types 彼此間的關係,之後再去填入一些特定欄位會更輕鬆!
建議 2: 設計 Schema 時,永遠先以高層次物件的結構與關係為方向去思考後,再去處理細節的欄位。
另外,我們可以發現我在設計 Query 時,都盡量只使用名詞,或頂多在前面加上形容詞。這樣的好處是更凸顯你資料之間的關係,不必被一堆 getter 或其他亂七八糟的命名給模糊焦點。
建議 3: 在 Query 裡建議使用「名詞」而非「動名詞」,更能夠表達 Graph 的概念。
有了以上的概念後,雖然可以幫我們有條理地組織整個 Schema ,但仍不能避免我們設計出一個龐大難用的系統,裡頭充斥著用不到的欄位或是難以理解的邏輯,這是因為不夠了解軟體的 bussiness domain (商業領域)
用上面的例子來說,對於後端資料處理來說,會需要分出 FoodProduct
與 ElectronicProduct
來決定要使用低溫冷層物流、熱騰騰送到家還是一般的超商取貨,或是找出食物是不是使用其他公司的倉儲服務。
但對於商店的消費者來說,他不必須需要知道後端實作的細節,他只要知道商品有哪些以及商品的基本資訊就好了,因此前端只需要滿足實際的使用需求,而不必暴露過多的實作細節。
type Product {
Image
[ProductVariant]
Shipment
}
另外,我自己也遇過類似的案例在設計 Order
type 時,因為後端會將每個折扣 (Discount) 都拿出來算過一遍才能得出總金額,所以我就順理成章的將 Discount
這個 entity 放進 Order
中,結果造成我 Order
裡面同時要有折扣紀錄 Order.discount: [Discount]
與折扣總金額 Order.discountPrice: Int
。
但是實際上前端所需要只有「最後的折扣總金額」,所以我根本不需要 Order.discount: [Discount]
。所以 Order
type 也可以更簡化。
type Order {
User
[ProductVariant]
}
建議 4: 以 business domain 及實際需求來設計你的 API ,而非迷失在實作細節、 UI 或是舊版 API。
有了骨架後,讓我們來介紹一些實用的方式使 API 更好管理與易讀。
前面有提過,我們要依實際使用需求來設計 API ,這不但只是因為簡潔,另一方面也是因為新增欄位很容易,但移除掉很難,尤其是加上 !
的 type 。通常一個 GraphQL API 要提供很多 client 使用,因此任何一個欄位的改變都勢必造成維護上小的負擔,而如果是 non-null 的欄位更是會造成 breaking changes (即這個改變有相當的機率會造成軟體出問題)。
因此在設計時,如果有看到類似概念的欄位就可以考慮抽象出來。假如今天要設計一個 Event
type 來代表日曆上的一個活動,裡面會有時間相關的欄位。
type Event {
name: String
owner: User
participants: [User]
"時間區間"
timeRange: [Date!]!
"這時段是否已經過期"
timeRangeInPast: Boolean!
}
但這樣會使得 Event
type 變得更複雜,而且如果又加入「該時間區段是否跨日」、「該時間區段是否超過 5 小時」等等,就會讓整個 Event
type 爆掉。因此可以考慮抽離出來:
type Event {
name: String
owner: User
participants: [User]
timeRange: TimeRange!
}
type TimeRange {
start: Date!
end: Date!
isInPast: Boolean!
}
這樣一來,不但讓 Event
type 清楚明瞭許多,而且還能讓 TimeRange
type 在其他地方重複使用!
建議 5: 以 Object Types 將相似概念的欄位抽離出來。
我們也常常因為命名問題而必須捨棄舊的欄位,比如當初設計「貼文評論」這件事情時相關的回覆概念只有在這邊出現,就很容易命名為 Comment
type ,但是到後來卻需要「評論的評論」、「粉絲專頁評論」、「使用者評論」等等...。
而這時候要拿掉 Comment
type ,就會非常麻煩,還需要加上一大堆註解。因此不如當初遇到這種概念較為模糊的 type 時,就直接命名為 PostComment
,就可以為 Comment
這個詞保留很多未來的可能性!
建議 6: 命名時,務必謹慎小心,如果有不確定之處,那就取名得越特定越好!
另外習慣上, Object type 的命名習慣是 Pascal case ,也就是開頭大寫、後面駝峰的形式。
在程式中,我們習慣將難以理解意義的數字與字串稱為 magic numbers ,這些莫名其妙出現在程式中的數字與字串常讓人頭痛不已。在 GraphQL 也會遇到這樣的問題,有些欄位明明只會用到特定的某些數值,但卻使用 Int
或 String
等模棱兩可的 type ,使得不管是在 type validation 或後端計算時都會造成額外的負擔。
因此假如你的 Order.status
只會有 PROCESSING
(處理中) 、 CLOSED
(已結案)、 CANCELED
(已取消) ,那麼就可以試試看 enum 。
enum OrderStatus {
"處理中"
PROCESSING
"已結案"
CLOSED
"已取消"
CANCELED
}
建議 7: 當欄位只需要特定幾組數值時, Enum 是你的首選。
同時也提醒,習慣上 enum 命名也是走 Pascal case ,然後裡面的值則是全大寫形式,最後也別忘了加上註解,免得又創造出一堆 magic numbers
明天會接著講述整理/重構 Schema 的技巧以及一些常用的 Design Pattern !
Reference:
想請問一下,設計 TimeRange 的時候,isInPast 是需要的嗎?
會有這個疑惑是因為下列三點
已經在下一篇文章中獲得解答了,謝謝