
今天來介紹 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),相當地醜,如果有人有其他比較好的方式希望可以分享~