點燈坊

失くすものさえない今が強くなるチャンスよ

GraphQL 初體驗

Sam Xiao's Avatar 2019-10-24

GraphQL 為 Facebook 所發表 API 查詢語言,在 2018 年成立 GraphQL Foundation 後,各語言的後端與前端都有 GraphQL 實作,本文將對 GraphQL 做簡單介紹,並以 Apollo 平台建立基本 GraphQL 開發環境。

Version

macOS Catalina 10.15
WebStorm 2019.2.3
Node 10.16.3
Apollo GraphQL 2.9.6
Vue CLI 4.0.5
Vue 2.6.10
Vue Apollo 3.0.0-beta.11

History of GraphQL

2012 年時,Facebook 開始提供 native app 版本,當時只是將 browser 版本包成 native app 而已,但執行效能很差,Facebook 發現主要原因是 REST API 傳輸很沒效率導致,因此開始著手以 mobile client 角度設計 GraphQL API 取代 REST API。

GraphQL 初步構想是既然能以 SQL 對 relational database 查詢,而不用在乎實際 database 實作,能否也設計一個新的 query language 對 internet 查詢,而不用在乎 backend 實作,至少目前 REST API 不是語言。

2015 年 Facebook 對外發表以 Node + JavaScript 實作的第一個 GraphQL Server,在 client 則有 relay,主要都是 React ecosystem 在使用。

2018 年 Facebook 將 GraphQL 開放成為 GraphQL Foundation 非營利組織,負責建立 GraphQL 語言標準,自此 GraphQL 開始出現各種實作版本,在 server 端如 JavaScript、Go、PHP、C#、Java、Python、Ruby 都能實現 GraphQL Server;在 Client 端如 React、Angular、Vue、iOS 與 Android native app 也都能成為 GraphQL Client。

目前 GraphQL ecosystem 除了 Facebook 平台外,Meteor 的 Apollo 平台也很受歡迎,本文將以 Apollo 平台為主。

Why GraphQL ?

GraphQL 全名為 Graph Query Language,如同 SQL 也是 Query Language,但為什麼稱為 Graph 呢 ?

query {
  me {
    name
    location
    birthday
    friends {
      name
      location
      birthday
    }
  }
}

一個典型的 GraphQL。

  • me
    • name
    • location
    • birthday
    • friends
      • name
      • location
      • birthday

若抽象化來看,GraphQL 結構很樹狀結構。

intro000

將其轉 90 度,其結構就更像 tree 了,而 tree 是一種 graph,因此 Facebook 稱為 GraphQL。

REST API vs GraphQL API

Overfetching of Data

REST API

https://swapi.co/api/people/1

以 REST API 讀取 SWAPI,想要取得 Luke Skywalker 的資料。

overview021

REST API 回傳了一堆資料,但事實上我們只想要 namemassheight 三個 property,剩下資料都只是浪費網路頻寬而已。

GraphQL API

https://graphql.org/swapi-graphql

以 GraphQL API 讀取 SWAPI,一樣取得 Luke Skywalker 的資料。

intro001

呼叫 person query,傳入 personID: 1,指定回傳 namemassheight 3 個 field,JSON 只會回傳所需資料而已,節省網路頻寬。

Multiple Round Trips

若需求時除了顯示 Luke Skywalker,還想顯示在哪些電影曾經出現過。

"films": [
  "https://swapi.co/api/films/2/",
  "https://swapi.co/api/films/6/",
  "https://swapi.co/api/films/3/",
  "https://swapi.co/api/films/1/",
  "https://swapi.co/api/films/7/"
],

https://swapi.co/api/people/1/ 回傳的 films property 下,包含所有電影的 endpoint。

overview020

https://swapi.co/api/films/1/ 回傳了一堆資料,但事實上我們只要 title 而已。

且為了得到所有 title,總共要發 1 + 5 次 API request。

intro002

person query 下呼叫其 filmConnection sub query,指定回傳其 films property 下的 title property,只要發 1 次 API request 就可得到我們要的結果,而且完全沒有多餘的 field,速度更快也節省頻寬。

Overfetching 與 Multiple round trips 也是當時 Facebook 發明 GraphQL 最想解決的問題

