iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 16
3
Modern Web

Think in GraphQL系列 第 16

GraphQL 入門: Interface & Union Type 的多型之旅

  • 分享至 

  • xImage
  •  

header

今天來介紹 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

1. Interface

Interface type 可以提供我們一組 fields 讓不同的 Object type 之間共享,就像 UserFanPage 都共享 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 不盡相同的資料時也特別好用!

再來讓我們看如何實作!

1-1. Interface 實作

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 !

1-2. Query: Inline Fragment For Interface

接著來看如何 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
    }
  }
}

1-3. Node Interface Pattern

這邊想要跟大家介紹一個最有名也是最簡單的 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 ExplorerShopify Storefront API Explorer

2. Union

接著來到了 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 的資料。

接著就來實作囉~

2-1. Union 實作

一樣 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 部分其實也是。

2-2. Inline Fragment For Union

以下為 Query 。

{
  search(contains: "Mary") {
    ... on Author {
      name
    }
    ... on Book {
      title
    }
  }
}

回傳資料如下:

{
  "data": {
    "search": [
      {
        "name": "Mary"
      },
      {
        "title": "Mary Loves Me"
      }
    ]
  }
}

3. 經驗談

通常這適合回傳可能有超過 3 種 type 可能以上的 field ,不然其實 Interface 及 Union 的投資報酬率並不高,
也會增加額外的理解負擔,雖然 schema 顯得簡潔,但增加的是後端的複雜度。

舉個例,如果今天一家電商的商業邏輯中使用者有四種類型: Admin, AdminHelper, Shopper, Guest ,那除非這四者之間的資料需求差異非常大,不然還是先以 type 欄位加上 Enum 定義來處理。

所以重點是「了解自己的商業邏輯」,大多數情況只需要使用 feild 值的交叉組合就可以創造很多類型, 並不會真的需要多創一個全新的 Object type 。

可以多多參考 Github API Explorer ,以下為大家分析一下它的做法:

進入右側 Documentation 的 Query -> viewerUser type 。

img

可以看到光是一個 User type 就已經實作好多 Interface 了!讓我們點開其中代表使用者的 Actor interface

img

這裡面的訊息相當豐富!可見 Actor interface 裡面的 fields 都代表一個正常使用者該有的資料。再看底下列出實作該 interface 的 type 可以大概了解一個可以在 GitHub 的 Issue 裡面回覆的可以有一般使用者、組織或是機器人。

如果想要查證,可以搜尋 IssueComment 可以見到裡面的 author field type 正是 Actor interface ! 如下圖:

img


Reference:


上一篇
GraphQL 入門: 給我更多的彈性! 建立自己的 Directives
下一篇
GraphQL 入門: Apollo Mock - 做假資料好測試~
系列文
Think in GraphQL30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
kpman
iT邦新手 5 級 ‧ 2018-11-11 01:32:15

1-1. Interface 實作 的 resolver,
第一個 key 應該是 Animal

fx777 iT邦新手 5 級 ‧ 2018-11-11 01:34:19 檢舉

謝謝大大幫忙糾正!

1
yxc
iT邦新手 5 級 ‧ 2021-06-04 23:43:28

另外這個模式在每個 id 都不重複的情況下放在 Query 入口點也是一個很強的搜尋功能

關於這個部分,如果不同 Type 的物件 ID 有重複,是否就無法享受到這個很強的搜尋功能的好處了?
請問會有對應的解法嗎?

choznerol iT邦新手 5 級 ‧ 2021-10-18 14:57:30 檢舉

以我自己目前的經驗確實是如此,就我所知這個功能就是 gid 的賣點之一

但實作 gid 或許並沒有想像的複雜,一方面可以漸進式地每次只針對某一個 GraphQL type 導入 gid,另一方面,gid 的實作也有機會只要修改 GraphQL 這一層(而不必動到 model 層甚至 presistent 層),例如把 record.type 跟 record.id 拼起來加密再encode 一下,例如 Post#42 -> base64(sign("Post__42"))

0
choznerol
iT邦新手 5 級 ‧ 2021-10-18 14:43:20

感謝分享! 想請教,假如今天上面 Facebook 的範例有個需求要「篩選列出 author 是 User 或 FanPage 的所有貼文」,想像會設計成類似:

Query {
  posts(charactorType: CharactorType): [Post!]!  # 先不考慮 connection
}

union CharactorType = User | FanPage # 新增的 Union

interface Charactor { ... } # 原本就有的 Interface

但這樣 interface Charactorunion CharactorType 就感覺有點重複,不知道有沒有更漂亮的做法?

choznerol iT邦新手 5 級 ‧ 2021-10-18 15:24:27 檢舉

(自問自答)上面例子把 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),相當地醜,如果有人有其他比較好的方式希望可以分享~

我要留言

立即登入留言