今天來介紹 GraphQL 的 Interface 與 Union 功能,這兩個 type definition 可以幫助我們在 GraphQL 做到多型 (Polymorphism) 。
舉個例子,大家都有用過 Facebook ,都知道現在除了使用者以外,粉絲專頁也可以 po 文章按讚等等,那我們就來模擬一下。假如今天有個部落格系統,只有註冊過的會員才能貼文,那 Schema 如下:
"""
貼文回覆
"""
type Post {
id: ID!
"貼文作者"
author: User!
title: String
body: String
}
type User {
id: ID!
name: String
"大頭貼圖片網址"
avatarUrl: String
friends: [User]
}
Query {
post(id: ID!): Post
}
那如果今天使用者看到 id 為 1 的貼文想點進去看作者的名字,只需寫下 query 如下
query {
post(id: 1) {
author {
name
}
}
}
但今天新增新的功能:粉絲專頁,而粉絲專頁不能交友但是有額外的資訊如按讚人,因此新增 Schema 如下:
type FanPage {
id: ID!
name: String
avatarUrl: String
likeGivers: [User]
}
而每個粉絲專頁也要可以有貼文功能,這時候原先 Post
Type 的 author: User
這一個 field 就麻煩了,因為他無法回傳 User
Type 以外的 Object Type ,這時候 Interface 就可以幫忙!
"""
貼文回覆
"""
type Post {
id: ID!
"貼文作者"
author: Charater!
title: String
body: String
}
# 定義 Charater interface
interface Character {
id: ID!
name: String
avatarUrl: String
}
type User implements Character{
id: ID!
name: String
avatarUrl: String
friends: [User]
}
type FanPage implements Character {
id: ID!
name: String
avatarUrl: String
likeGivers: [User]
}
Query {
post(id: ID!): Post
}
這時候如果要找到 id 為 1 的貼文的作者的名字就可以這樣下 query
query {
post(id: 1) {
id
name
...on User {
friends {
name
}
}
...on FanPage {
likeGivers {
name
}
}
}
}
如此一來, GraphQL 就會自行判斷,如果今天作者是 User
Type 那就會進入 ...on User
並回傳其中的 fields ,如果是 FanPage
Type 那就會進入 ...on FanPage
並回傳其中的 fields 。
在這邊就簡單實現了 GraphQL 的多型 (即不會事先決定型別,而是等到執行時再做決定),以下將會仔細介紹 Interface 實作以及另一個相似的概念 Union
。
Interface type 可以提供我們一組 fields 讓不同的 Object type 之間共享,就像 User
與 FanPage
都共享 Character
interface 的 id
, name
, email
等 fields ,而這種 Object tyep 使用 Interface type 的關係我們稱做 implementation (實作),所以可以說 User
type 實作 Character
interface 。
在實作時,實作方 (Object type) 需要將被實作方 (Interface type) 的每項 field 都要再次定義出來。
透過這種方式,只要在 Schema 中 field 的 type 為 Interface type ,就代表最終回傳 type 會是其中一個實作該 Interface type 的 Object type。不過要謹記,不能直接回傳 Interface type 的值,回傳的一定要由實作它的 Object type 的值。
以下來個簡單的例子:
interface Animal {
name: String
}
type Bird implements Animal {
name: String
"翅膀展開長度"
wingSpanLength: Int
}
type Monkey implements Animal {
name: String
"手臂展開長度"
armSpanLength: Int
}
type Query {
animal(name: String): Animal
animals: [Animal]
}
可以看到通常會實作同一個 Interface type 的 Object type 都會有一些邏輯上的共通,另外 Interface 在當你想回傳一組裡面 type 不盡相同的資料時也特別好用!
再來讓我們看如何實作!
Interface 實作一樣是在 Server 的 Schema + Resolver 兩部分。
Schema 部分上面已經定義過了,我們直接來看 Resolver 部分怎麼做 !
const animals = [
{ name: 'Chiken Litte', wingSpanLength: 10 },
{ name: 'Goku', armSpanLength: 20 },
{ name: 'King Kong', armSpanLength: 200 }
];
const resolvers = {
Animal: {
// 一定要實作這一個特殊 field
__resolveType(obj, context, info) {
// obj 為該 field 得到的資料
if (obj.wingSpanLength) {
// 回傳相對應得 Object type 名稱
return 'Bird';
}
if (obj.armSpanLength) {
return 'Monkey';
}
return null;
}
},
Query: {
animal: (root, { name }) => animals.find(animal => animal.name === name),
animals: () => animals
}
};
這邊再次提醒,如果沒有回傳實際的 Object type 一定會吐出 Error !
接著來看如何 Query 這個 Interface type。
之前說過 Fragment 可以幫我們抽出一些繁雜的 field 讓畫面簡潔,而在 Interface 與 Union 這邊會使用一種特殊的 Fragment 來取得資料,我們稱為 Inline Fragments 。透過多個不同的 ...on SpcificType { ... }
讓資料能夠找到對應的 type 回傳。以下為範例 query:
{
animal(name: "Chiken Litte") {
name
... on Bird {
wingSpanLength
}
... on Monkey {
armSpanLength
}
}
}
可以得到值:
(可以觀察到,最終顯示的 field 只會符合其中一個 type !)
{
"data": {
"animal": {
"name": "Chicken Little",
"wingSpanLength": 10
}
}
}
這邊想要跟大家介紹一個最有名也是最簡單的 Interface 應用範例: Node Interface Pattern 。
實作起來很簡單, Node
interface type 只需要一個 field id
,可見下方 Schema Difinition 範例:
interface Node {
"ID of the object"
id: ID!
}
type User implements Node {
id: ID!
...
}
type Post implements Node {
id: ID!
...
}
type Club implements Node {
id: ID!
...
}
在大型的 GraphQL Schema 中,一般會推薦所有主要商業邏輯物件都要實作 Node
interface type,因為通常這些物件在 database 中都有 id
,實作 Node
interface type 可以明確告訴 Client 這是一個重要概念的物件,並且可透過 id
的操作來做 caching 及 batching 。
另外這個模式在每個 id 都不重複的情況下放在 Query 入口點也是一個很強的搜尋功能,如下
type Query {
node(id: ID!): Node
nodes(ids: [ID!]): [Node]!
}
這樣一來留給 Client 端非常大的彈性空間去取得想要的物件。
很多大公司都有在用這個模式,可以參考 GitHub API Explorer 及 Shopify Storefront API Explorer
接著來到了 Union type ,很多人一開始都會一直把 Interface type 與 Union type 兩者搞混,但兩邊的差異很簡單,可以看一下一個簡單的 Union type 如何被定義:
union Result = Book | Author
type Book {
title: String
}
type Author {
name: String
}
type Query {
search(contains: String!): [Result]
}
可以從這個例子中看到兩者的差異。
實作 Interface type 的 type 都有一些共通 fields (強制要定義),而在 Union type 範疇裡的 type 則不必有共通 fields
Interface 就像是同一所學校的學生,雖然每個都不同但有一些共通的特徵如制服,而 Union 比較像是個雜牌軍,較不注重彼此間的共通性。
而兩者的相似之處在於最終回傳時一定要是一個實際的 type ,不能傳回 interface 或 union type 的資料。
接著就來實作囉~
一樣 Schema 部分上面已經有了,直接進入 Resolver 部分:
const authors = [{ name: 'John' }, { name: 'Mary' }];
const books = [{ title: 'Journey to the West' }, { title: 'Mary Loves Me' }]
const resolvers = {
Result: {
// 一定要實作這一個特殊 field
__resolveType(obj, context, info){
// obj 為該 field 得到的資料
if(obj.name){
// 回傳相對應得 Object type 名稱
return 'Author';
}
if(obj.title){
return 'Book';
}
return null;
},
},
Query: {
search: (root, { body }) =>
[
...authors.filter(author => author.name.includes(body)),
...books.filter(book => book.title.includes(body))
]
},
};
可以看到 Resolver 方面 Union type 與 Interface type 兩邊非常類似,而 Query 部分其實也是。
以下為 Query 。
{
search(contains: "Mary") {
... on Author {
name
}
... on Book {
title
}
}
}
回傳資料如下:
{
"data": {
"search": [
{
"name": "Mary"
},
{
"title": "Mary Loves Me"
}
]
}
}
通常這適合回傳可能有超過 3 種 type 可能以上的 field ,不然其實 Interface 及 Union 的投資報酬率並不高,
也會增加額外的理解負擔,雖然 schema 顯得簡潔,但增加的是後端的複雜度。
舉個例,如果今天一家電商的商業邏輯中使用者有四種類型: Admin, AdminHelper, Shopper, Guest ,那除非這四者之間的資料需求差異非常大,不然還是先以 type
欄位加上 Enum 定義來處理。
所以重點是「了解自己的商業邏輯」,大多數情況只需要使用 feild 值的交叉組合就可以創造很多類型, 並不會真的需要多創一個全新的 Object type 。
可以多多參考 Github API Explorer ,以下為大家分析一下它的做法:
進入右側 Documentation 的 Query
-> viewer
的 User
type 。
可以看到光是一個 User
type 就已經實作好多 Interface 了!讓我們點開其中代表使用者的 Actor
interface
這裡面的訊息相當豐富!可見 Actor
interface 裡面的 fields 都代表一個正常使用者該有的資料。再看底下列出實作該 interface 的 type 可以大概了解一個可以在 GitHub 的 Issue 裡面回覆的可以有一般使用者、組織或是機器人。
如果想要查證,可以搜尋 IssueComment
可以見到裡面的 author
field type 正是 Actor
interface ! 如下圖:
Reference:
1-1. Interface 實作 的 resolver,
第一個 key 應該是 Animal
。
謝謝大大幫忙糾正!
另外這個模式在每個 id 都不重複的情況下放在 Query 入口點也是一個很強的搜尋功能
關於這個部分,如果不同 Type 的物件 ID 有重複,是否就無法享受到這個很強的搜尋功能的好處了?
請問會有對應的解法嗎?
以我自己目前的經驗確實是如此,就我所知這個功能就是 gid 的賣點之一
但實作 gid 或許並沒有想像的複雜,一方面可以漸進式地每次只針對某一個 GraphQL type 導入 gid,另一方面,gid 的實作也有機會只要修改 GraphQL 這一層(而不必動到 model 層甚至 presistent 層),例如把 record.type 跟 record.id 拼起來加密再encode 一下,例如 Post#42
-> base64(sign("Post__42"))
感謝分享! 想請教,假如今天上面 Facebook 的範例有個需求要「篩選列出 author 是 User 或 FanPage 的所有貼文」,想像會設計成類似:
Query {
posts(charactorType: CharactorType): [Post!]! # 先不考慮 connection
}
union CharactorType = User | FanPage # 新增的 Union
interface Charactor { ... } # 原本就有的 Interface
但這樣 interface Charactor
跟 union CharactorType
就感覺有點重複,不知道有沒有更漂亮的做法?
(自問自答)上面例子把 Union 用在 input 是不符合 GrpahQL spec 的(https://github.com/graphql/graphql-spec/issues/488 被 close 了),以 graphql-ruby. 為例,會得到錯誤: ArgumentError: Invalid input type for ReportsQuery.reportableType: Reportable. Must be scalar, enum, or input object, not UNION.
目前似乎只能期待還在 RFC 的
Tagged Type (https://github.com/graphql/graphql-spec/pull/733 ) 或許可以解決這個問題
我們目前的 workaround 是新增一個 CharactorTypeEnum = USER | FAN_PAGE
實作 posts(charactorType: CharactorType)
,相當地醜,如果有人有其他比較好的方式希望可以分享~