iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 22
3

header

總結了這麼多天,講了很多關於 GraphQL 的技巧,但是會了技術還要會心法!我當初寫文章的本意就是希望加上一些進階一點的內容,包括「如何設計好一個 Schema 」,否則貿然引入 GraphQL 只有前期嚐得到甜頭,到後期開發時真的會慘不忍睹...

我接下來會花 3 - 4 篇來講述如何設計好一個 GraphQL Schema ,主要是整合這一篇 Shopify GraphQL Design Tutorial (大推!目前網路上最完整的設計教學)、其他網路上的文章以及個人經驗。

今天想講的是關於 GraphQL Query 及 Type 的設計法則,主要分為:

  1. 如何以 GraphQL API 的方式思考
  2. 設計時的一些好習慣

1. 如何以 GraphQL API 的方式思考

很多人包括我剛開始使用 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 { ... }
  }
}

那麼這樣會有哪些壞處呢 ?

  1. Schema 會隨著 entity 越來越多而難以管理。

假如今天要加入商品、照片、推薦商品、店家等概念時就會讓 Query 難以管理

  1. 沒有凸顯出 entity 之間的關係,這樣你很難知道 User 與 Order 之間的關係 (所以我可以去看別人的訂單 ?)

理想狀態下好的 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 (商業領域)

用上面的例子來說,對於後端資料處理來說,會需要分出 FoodProductElectronicProduct 來決定要使用低溫冷層物流、熱騰騰送到家還是一般的超商取貨,或是找出食物是不是使用其他公司的倉儲服務。

但對於商店的消費者來說,他不必須需要知道後端實作的細節,他只要知道商品有哪些以及商品的基本資訊就好了,因此前端只需要滿足實際的使用需求,而不必暴露過多的實作細節。

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。

2. 設計時的一些好習慣

有了骨架後,讓我們來介紹一些實用的方式使 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 ,也就是開頭大寫、後面駝峰的形式。

萬事用 Enum !

在程式中,我們習慣將難以理解意義的數字與字串稱為 magic numbers ,這些莫名其妙出現在程式中的數字與字串常讓人頭痛不已。在 GraphQL 也會遇到這樣的問題,有些欄位明明只會用到特定的某些數值,但卻使用 IntString 等模棱兩可的 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:


上一篇
GraphQL 設計: Autentication 與 Authorization 大全
下一篇
Think in GraphQL: Schema Query 設計守則 - 2
系列文
Think in GraphQL30

尚未有邦友留言

立即登入留言