GraphQL 教學:為你迭代快速的專案 提供最適合的解決方案!


GraphQL 是一個由 Facebook 開發、而且公開的資料查詢語言。在 client 跟 server 溝通的時候,目前最流行的做法是定義 RESTful API,藉由 client 跟 server 端定義好的無狀態介面,來做資料的交換。RESTful API 雖然被廣為使用,但有著介面不容易修改、版本不好控管等問題。相對於 RESTful API 需要完整的介面定義,GraphQL 則是使用了完全不一樣的做法:由 client 端定義好資料的 query,讓 server 端針對 client 的 query 給予特定的資料。這個做法給予了 client 端最大的彈性:client 端可以直接拉任何想要的資料,不需要等待 server 給出 API 才能夠取得資料,這對於變動非常快速的專案來說,是一個相當好的解決方案!

這篇文章主要會介紹:

  • 為甚麼想要用 GraphQL?
  • GraphQL 的概念與語法
  • 在 iOS 上操作 GraphQL:Apollo iOS
  • 動手做一個簡單的 GraphQL+SwiftUI 專案

底下的內容都會以 SpaceX 的 API 當做基礎,API 已經被打包成教學 server,你可以在這邊找到原始碼。

先來看看 RESTful API

在我們熟悉的 RESTful API 中,假設我們想要取得 SpaceX 裡的火箭列表,我們可能就會針對某個 API endpoint 下一個 GET request,去取得一個包含火箭資料的 JSON,再把 JSON parse 成某個 Swift 的物件。

比方說,在 SpaceX API 裡面,我們想要取得某一次發射的資料,我們可以下一個這樣的 request:

curl -s https://api.spacexdata.com/v3/launches/92

我們會得到一個包山包海的 JSON response:

{
  "flight_number": 92,
  "mission_name": "Starlink 5",
  "rocket": {
    "rocket_id": "falcon9",
    "rocket_name": "Falcon 9",
    …
  },
  … // 資料太............多所以省略
  "last_wiki_revision": "c924fae3-67b8-11ea-b7a8-0e7927e3be09",
  "last_wiki_update": "2020-03-16T19:03:14.000Z",
  "launch_date_source": "launch_library"
}

這個是我們已經非常習慣的 API 的互動方式,雖然已經行之有年,但是這樣的互動方式主要有幾種問題:

  1. 回傳的資料很多都是不需要的
    因為這個 API 可能服務很多的客戶,也不會只用在同一個前端介面,所以一般的做法,是會把所有可能有需要的資訊都塞在同一個 response,但這樣做會讓回傳的資料變得很肥大,尤其是如果回傳的資料是一個 array,裡面任何一個物件多帶的 key/value pair 都會讓 JSON 的大小倍增。
  2. 修改需求十分麻煩
    如果遇到前端修改需求,只要是跟後端資料有關的,都一定要修改 API,這樣就變成每次修改前端需求,都需要同時牽涉到前後端的修改,時程就會拉得很長,一來一往成本也非常高。
  3. 版本控制不易
    市面上的 client 可能不會只有一個版本,我們應該不會希望改了一個 API 讓舊的 client 都掛點。這樣就變成我們需要做 API 版號控制,或者直接開一個新的 API。這樣做會有兩個問題:一是 server 端多開一個 endpoint,也就多少增加了維護的成本;二是 client 也需要對應版本接到不一樣的 endpoint,讓維護變得比較困難。

上面的問題應該是我們平時開發都會遇到的,那有沒有甚麼解法呢?

GraphQL 參上

為了因應上面這樣的情況,Facebook 開發了一套不同於 RESTful 的溝通方式,並且在 2015 年的時候將這個溝通準則公開,也就是我們今天要來研究的 GraphQL!

這是一種資料查詢的語言標準,用來定義與 API 的互動方式。一般的 RESTful API 的溝通方式,是針對需要的功能定義好不同的 API,每一個 API 都有自己的參數跟回傳格式等,client 需要了解某個 API 所需要、和能提供的資料做對應的處理。