API Versioning

對於已發佈 REST API,若想刪除 property,會造成既有 client 的 breaking change,REST API 的解法是從 endpoint 定義 v1v2 …,原來 client 使用 v1,新 client 使用 v2 …。

若使用 GraphQL API,就沒有 versioning 問題,可一直新增 property,對 client 沒有影響,若要刪除 property,也不必從 server 動手,client 自行從 GraphQL 刪除 property 即可。

API Document

REST API 並沒有定義 document,傳統會另外提供 document 或使用 Swagger。

intro003

定義 schema 時可同時建立 document,user 可在 GraphiQL 或 GraphQL Playground 內建的Document Explorer 讀取最新 document。

Black Magic of GraphQL

REST API vs Graph API

intro004

  • REST API 有眾多 endpoint,但 Graph API 只有一個 endpoint

  • REST API 有 verb 與 URI,但 Graph API 有 query 與 mutation

  • REST API 與 Graph API 都回傳 JSON

  • REST API 與 Graph API 都不局限於特定 server 與 client 技術

  • REST API 與 Graph API 都是 stateless

URL-Driven vs Query-Language

intro005

GraphQL 底層一樣使用 REST API 與 HTTP,只是僅使用 POST,且 body 用來傳送標準化的 GraphQL,GraphQL Server 本質上是一個負責解譯 GraphQL 的 parser。

http://snowtooth.herokuapp.com

intro006

可使用 GraphQL API。

$ curl 'http://snowtooth.herokuapp.com' -H 'Content-Type: application/json' --data '{"query": "{ allLifts { name }}"}'

亦可使用 REST API 以 POST 與 body 傳送 GraphQL。

再次證明 GraphQL 是基於 REST API 與 HTTP 的產物

intro007

Fullstack GraphQL

接下來將以 Vue Apollo + Apollo GraphQL + GraphQL Playground 建立 GraphQL 開發環境。

Browser

overview018

Vue 使用 GraphQL 對 Apollo GraphQL 要資料並顯示。

Vue CLI

$ vue create vue-grapql

使用 Vue CLI 建立 vue-gql project。

Vue Apollo

$ vue add apollo

安裝 apollo plugin 自動設定 Vue Apollo 。

intro008

是否安裝 sample code ? 直接按 選擇 N 不安裝。

overview008

是否安裝 GraphQL Server ? 直接按 選擇 N 不安裝。

overview010

是否設定 Apollo Engine ? 直接按 選擇 N 不設定。

overview011

apollo plugin 除了安裝 Vue Apollo 外,還幫我們設定了以上檔案。

Apollo GraphQL

$ mkdir server
$ cd server
$ yarn init --yes
$ yarn add apollo-server graphql

以 Apollo GraphQL 提供 GraphQL API 供 Vue Apollo 使用。

  • 在 Vue project 下建立 server 目錄
  • 使用 yarn init --yesserver 目錄下建立 package.json
  • 使用 Yarn 安裝 apollo-servergraphql

overview012

server/index.js

let { ApolloServer, gql } = require('apollo-server')

let data = [
  { title: 'FP in JavaScript', category: 'FP'},
  { title: 'RxJS in Action', category: 'FRP'},
  { title: 'Speaking JavaScript', category: 'JS'}
]

let typeDefs = gql`
  type Query {
    """
    根據書本種類查詢所有書籍
    """
    books(category: BookCategory!): [Book]
  }
  
  """
  書本 type
  """
  type Book {
    """
    書本名稱
    """
    title: String
    """
    書本種類
    """
    category: BookCategory
  }
  
  """
  書本種類 enum
  """
  enum BookCategory {
    """
    函數式編程
    """
    FP
    """
    函數響應編程
    """
    FRP
    """
    JavaScript
    """
    JS
  }
`

let books = (_, { category }) => data.filter(x => x.category === category) 

let resolvers = {
  Query: {
    books
  }
}

let apolloServer = new ApolloServer({ typeDefs, resolvers })

apolloServer.listen()
  .then(({ url }) => `GraphQL Server ready at ${ url }`)
  .then(console.log)

server 目錄下建立 index.js

第 1 行

