iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 23
4

header

今日主題

  1. 設計時的好習慣 (續)
  2. 常見 Design Pattern
  3. 專注在 Business Logic 的思考

設計時的好習慣 (續)

Custom scalar types

之前在 GraphQL 入門: 實作 Custom Scalar Type (Date Scalar Type) 這篇有介紹過 custom Scalar Type 。簡單來說就是可以自訂義更精確語意的 scalar type 來取代預設的 Int, Float String, Boolean, ID 。

這是一個很好用的功能,不過在使用前也要先思考:這個欄位的值的語意真的夠特別嗎 ? 預設的 Scalar Type 真的無法表達嗎 ? 不能使用自定義 enum type 表達嗎 ?

目前自己使用經驗上有幾種情形適合用 custom scalar type :

  1. Date

時間格式真的很容易搞慘人。另外個人經驗上最好回傳 +0 時間,再交給前端做時區轉換會是比較簡單的做法。

  1. JSON

JSON 格式也是蠻常見的 Scalar Type ,尤其適合那種「我就是要一大包資料」的欄位。

  1. Positive Int

可以減少後端檢查邏輯。

  1. URL

儲存圖片網址、頁面網址之類的很好用。

  1. Email

客戶輸錯電子郵件的案例相信不少人並不陌生,不管是 gmail 拼成 gmial 或是 _ 打成全形 _ 。

建議 8: 如果欄位的值的語意夠特別,可以考慮使用 custom scalar type

Non-null

接著介紹 non-null 這個令人又愛又恨的欄位,優點是可以強化 Schema 的驗證邏輯,但缺點也不少,比如

  1. 難以擴展 Schema。

之前已經說過,修改一個 non-null 欄位意味著 breaking change 。預測未來的 Schema 變化是不實際的,把 nullable 欄位轉向 non-null 很容易,但要轉回來的成本會高出很多。

打個比方,有可能今天有個 User type ,規定一定要有名字與生日,那假如有一天註冊方式改用 Oauth 或其他方式使得 name 非必要,或是要修改能看到 age 的權限等等,都會限制你的 Schema 。

而且因為前端會預期得到 non-null ,所以如果 deprecate 掉這個欄位,還是要提供 default 值。

type User {
  id: ID!
  email: String!
  name: String!
  age: Int!
}
  1. Non-null 欄位可能會放大錯誤。

回傳 non-null 欄位的資料時,如果該欄位因為得到 null 而失敗,會導致整個 Object 一起失敗。
用上面的例子,假如 User 其中一個 non-null 欄位驗證失敗,那即使其他欄位資料正確也會整個失敗,進而可能讓前端難以處理錯誤,甚至讓 UI 元件壞掉...

所以一開始設計時,能先不用 non-null 就先不用。真的適合的地方有:

  1. id 欄位。

id 也不給就有點天理不容了。

  1. parent Object 欄位。

比如一則 Post 、一張 Order 一定是由一個 User 產生出來的!如果沒有 parent 可以回傳那該檢討的應該是後端的程式碼。

  1. Boolean type 欄位。

身為一個 Boolean ,最討厭的就是拿到 Null ,那到底是有還是沒有還是不存在呢 ? 因此建議 Boolean type 欄位都可以專門寫一個 field resolver 來回傳一個預設值。

  1. [type!] 欄位。

Non-null 跟 Array 最好的結合方式。畢竟你可以給我一個 null 或一個空 Array ,但回傳一個夾雜 Null 的 Array 真的會令人捏把冷汗。

建議 9: 只在真正必要時才使用 non-null 。

善用 @deprecated

當 Schema 中某些欄位用不到時,先別急著移除它,搞不好還有某個 client 正在使用!這時候就適合加入 @deprecated (reason: String) 的欄位,可以看看 Documentation 中,搜尋 __Type 點進 __Field 可以看到

img

當你使用 @deprecated 時, GraphQL 會自動幫你標注此欄位已經不再被支援並且加上理由。這樣一來,即使還有 client 正在使用,被 deprecate 的欄位依然會提供值,不會直接造成 breaking change !

不過美中不足的是,目前 input type 並沒有 deprecate 相關的 spce ,所以尚不支援 @deprecated

img
(可以看到沒有 deprecate 相關的欄位)

建議 10: 不再支援的 field 請用 @deprecated(reason: "") 來做廢除。

註解不能少!

GraphQL 的優點在於「 API 即文件」,組織良好的架構與精準的命名讓整個 API 易讀好用,但是也別忘了加上註解!畢竟再怎麼精美的程式放久了也會發臭,而註解文件正好可以維持程式的新鮮度!

在使用註解時,通常會使用三種符號

  1. """ 多行文件註解。通常用於 object type 的註解,註解裡面支援 markdown 格式。
  2. " 單行文件註解。通常用於 field 的註解,但如果該 field 註解過多也可考慮改用 """
  3. # 純單行註解。 # 的文字不會被顯示在 documentation 中,僅供程式裡參考。