而 GraphQL,則是走完全不一樣的路線。它只定義了 server 上有的資料類型,但不會有明確的 API 來限制傳入的參數、跟取出的資料格式。Client 在取資料的時候,只需要跟 server 指定想要取的資料格式、想要的欄位等等,server 就會根據 client 指定的格式回傳對應的資料。

上面講的可能很難理解,讓我們用工程師能夠理解的語言再來解釋一次!

在 server 上我們有這樣的資料:

type Launch {
    id: ID!
    site: String
    mission: Mission
    rocket: Rocket
    isBooked: Boolean!
}

這是某次火箭試射的資料。如果我們想要取得某筆試射資料,我們會用這樣的方式跟 server 要資料:

query GetLaunch {
  launch(id: "92") {
    id
    site
  }
}

這樣代表我跟 server 說:我想要拿 id 是 “92” 的試射資料,並且只想要 idsite 這兩個欄位,不要給我多餘的東西!

而 server 就會乖乖地給我們回傳一個 JSON:

{
  "launch": {
    "id": "92",
    "site": "KSC LC 39A"
  }
}

可以看到,我們得到的資料欄位,就是我們指定的那些,不多也不少,長得也跟我們發出去的請求很像。是不是非常直覺呢!(是是是!)

而一旦 client 能夠直接指定溝通的資料內容,上面提到的 RESTful 問題就迎刃而解了。Server 永遠都只回傳 client 請求的資料,舊版的 client 也不會出現預期以外的資料回傳,不同平台也可以在不修改 server 的情況下,有自己獨立的設計。把這樣的彈性轉移到 client 上面,對於邏輯非常複雜的服務來說,有相當大的好處!

到這邊我們要釐清一個重點,GraphQL 並不是一個後端或前端的 framework,而是一套規範好的語法介面,讓前後端可以根據這套規範來溝通。就好像我們會用 RESTful 當成 API 的設計準則,我們也可以使用 GraphQL 來取代 RESTful,當成是我們 API 的設計準則,定好這樣的準則之後,前後端會各自針對這個規範,實做出能夠符合這樣規範的程式。

接下來,讓我們簡單介紹一下這個的語法吧。

GraphQL 的基本概念跟語法

在 GraphQL,最重要的基礎名詞就只有兩個:SchemaQuery

Schema

GraphQL 透過一個特殊的語法格式,來定義 server 能提供的資料型態,這樣的格式被稱作為 schema。透過 schema,我們可以了解到 server 提供了哪一些資料型態讓我們使用,client 也就可以針對這些資料格式取用所需要的資料。

還記得剛剛火箭發射的範例嗎?對,跟我一樣完全忘記了,我們再來複習一下:

type Launch {
    id: ID!
    site: String
    mission: Mission
    rocket: Rocket
    isBooked: Boolean!
}

這個 Launch 就是我們定義好的資料型態,就像是 Swift 的 class 或 struct 一樣,而 StringInt 也是 GraphQL 上的資料型態。GraphQL 上面有兩種基本的資料型態類型 (type),分別是 Object type 跟 Scalar type。Object 就是任何自定義的物件,像是 Launch 就是其中一種。而 Scalar 就是一些很基本的資料型別,像是 StringInt 等等。

在上面這個 Launch 類別裡的 idsite 等等,我們稱它們為 field(欄位),欄位的後面就是這個欄位的類別,它可以是一個 Object(像是 mission),也可以是一個 Scalar(像是 site)。眼尖的你會發現,這些 field 裡面有一個特別的類別 IDID 也是一種 Scalar,底層其實就是一個字串,只不過 ID 確保了同一個階層裡這個攔位會是唯一的。 另外在這個 ID 後面,還有一個特別的地方,也就是 Swift 攻城獅非常熟悉的驚嘆號 (!)。這個驚嘆號代表的,是這個欄位是一定要有值的 (non-nullable)。

任何時候我們想知道 server 提供哪些資料供我們使用時,只要看 schema 就可以了解了!

Query

現在,我們知道 GraphQL 是怎樣透過 schema 定義資料了,下一步就是要來看看 client 怎麼下達指令,跟 server 要資料或者寫入資料。針對上面 Launch 的這個 schema,我們下達的 query 可以是這樣:

query GetLaunch {
  launch(id: "92") {
    site
  }
}