let { ApolloServer, gql } = require('apollo-server')

apollo-server require 進 ApolloServergql

第 3 行

let data = [
  { title: 'FP in JavaScript', category: 'FP'},
  { title: 'RxJS in Action', category: 'FRP'},
  { title: 'Speaking JavaScript', category: 'JS'}
]

原始資料為 array of object,包含 titlecategory 兩個 property。

第 9 行

let typeDefs = gql`
  type Query {
    books(category: BookCategory!): [Book]
  }
  
  type Book {
    title: String
    category: BookCategory
  }
  
  enum BookCategory {
    FP
    FRP
    JS
  }
`

GraphQL 在 server 端第一步是建立 schema,使用的是 GraphQL SDL (GraphQL Schema Definition Language),類似 relational database 使用 SQL 的 DDL (Data Definition Language) 定義 schema。

gql 開頭,後面加上 ES6 的 template string 定義 schema。

Schema 內會定義三種型別:

  • Query:查詢資料的 function
  • Mutation:修改資料的 function
  • Custom Type:自行定義的 type

第 10 行

type Query {
  """
  根據書本種類查詢所有書籍
  """
  books(category: BookCategory!): [Book]
}

type Query 開頭定義 query,目前我們只有一個 books

  • 其 argument 為 category,型別為 BookCategory!! 表示不能為 null,一定得提供,否則 GraphQL 會幫你擋下來
  • 回傳為 Book 型別的 array

GraphQL 為 strongly typed language,須明確定義 type