"""
User Type
"""
type User {
  "名字"
  # 這個註解不會出現在 documentation 裡面
  name: String
}

但當然不是每一個人都有那麼多時間每行都寫文件註解,而且只要架構良好且命名清楚的話不用註解也可以清楚表達。但仍有幾個情形推薦使用註解:

  1. 使用 Enum 時。 Enum 本身雖然有一定的表達性,但程式與表達中間仍會有一些落差,因此建議都加上註解。
  2. 欄位涉及商業邏輯時。有商業邏輯的欄位必定會牽涉到後端的檢查或計算,而這個計算過程或目的若可以表達出來,也會減少許多不必要的猜測。
  3. 當欄位新舊並行時。有時後想用新的名稱取代舊名稱,但一方面更動太大,一方面也不想要急著 deprecated 掉,此時就可以用寫文件註解來表達這種特殊情況。

建議 11: 開發在走,註解要有。

2. 常見 Design Pattern

講完了上述的 Query 設計技巧,讓我們來看看有什麼 Design Pattern 可以供我設計時參考。

Node ID Interface 模式

Node ID Interface 一方面增加了結構的可讀性,讓人可以一眼了解這個物件的重要性與意義,另一方面前端也能針對物件的 id 做 caching 或其他管理,以增加效能與效率。

interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  ...
}

type Product impplements Node {
  id: ID!
  ...
}

另外還可以開這麼一個 query field 來增加 Schema 的彈性。

type Query {
  node(id: ID!): Node
}

建議 11: 重要的 business object 都實現 Node Interface

用 Connection 模式來分頁

當一個欄位需要表達大量資料時,使用 Array 就會一次撈出大量資料,進而拖累你的效率,這時候你需要的是分頁的技巧。 Connection 模式是一個被廣泛使用的模式,與 Node Interface 一樣都是由 Relay 推出的一個標準。

所以當你預期資料量會非常大時,比如你逛商店時有數千件商品,就該考慮使用 Array 或是 Connection 。

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type UserEdge {
  cursor: String!
  node: User
}

type UserConnection {
  edges: [UserEdge]!!
  pageInfo: PageInfo!
  total: Int
}

Viewer 模式管理你的權限

在 Relay 還有推出一個很好用 Viewer 模式。使用 Viewer 模式可以很切合地配合前端的需求,只要使用 query.viewer 就可以滿足一個使用者幾乎所有的資料需求。

此外 Viewer 模式讓你的權限管理更加方便,不必要一隻一隻 query 的檢查,只需要配合進來的 viewer 身份給予符合權限的資料即可,因此非常適合有多種使用者身份的系統。

type Viewer {
  self: User
  products: [Product]
  orders: [Order]
  cart: Cart
  "推薦商品"
  recommendedProducts: [Product]
}

3. 專注在 Business Logic 的思考

講完這些了這些,還有最後一點要強調,那就是小心不要設計出 Anemic domain model (貧血資料模型) 。引用 Clean Code 說過,使用物件導向設計時,要分清楚「物件」與「資料結構」的差別。物件的目的是用來提供行為而非暴露資料 ; 資料結構是暴露資料而非提供行為。簡單來說,物件不是用來當個容器放資料,而是透過提供 function 去操作資料。

同樣的,在 GraphQL Schema 資料設計中,雖然總體來說是個資料結構,但有些時候並不貼近實際使用情況,舉個例子來說,假如今天有這樣一個 Schema:

type User {
  id: ID!
  name: String
  posts: [Post]
}

而今天需要一個 User 資訊總覽頁面,除了基本資料外,還需要「貼文的數量」。這時候使用 posts 再去取得 [Post] 長度就顯得很多餘了,此時不妨加上一個 postCount: Int 欄位,反而更貼進使用需求!

又或者這個頁面還需要「文章總讚數」的欄位,與其一個一個 post 拿出來統計,倒不如直接新增 totalPostLikeCount: Int 來滿足前端需求!

別忘了, GraphQL 是為了前端的需求而生,而非依資料庫的資料結構來設計! 如果你是從 RESTful 轉來 GraphQL ,那建議你可以直接重新設計 GraphQL ,不要貪圖一時的方便把整套舊的搬過來,兩者的設計精神從一開始就不相同!

type User {
  ...
  postCount: Int
  totalPostLikeCoun: Int
}

建議 12: API 可以提供的不只是資料,也應該要提供商業邏輯層面的欄位,。複雜的計算應該交由後端來處理,而非散落各處。

不過還是要提醒,不要因為有了商業邏輯欄位而拿掉原先的純資料欄位,兩者並行可以為 Schema 帶來更大的彈性!

關於避免 Anemic Object ,我們將會在明天的 Mutation 有更多實際案例來演示!


明後天將會帶來 Mutation 相關的設計守則!


Reference:


上一篇
Think in GraphQL: Schema Query 設計守則 - 1
下一篇
Think in GraphQL - Schema Mutation 設計守則 - 1
系列文
Think in GraphQL30

尚未有邦友留言

立即登入留言