這段 code 代表的,是跟 server 拿一筆火箭發射的資料,指定 id 需要是 “92”,而且回傳值只需要包含 site 這個 field,這樣就算是一個操作 (operation)。前面的 query GetLaunch 當中,“GetLaunch” 是可以自由指定的名稱 (operation name),而 “query” 就是這次操作的種類 (operation type)。在 GraphQL,總共有三種 operation type,分別是:

  • query:單純的查詢資料
  • mutation:寫入資料到 server
  • subscription:查詢資料,並且在接下來的資料更新時收到新資料

Operation type 跟 name 底下的第一層,一般稱作 Root type 或是 Query type,在 schema 裡面通常會被寫成 type Query 或 type Root。這個可以看做是 GraphQL 的 entry point,每一個 query 都會以 Root type 作為最上面的一層,每一個 query 都需要從 root type 開始。在查詢 GraphQL schema 文件的時候,我們可以先從這個 type 開始看起,了解有哪一些 entry points 是可以被呼叫的。

iOS 上的 GraphQL 實作:Apollo iOS

GraphQL 並不是一個 framework 或者一個 library,它只是一個語言規範,可以在不同的語言上實作。就 iOS 的開發來說,要導入 GraphQL,我們需要 server 跟 client 端的 GraphQL 支援。Server 端已經有相當多的語言實作,就連邊緣開發 Server-Side Swift 都有相對應的實作了,可見支援度之高(誤)。至於 client 端,目前比較穩定且社群最茁壯的實作是 Apollo GraphQL。Apollo 是一家專注在 GraphQL 實作跟平台的公司,它們提供不同語言的前後端 GraphQL 實作,還有一些在 client 端針對 GraphQL 的最佳化。

Apollo iOS 會幫你把 GraphQL schema 定義的類別,轉換成 Swift 的 struct,讓你可以直接在 Swift 裡面操作長得跟 schema 一模一樣的物件,並且還提供介面幫你處理 GraphQL 的操作 (query, mutation, subscription)。

接下來我們要來以 Apollo iOS 為中心,動手撰寫一個以 GraphQL 為溝通基礎的 App!

範例 server:SpaceX GraphQL API

就拿我們一開始提到的 GraphQL server 來開刀,這個範例 server 提供了 SpaceX 火箭計畫的發射記錄,包括任務名稱、火箭名稱、還有一些有趣的徽章。它有個公開的 endpoint:https://apollo-fullstack-tutorial.herokuapp.com,如果直接用 browser 打開它,你可以看到一個互動式的 GraphQL 編輯器:

GraphQL-1

你可以在左邊寫入想要的 query,點下中間的播放鈕之後,就可以在右邊看到查詢的結果。最右邊有兩個小標籤 DOCSSCHEMA,你可以隨時點開來看看這台 server 的 schema 跟文件。

來做一個簡單的 App

利用這個 API,我們可以來做一個很簡單的 App:取得 SpaceX 的發射記錄,點擊每一筆記錄都可以進到細節頁面看更多資訊,它會長得像這樣:

demo-app

為了簡化程式,文章中的程式沒有加上那些酷炫的圖片,有興趣的可以直接快轉到最底下的 source code 參考。

整合 Apollo iOS

好的,接下來我們要來將 Apollo iOS 整合到我們的 App 裡。詳細的安裝方式可以在這裡找到,如果你是用 CocoaPods 管理套件的話,可以直接用我們最熟悉的老朋友:

pod “Apollo”

Apollo iOS 主要有兩個任務:

  1. 讀取來自 server 的 schema 跟專案的 query 指令,並把它們轉換成 Swift 物件。
  2. 利用 schema/query 轉成的物件,發送 http request 到 GraphQL server,處理 cache 等等。

整個整合流程需要以下的步驟:

  1. 從 server 下載 schema,放入專案的主要資料夾。
  2. 新增一個 GraphQL 的 query,並且把它存成 *.graphql
  3. 在 Xcode 的 run script 裡執行轉換 schema/query 的 script。
  4. 把轉換過後的 Swift 原始碼拉進 project,讓它也可以被編譯。

雖然看起來非常複雜,但別擔心,讓我們手把手一起來實作吧(甚麼邪教)!