可在 schema 內以 """ 定義註解,將顯示在 GraphQL Playground 的 Docs 內。

20 行

type Book {
  title: String
  category: BookCategory
}

定義 Book type,包含 object 內的 titlecategory,其中 category 型別為 BookCategory enum。

34 行

enum BookCategory {
  FP
  FRP
  JS
}

定義 BookCategory enum,包含 FPFRPJS 三個 enumeration。

52 行

let resolvers = {
  Query: {
    books
  }
}

GraphQL 在 server 端第二步是建立 resolver,有別於 typeDefs 為 string,resolvers 為 object。

Schema 只定義了 query 與 mutation 的 signature,其實際內容要在 resolver 實作。

定義 Query property,再以 object 定義 books() resolver。

50 行

let books = (_, { category }) => data.filter(x => x.category === category) 

Resolver 依序有 4 個 argument: parentargscontextinfo,讀取 argument 只會用到第二個 args,而 parent 目前用不到可用 _ 表示, contextinfo 目前可忽略。
57 行

let apolloServer = new ApolloServer({ typeDefs, resolvers });

建立 ApolloServer object,將 typeDefsresolvers 組合成 object 傳進其 constructor。

60 行

apolloServer.listen()
  .then(({ url }) => `GraphQL Server ready at ${ url }`)
  .then(console.log)

server.listen() 啟動 Apollo GraphQL,其回傳為 promise,將其 url destructure 之後以 console.log() 顯示。

Apollo server 預設啟動在 4000 port

GraphQL Playground

$ node server/index.js

直接以 Node 執行 Apollo GraphQL。

overview013

http://localhost:4000

Apollo Server 已經內建 GraphQL Playground 供我們測試 GraphQL API 與查詢文件。

query {
  books(category: FP) {
    title
  }
}

使用 books query 查詢資料,category argument 傳入 FP,雖然有 titlecategory 兩個 property,但只要回傳 title property 即可。

overview014

query {
  books {
    title
  }
}

books query 沒傳入任何 argument,GraphQL 會自動幫你攔下來。

overview015

type Query {
  books(category: BookCategory!): [Book]
}

回想我們並沒有在 server 端寫任何判斷 category 是否為 null,只有宣告 categoryBookCategory!,因為有 ! 所以 GraphQL 會自動在 runtime 幫你檢查,而不用寫任何程式碼。

overview016

GraphQL Playground 可自動將 schema 內的註解顯示在 DOCS 內。

Vue Apollo

.env

VUE_APP_GRAPHQL_HTTP=http://localhost:4000

在 project 根目錄建立 .env,設定 Apollo Server 位置。

overview017

App.vue

<template>
  <div>
    <ul>
      <li v-for="(item, index) in books" :key="index">
        {{ item.title }}
      </li>
    </ul>
  </div>
</template>

<script>
import gql from 'graphql-tag';

let books = gql`
  query {
    books(category: FP) {
      title
    }
  }
`;

export default {
  name: 'app',
  apollo: {
    books
  }
}
</script>

12 行

import gql from 'graphql-tag';

Graphql-tag import 進 gql

21 行

export default {
  name: 'app',
  apollo: {
    books
  }
}

在 Vue instance 的 apollo property 內宣告 books query。

14 行

let books = gql`
  query {
    books(category: FP) {
      title
    }
  }
`;

定義 books query 所使用的 GraphQL,將 GraphQL Playground 的 GraphQL 複製貼上即可。

第 1 行

<template>
  <div>
    <ul>
      <li v-for="(item, index) in books" :key="index">
        {{ item.title }}
      </li>
    </ul>
  </div>
</template>

由於 books query 回傳為 array,因此一樣使用 v-for 顯示所有資料。

Yarn Script

package.json

{
  "name": "vue-gql",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "start": "nodemon ./server/index.js",
    "all": "yarn start & yarn serve"
  },
}

在 project 根目錄下的 package.json 增加啟動 Apollo GraphQL 的 Yarn script。

第 9 行

"start": "nodemon ./server/index.js",

增加 start,可輸入 yarn start 可使用 nodemon 啟動 Apollo Server。

當 code 有任何修改,nodemon 會自動重新啟動 node 執行

第 10 行

"all":"yarn start & yarn serve"

增加 all,可輸入 yarn all 同時啟動 Apollo Server 與 Vue。

Start All Server

$ yarn all

使用 yarn all 一次啟動所有 server。

overview018

Summary

Backend

  • 不必再用 code 檢查 argument 是否有傳,檢查 type 正不正確, GraphQL 會幫你擋掉
  • 可盡情提供所有 property,不用擔心網路頻寬問題,前端可自行選擇要使用的 property
  • 為了相容已發佈 REST API,無法刪除 field,導致沒用的 field 越來越多浪費頻寬,除非在 endpoint 加上 v1v2 …,但 GraphQL 只要在 client 移除 field 即可,server 不用修改
  • 不必再使用 Swagger,GraphQL 已經內建 GraphQL Playground
  • Schema 可直接寫 API 註解,GraphQL Playgound 與 client 都可直接顯示
  • Schema 直接整合文件,bug 修改時就可直接修改文件
  • 設計時不必使用 route 與 body 概念,改用與程式設計更接近的 function 與 argument,DX 更佳
  • 在 microservice 架構下,本來就需要 API gateway 角色面對 client,GraphQL 適合做 API gateway,而原有 service 可繼續使用 REST / gRPC / Socket 溝通
  • GraphQL 適當 彌補了 JavaScript 沒型別的缺憾

Frontend

  • 可自行選擇 field 下載,不必再將不必要的資料下載浪費網路頻寬,大幅提升 UX
  • 多個 API 只需使用一次 query 即可,不必多次 API 來回浪費時間
  • 單一 endpoint 就可測試 API 與查詢文件
  • Client 也能讀到 server 寫的 schema 註解
  • 設計時不必使用 route 與 body 概念,改用與程式設計更接近的 function 與 argument,DX 更佳

Conclusion

  • GraphQL 從 2018 年成立 GraphQL Foundation 後,GraphQL 從此脫離 React ecosystem,其他語言 / framework 也能受惠
  • Apollo 平台在使用上比 Facebook 平台簡單,這也是為什麼 GraphQL 在 2018 年開始爆紅
  • GraphQL 特別適合 API 使用對象為 mobile client,若 API 使用對象為其他 service,則 REST / gRPC / Socket 都有它適用場合

Sample Code

完整範例可在我的 GitHub 上找到

Reference

Alex Banks & Eve Porcello, GraphQL 學習手冊
Eve Porcello, GraphQL Query Language
GraphQL
Apollo Docs
Vue Apollo
Academind, REST vs GraphQL - What’s the best kind of API ?
Program With Erik, What is GraphQL and Why You Should Choose It