首先,我們需要先從 server 下載 GraphQL schema 檔案,schema 可以透過上面說到的網頁下載:

GraphQL-schema

點開 schema 的標籤後,你就可以看到右上角有一個 “DOWNLOAD” 的按鈕,點下去之後,你就會得到一個已經轉換成 JSON 格式的 schema 檔 schema.json。我們可以把下載好的 schema.json 放在專案的資料夾。Apollo 預設會去跟 target 相同名稱的資料夾裡面找 schema.json 檔,所以為了方便,我們就直接把 schema.json 放在這邊:

schema.json

注意:底下提到看 schema,指的都是看編輯器上 “SCHEMA” 標籤的內容,而不是 schema.json 的內容。

下一個步驟,我們需要加入我們的第一個 GraphQL query,把它存成 *.graphql 檔加入專案。我們來先寫一個 .graphql 檔,姑且先把它命名為 GetLaunchDetail.graphql,顧名思義就是取得單筆發射資料的 query:

query GetLaunchDetail($id: ID!) {
  launch(id: $id) {
    id
    site
    rocket {
        name
    }
  }
}

在這裡,你會發現 operation name 的部分我們多加了一個 ($id: ID!),這代表這個 query 包含一個輸入的參數 id(需要加上前綴 “$”),它的類型是 ID,而且它不能為 null。另外我們也注意到 rocket 底下還有另外一個巢狀結構,裡面只有一個 field name,這代表我們想要取出 rocket 這個 field 的內容,不過只要包含 name 就好。

這個 GetLaunchDetail.graphql 檔只要在專案資料夾裡面,都可以被 script 找到,習慣上因為我們會需要常常修改這個檔案的內容,所以可以順便引入專案並且放在相關的資料夾,方便在 Xcode 裡面直接修改。

接下來,我們需要在 run script 裡面呼叫 Apollo 提供的 script,讓它去針對 schema.json 跟 GetLaunchDetail.graphql 產生對應的 Swift 原始碼。所以我們在 run script 裡面加入一個 script,並把它拉到 “Compile sources” 的上面:

SCRIPT_PATH=“${PODS_ROOT}/Apollo/scripts”
cd “${SRCROOT}/${TARGET_NAME}”
“${SCRIPT_PATH}”/run-bundled-codegen.sh codegen:generate —target=swift —includes=./**/*.graphql —localSchemaFile=“schema.json” API.swift

這裡你可以看到它會執行一個 run-bundled-codegen.sh 的 script,並且從 ${SRCROOT}/${TARGET_NAME} 裡面去找 schema.json,加上資料夾裡面所有的 .graphql 檔案當成輸入,產生出一個 API.swift

這個 API.swift 就是 Apollo 神奇的地方,它裡面是許許多多 Swift 的 class 或 struct,讓你可以在 Swift project 裡面直接使用強型別的 GraphQL 物件,晚點我會帶大家一起看看這個檔案的內容。

準備好了 schema.json 跟 .graphql 檔之後,我們就可以按下 ⌘ + B 來 build 這個專案。build 完畢之後,在跟 schema.json 同樣的專案資料夾裡面,我們會發現一個新產生的 API.swift,直接把這個 API.swift 拉到我們的專案裡面,Apollo 的前置設定就大功告成啦!最後,記得確認 API.swift 有在 Compile Sources 清單裡,這樣你才能使用自動產生的 schema 跟 query!

一窺 API.swfit

知道 Apollo 會針對我們指定的 schema 跟 query,產生對應的 Swift 的 code 之後,不看看它生成的 code 實在很不放心(是在擔心甚麼?)。我們來看看 Apollo 是怎樣產生這些 code 的吧!

到目前為止,我們放了一個 query 檔 GetLaunchDetail.graphql,它定義了一個名為 GetLaunchDetail 的 query。另外,我們也放了 schema.json,讓我們來看一下 Launch 這個 type 的 schema:

type Launch {
  id: ID!
  site: String
  mission: Mission
  rocket: Rocket
  isBooked: Boolean!
}

打開 API.swift,你會看到綜合上面兩項自動產生的 swift 類別會變這樣(已經移除掉不相關的 code ):

public final class GetLaunchDetailQuery: GraphQLQuery { // 1
  public struct Data: GraphQLSelectionSet { // 2 
    public var launch: Launch? 

    public struct Launch: GraphQLSelectionSet { // 3
      public var id: GraphQLID 

      public var site: String? 

      public var rocket: Rocket? // 4

      public struct Rocket: GraphQLSelectionSet {
        public var name: String?
      }
      // 其它省略
    }
  }
}
  1. 每個 “.graphql” 裡面定義的 query,都會被轉成 postfix 為 Query 的 class。
  2. 回傳的資料都會定義在 Data 這個 struct 裡面。
  3. Data 底下的類別,像是這裡的 Launch,是針對我們下的 query 產生的。剛剛我們在 GetLaunchDetail.graphql 裡面指定要取 idsiterocket 三個 field,所以這邊也就只會有三個 property。如果我們在 .graphql 檔裡面新增或刪除任何 field,在 build 專案之後,這裡面的 struct 也都會相應增加或減少 property。

有了這個 API.swift,我們在 project 裡面的操作都會是強型別的,在撰寫或維護上都會變得相當方便。

做一個簡單的 SwiftUI View

讓我們回來關注一下 UI 吧!因為 GraphQL+Apollo 產生的物件天生就是強型別,加上介面也都被打包好了,很適合直接拿來跟 SwiftUI 一起使用,所以我們就姑且使用 SwiftUI 當成我們這個小專案的 UI 架構吧!

我們的小專案只有兩個頁面:

  • LaunchListView
  • LaunchDetailView

讓我們先從一個簡單到不能再簡單的細節頁面開始做起:

struct LaunchDetail: View {
    @State var id: String = “ — “
    @State var rocketName: String = “ — “
    @State var site: String = “ — “

    var body: some View {
        VStack(spacing: 10) {
            Text(id).fontWeight(.light)
            Text(rocketName).fontWeight(.bold)
            Text(site)
        }.onAppear(perform: loadData)
    }

    private func loadData() {
        // 送出 GraphQL query,並且更新 id、rocketName、還有 site
    }
}

這個細節頁面會從 GraphQL 讀取資料,並且在讀到資料後,修改 State 並且觸發 UI 更新。當然在 production 的環境上我們需要做更完善的職責切分,不過就學習語法來說,我們先以簡單好理解為主就好。(不是我懶)

接著,我們要來實作 loadData 這個 function 的內容。

在 Apollo 上執行 GraphQL 的 query

這個步驟也非常簡單。首先,執行各種 query 或 mutation 的是一個 ApolloClient 物件,官方的建議是將它放在一個 singleton 的物件之中,方便 Apollo 管理狀態跟 cache 等等:

class NetworkService {
    static let shared: NetworkService = NetworkService()
    var apolloClient = ApolloClient(url: URL(string: “https://apollo-fullstack-tutorial.herokuapp.com/“)!)
}

有了這個 singleton 之後,我們就可以在 loadData() 裡面,透過 apolloClient 呼叫 GraphQL 的 API:

func loadData() {
      NetworkService.shared.apolloClient
          .fetch(query: GetLaunchDetailQuery(id: “92”)) { (result) in // 1

          if let launch = try? result.get().data?.launch { // 2
              self.id = launch.id // 3
              self.rocketName = launch.rocket?.name ?? “”
              self.site = launch.site ?? “”
          }
      }
}
  1. GetLaunchDetailQuery 就是我們剛剛在 GetLaunchDetail.graphql 裡面定義的 query operation,已經透過 API.swift 轉換成 Swift 的 class GetLaunchDetailQuery。這個 query 接收一個參數 id,我們暫時直接指定為 “92”。
  2. Request 的 response 會透過一個 closure 回傳,result 是一個型別為 Result<GraphQLResult<GetLaunchDetailQuery.Data>, Error> 的物件,它其實是一個泛型,型態會根據 query 輸入來決定。在這個地方,我們就可以直接把 Launch 物件拿出來使用。
  3. 得到資料後,我們就可以直接修改 @State 更新 UI。

利用上面的這段 code,我們就可以發一個簡單的 request,並且讓 UI 對應更新。因為 Apollo 已經把 server 回傳的資料類型轉換成 Swift 的物件,使用上就會變得非常方便!

Connection 跟 Pagination

在完成了細節頁面之後,是時候要來做我們的主頁面了。主頁面是顯示近期發射記錄的列表,並且希望能支持無限滾動。

第一個問題是,我們要知道發射記錄的 query 要怎麼下!還記得 Root/Query type 嗎?我們可以從 schema 的 type Query,來知道有哪一些 query 可以發:

type Query {
  launches(
    pageSize: Int
    after: String
  ): LaunchConnection!
  launch(id: ID!): Launch
  me: User
}

launch(id: ID!): Launch 就是我們剛剛使用的 field。很明顯從 launches 這個 field 就可以取得發射列表。不過你應該會注意到,這個 field 有個特別的類別: LaunchConnection,這是 GraphQL 裡面的一個特殊的 type,叫 Connection type,在名稱上都會以 “Connection” 為節尾,這個 connection type 存放著跟分頁相關的資訊,可以把它視為是某一頁資料,它的定義長這樣:

type LaunchConnection {
  cursor: String!
  hasMore: Boolean!
  launches: [Launch]!
}

注意:GraphQL 規範上 Connection 需要實作 pageInfo 跟 edges 這兩個 field,但我們使用的範例 server 為了簡化流程,直接使用自定義的 field 取代。更完整的 Connection 代表的是一個 graph,graph 包括了許多的 nodes(資料)跟 edges(連接到某個 node 的一條線)。可以參考官方的文件以獲得更多資訊。

一般我們在做分頁的時候,有兩種做法:

  1. Offset-based pagination
    在切換頁面時指定 offset 跟 page size,如果一頁有五筆資料的話,第二頁的 offset 就是 5、第三頁的 offset 就是 10,以此類推。Offset-based 的做法雖然非常直觀,但有可能會遇到一個問題,就是如果我們在取第三頁的同時,有人刪除了第二頁的某一筆資料,這時候指定 offset 10 拿到的資料會跟原本的不太一樣,會少拿開頭的那一筆。
  2. Cursor-based pagination
    GraphQL 官方推薦使用另外一種做法,叫做 cursor-based pagination。跟 offset 不一樣,cursor-based pagination 在每一頁的資料裡面,都會帶上一個名稱為 cursor 的 field,我們要取下一頁的資料之前,會把這個 cursor 傳進去當參數, server 就會知道接下來要取這個 cursor 之後的資料。這個 cursor 通常就是一個 String。

實作上,GraphQL並沒有限制你一定要用哪一種方法。而在這個範例裡,我們要來學習用 cursor-based 的做法。

首先,我們先來定義一下我們列表的 query,針對上面的 schema 資訊,我們可以這樣寫:

query GetLaunches($cursor: String) {
  launches(after:$cursor) {
    launches {
      id
      rocket {
        name
      }
    }
    hasMore
    cursor
  }
}

這個 query 的參數只有一個,就是取下一頁時需要用到的 cursor。在 UI 上我們看到最後一筆資料之後,就會拿這一頁的 cursor,再發一次 request 去取下一頁的資料。話不多說,我們直接來看 code:

struct LaunchList: View {
    @State var lastCursor: String? // 1
    @State var launches: [GetLaunchesQuery.Data.Launch.Launch] = []
    @State var hasMore: Bool = true

    // UI
    var body: some View {
        List(launches) { launch in
            HStack {
                Text(launch.id).bold()
                Text(launch.rocket?.name ?? “”)
            }.onAppear {
                if launch == self.launches.last { // 2
                    self.loadData()
                }
            }
        }.onAppear(perform: loadData)
    }

    // Data
    func loadData() {
        guard hasMore else { return } // 3
        NetworkService.shared.apolloClient.fetch(query: GetLaunchesQuery(cursor: lastCursor)) { (result) in
            if let connection = try? result.get().data?.launches {
                self.launches.append(contentsOf: connection.launches.compactMap { $0 }) // 4
                self.lastCursor = connection.cursor
                self.hasMore = connection.hasMore
            }
        }
    }
}
  1. 在得到每一頁資料之後,我們都會把 cursor 存下來,以用來當下一頁的基準。
  2. 簡單版的無限滾動,只要看到最後一個 cell 就觸發下一頁的 fetch。
  3. 根據上面 LaunchConnection 的 schema,server 會回傳一個「還有沒有下一頁」的資訊,我們可以透過這個資訊決定要不要繼續抓資料。
  4. 每次取到資料之後,都把它們接到原本放資料的 array 裡面。

列表的資料跟 UI 就這樣大功告成啦!(狂刷)

最後我們來把流程串在一起!首先,我們需要讓我們的列表能夠導覽到細節頁面:

struct LaunchList: View {
    var body: some View {
        NavigationView {
            List(launches) { launch in
                NavigationLink(destination: LaunchDetail(id: launch.id)) {
                    HStack {
                        Text(launch.id).bold()
                        Text(launch.rocket?.name ?? “”)
                    }
                }.onAppear {
                    if launch == self.launches.last {
                        self.loadData()
                    }
                }
            }
        }.onAppear(perform: loadData)
    }
}

我們加上了 NavigationView 跟 NavigationLink,並且把 launch.id 當成參數傳入 LaunchDetail 這個頁面。然後,我們再修改一下 LaunchDetail:

struct LaunchDetail: View {
    var id: String

    // 省略

    private func loadData() {
        NetworkService.shared.apolloClient.fetch(query: GetLaunchDetailQuery(id: id)) { (result) in
        // 省略
        }
    }
}

這樣就可以按照我們點擊的內容抓取對應的資料了!

Mutation

基本上 mutation 的寫法跟 query 幾乎是完全一樣的,只不過對 query 來說,我們需要對 ApolloClient 呼叫 .fetch(…) 這個 function;而對 mutation 來說,我們需要呼叫 .perform(…) 這個 function。

而整個 API 的整合流程也很類似,我們從 schema 裡面可以看到 mutation 的 Root type:

type Mutation {
  bookTrips(launchIds: [ID]!): TripUpdateResponse!
  cancelTrip(launchId: ID!): TripUpdateResponse!
  login(email: String): String
  uploadProfileImage(file: Upload!): User
}

從這個 schema 我們可以看到有四個 entry point 可以使用。以 login 為例,我們可以寫出這樣的 query:

mutation Login($email: String!) {
  login(email: $email)
}

這個 mutation 有一個 non-nullable 的參數 email,而成功後會回傳一個型別為 String 的 token。現在我們來快速示範一下一個 SwiftUI 的簡單登入頁面:

struct LoginView: View {
    @State var email: String?

    var body: some View {
        VStack {
            TextField(“Please input email”, text: $email) // 1
            Button(action: {
                self.login()
            }) {
                Text(“Submit”)
            }
        }.frame(width: 200)
    }
    func login() {
        if let email = email, !email.isEmpty { // 2
            NetworkService.shared.apolloClient.perform(mutation: LoginMutation(email: email)) { (result) in
                if let token = try? result.get().data?.login {
                    print(“Token \(token)”)
                }
            }
        } else {
            print(“No input!!”)
        }
    }
}
  1. 這邊利用一個 binding 來接收使用者輸入的資料。
  2. 你會注意到,如果我們的 query 或 mutation 的參數是 non-nullable,對應的 Swift 類別就一定是 non-optional,這樣在 compile time 就可以確保我們送出去的 query 都會是正確的,這也是 Apollo 這個 framework 對 Swift 工程師來說最大的好處之一。

上面這個 UI 達到的效果,就是按下送出之後,會呼叫一個 GraphQL 的 mutation 操作,並且把回傳的資料印在 console。

Client-side Cache

最後,Apollo iOS 還有一個非常方便的功能,就是 client 端的 cache。Apollo 會針對 query 的內容把資料存在 client 端,下次在取用一模一樣 query 時,就可以從 cache 裡面直接取用資料。

ApolloClient.fetch(…) 這個 function 預設是會 cache 資料的,如果它有找到 cache 資料,就在 callback closure 裡面回傳 cache 的資料;如果沒有,就會真的發一個 request 出去。這個 function 裡面有一個特別的參數 cachePolicy,可以指定預設值以外的行為模式,它可能的值有下面幾種:

  • .fetchIgnoringCacheData:不使用 cache,但是在抓到資料後,還是會把抓到的資料存 cache。
  • .fetchIgnoringCacheCompletely:完全不使用 cache,也不存 cache 資料。
  • .returnCacheDataDontFetch:如果有 cache 資料就回傳 cache,並且在這種狀況下,不再發新的 request 更新 cache 資料。
  • .returnCacheDataAndFetch:如果有 cache 資料就回傳 cache,不過同時也發一個 request 出去撈最新的資料。

現在我們收到一個新的需求:我們希望在剛剛的 detail 頁面裡,如果資料是有 cache 的,就先回傳 cache 的內容,同時再發一個 request 到 server 去要最新的資料,如果資料有更新,就再觸發一次 callback closure 讓頁面更新。這個時候,我們就要用 .watch(…) 這個 function 來取代:

    private func loadData() {
        let watcher = NetworkService.shared.apolloClient.watch(query: GetLaunchDetailQuery(id: id), cachePolicy: .returnCacheDataAndFetch) { (result) in
            if let launchDetail = try? result.get().data?.launch {
                self.rocketName = launchDetail.rocket?.name ?? “”
                self.site = launchDetail.site ?? “”
            }
        }
    }

.watch(…).fetch(…) 最大的不同,就在於 fetch 的 callback closure 只會被呼叫一次;而 watch 的 callback closure,只要在資料有更新的狀況下都會被呼叫。要注意的是,因為這個 watch function 的 callback closure 其實是被存在某個地方,Apollo 沒辦法知道甚麼時候應該要把這個 closure 釋放,所以我們需要手動通知 Apollo 去取消這個行為:

    // UI
    var body: some View {
        VStack(spacing: 10) {
            Text(id).fontWeight(.light)
            Text(rocketName).fontWeight(.bold)
            Text(site)
        }
        .onAppear(perform: loadData)
        .onDisappear(perform: cancelWatcher) // 1
    }

    // Data
    class WatcherContainer { // 2
        var watcher: GraphQLQueryWatcher<GetLaunchDetailQuery>?
    }

    var watcherContainer = WatcherContainer()
    private func loadData() {
        watcherContainer.watcher = NetworkService.shared.apolloClient.watch(…)
        }
    }

    private func cancelWatcher() {
        watcherContainer.watcher?.cancel()
    }
  1. 在這個 view 消失的時候停止 .watch(…) 的 callback。
  2. 利用一個簡單的 wrapper 來存放這個 watcher 的 reference。

這樣一來,原本複雜的「先顯示 cache,再顯示更新後的資料」的邏輯,現在變得相當簡單了!

總結

好的,在看完這篇文章之後,想必你已經能夠掌握:

  • GraphQL 與一般 RESTful API 的差異
  • GraphQL schema 的語法
  • 如何利用自動生成的 code 來快速寫好 query、mutation 等操作
  • GraphQL 的 pagination
  • Apollo 的 cache 機制

GraphQL 在最近幾年一直都非常火紅,絕對是一個非常值得研究的工具。不過技術領域沒有絕對正確的選擇,它也有它的缺點。因為這個東西相對還算新,所以對前後端來說,都需要一段時間學習,並不是能夠馬上切入的。加上,它非常仰賴 schema 的設計,怎麼設計出一個好的 schema、怎麼設計出讓舊有系統也能快速整合的 schema 等,這些都是學習的門檻。另外,對於一些簡單的 API 系統來說,GraphQL 反而會有點過度複雜(對後端來說)。雖然技術的選用需要許多考量,但是它強大的彈性,對於迭代快速、功能複雜的公司來說,還是非常值得嘗試的!

最後,附上一個簡單的範例程式,裡面有比較完整的權責分配和圖片支援等,可以在掌握了基本的 GraphQL 語法之後,再回來參照。歡迎大家針對裡面不完善、有問題、或可以改進的地方,一起來討論或直接發 issue 喔!

參考資料

以下都是非常有趣也有用的文件,本文只介紹了一小部分的 GraphQL,還有很多的規範跟實作都值得再花時間研究!


I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!

blog comments powered by Disqus
Shares
Share This