Swift
GraphQL
apollo

SwiftでGraphQL入門 & Apolloを試す

これはSwift その2 Advent Calendar 2016の16日目の記事です。

作業環境

  • Xcode 9.2
  • Swift 4.0
  • Apollo-iOS 0.8.0
  • GitHub GraphQL API v4

GraphQLとは

GraphQLは2015年のReact.js ConfでFacebookから発表された、クライアントアプリがサーバから必要なデータを取得・やり取りする際、直感的に、かつ柔軟に記述可能なように設計されたクエリ言語(の仕様)です。

簡単な例としてGraphQLのWorking Draftの最初に出てくるものを紹介すると、次のようにGraphQLで記述したクエリに対し

{
  user(id: 4) {
    name
  }
}

レスポンスをJSON形式で次のように返します。

{
  {
  "user": {
    "name": "Mark Zuckerberg"
  }
}

この場合はidのフィールドの値が4であるusernameフィールドを取得する、というクエリですが、このように必要なフィールドをクライアント側で指定し、それだけを受け取ることができます。
サーバ側ではどんな型が存在するか、どんなクエリを受け取れるか等のスキーマを定義しており、アプリケーション毎に決まった型システムを利用できることもGraphQLの特徴で、レスポンスは形式こそ単なるJSONですが、その形式をクライアント側が事前に知ることができます。
その他、個々を詳細に述べるのは避けますが、以下の点が設計思想としてリストアップされています。

  • Hierarchical
  • Product‐centric
  • Strong‐typing:
  • Client‐specified queries
  • Introspective

なお、2016年の9月にはProduction Readyであることが公言されています。

For us at Facebook, GraphQL isn't a new technology. GraphQL has been delivering data to mobile News Feed since 2012.
In recognition of the fact that GraphQL is now being used in production by many companies, we're excited to remove the "technical preview" moniker. GraphQL is production ready.

Leaving technical preview | GraphQL

iOSアプリでの利用事例

FacebookがGraphQLを外向けに発表したのは2015年ですが、内部ではそれ以前から利用していたようです。
FacebookはもともとモバイルアプリをHTML5で書いていました。それをネイティブで置き換えた最初のiOSアプリ(2012年)でGraphQLを利用し始めたそうです。
というよりGraphQL自体が、もともとはこの時にネイティブアプリのために開発されたものである、ということが次の動画でも語られています。

Lee Byron - Exploring GraphQL at react-europe 2015

国内でのiOSアプリ上での利用事例は自分は知りませんが、海外ではArtsy社が自社アプリのeigenで利用しています。

GraphQL for iOS Developers - Artsy Engineering

それ以外の利用事例は調べても見つかりませんでした。GraphQL自体の利用企業はここで確認できるので、もしかしたらこの中で採用している企業があるかもしれません。
(他にプロダクションでの事例を知っている方がいればぜひ教えてください。)

GraphQLを実際に試す

GraphQLを受け付けるAPIサーバに対し、実際にリクエストを投げてみます。
今回はGitHubが公式に公開しているGitHub GraphQL APIを利用します。
(記事の更新時点でv4です。また、この記事では以降このAPIを用いますが、利用するにはDeveloper向けのEarly Accessプログラムに登録している必要があるので注意して下さい。)

GraphQLというクエリでGitHub上のレポジトリを検索し、検索結果の先頭2件について以下の情報を取得するリクエストを投げてみます。

  • レポジトリ名
  • レポジトリのパス
  • レポジトリのURL
  • Star数

クエリは以下のようになります。

{
  search(query: "GraphQL", type: REPOSITORY, first: 2) {
    edges {
      node {
        ... on Repository {
          name
          owner {
            resourcePath
          }
          stargazers {
            totalCount
          }
          url
        }
      }
    }
  }
}

curlでリクエストを送信

curlでこれをAPIサーバに投げてみましょう。
(repositoryをscopeに含んだOAuthトークンを事前に生成しておく必要があります。)

% TOKEN="YOUR_TOKEN"
% curl -H "Authorization: bearer $TOKEN" -X POST -d '
{
  "query": "query { search(query: \"GraphQL\", type: REPOSITORY, first: 2) { edges { node { ... on Repository { name, owner { resourcePath } stargazers { totalCount } url } } } } }"
}
' https://api.github.com/graphql | jq .

jqで整形したレスポンスは以下のようになり、結果が取得できていることが分かります。

{
  "data": {
    "search": {
      "edges": [
        {
          "node": {
            "name": "graphql",
            "owner": {
              "resourcePath": "/facebook"
            },
            "stargazers": {
              "totalCount": 7816
            },
            "url": "https://github.com/facebook/graphql"
          }
        },
        {
          "node": {
            "name": "graphql",
            "owner": {
              "resourcePath": "/graphql-go"
            },
            "stargazers": {
              "totalCount": 2813
            },
            "url": "https://github.com/graphql-go/graphql"
          }
        }
      ]
    }
  }
}

生のURLSessionでリクエストを送信

続いてSwiftでも試してみます。まずは何も考えず、クエリ文字列をそのままURLRequesthttpBodyに突っ込んで生のURLSessionでPOSTしてみます。

let token = "YOUR_TOKEN"
let url = URL(string: "https://api.github.com/graphql")!

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("bearer \(token)", forHTTPHeaderField: "Authorization")

let query = "query { search(query: \"GraphQL\", type: REPOSITORY, first: 2) { edges { node { ... on Repository { name, owner { resourcePath } stargazers { totalCount } url } } } } }"
let body = ["query": query]
request.httpBody = try! JSONSerialization.data(withJSONObject: body, options: [])
request.cachePolicy = .reloadIgnoringLocalCacheData // Avoid 412

let task = URLSession.shared.dataTask(with: request, completionHandler: { data, _, error in
    if let error = error { print(error); return }
    guard let data = data else { print("Data is missing."); return }
    do {
        let json = try JSONSerialization.jsonObject(with: data, options: [])
        print(json)
    } catch let e {
        print("Parse error: \(e)")
    }
})
task.resume()

実行するとJSONにシリアライズされたオブジェクトが出力されますが、 curlの時とほぼ同様なので省きます。
結果は得られるのですが、この段階では以下のような問題があります。

  • クエリが手書き
  • JSONオブジェクトをあらかじめ定義しておいた型にマッピングする必要がある
  • サーバ側で用意している型システムの情報を活用できていない

SwiftでGraphQLを扱うためのライブラリは探すといくつか出てきますが、今回はこの辺りの問題を全て解決できるApollo iOSを試してみます。

Apollo iOSを試す

Apolloとは

ApolloMeteor社が開発しているGraphQLベースのデータスタックです。商用のツールのほか、GraphQLを利用しやすくするオープンソースのライブラリも多数公開しており、その1つとしてiOSクライアント用のライブラリであるApollo iOSがあります。
Apollo iOSは2016年の9月に公開されたばかりで、以下のような特徴があります。

  • GraphQLで記述したクエリからSwiftのコードを自動的に生成できる
  • スキーマに記述されたモデル毎ではなく、クエリ毎にレスポンスをSwiftの型にマッピングできる
  • Xcode上でGraphQLクエリの文法チェックがコンパイルタイムで行える

Apolloを利用したiOSのサンプルプロジェクトは以下で公開されているので、さくっと試すことができます。ローカルのnodeサーバに接続することになるので、そちらの用意も必要です。

今回は1からプロジェクトを作成し、先のGitHub GraphQL APIにリクエストを投げてみます。

前準備

Apollo iOSを利用する手順は以下に記載されているので、基本的にはこちらに従います。

Apollo iOS Guide

今後変わる部分も多いと思うので無理に記述を残さない方が良いかなと思っているのですが、大まかな内容としては以下です。

  • ApolloをCarthage / Cocoapodsで導入
  • apollo-codegenをインストール
  • ビルド時に以下を自動で行うようにする
    • サーバからGraphQLのスキーマを取得しschema.jsonとして保存
    • schema.jsonとプロジェクト中の.graphqlをもとに、GraphQLのクエリ毎のSwiftのモデルを自動生成

Introspectionとapollo-codegen

冒頭で触れましたが、GraphQLではアプリケーションごとに型システムを定義でき、サーバ側はこれをスキーマファイルとして保持します。
Apolloのサンプルアプリで言うと以下がこれに相当します。

https://github.com/apollostack/frontpage-server/blob/master/data/schema.js

この型システムはGraphQLのスキーマ用の言語で書くことができます。詳細は以下を参照して下さい。

Schemas and Types | GraphQL

このスキーマ情報をクライアント側で活用するためには、GraphQLのIntrospectionを活用します。
IntrospectionはGraphQL APIサーバに対し、どのようなクエリを受け付けるのかを問い合わせる機能です。
Introspection自体もGraphQLのクエリとして送信するのですが、Apolloではapollo-codegenという、これを簡単に行うためのツールを用意しています。以下のコマンドで実行し、この場合であればschema.jsonとして出力できます。

apollo-codegen download-schema http://localhost:8080/graphql --output schema.json

GitHub GraphQL APIももちろんIntrospectionに対してのレスポンスを返すことができ、Authorizationヘッダを付与した上で送ることで結果を受け取れます。

apollo-codegen download-schema https://api.github.com/graphql --header "Authorization: Bearer $TOKEN" --output schema.json

結果のschema.jsonの中身を貼りたいところなのですが、現時点で取得したものの行数は25000行弱もあって載せられそうもありません。
とりあえず、今回のサンプルプロジェクト用に生成したファイルへのリンクを貼っておきます。

https://github.com/shingt/GitHub-GraphQL-API-Example-iOS/blob/master/GitHub-GraphQL-API-Example-iOS/schema.json

更にapollo-codegenではこのschema.jsonとプロジェクト中の.graphqlをもとに、そのプロジェクトで必要なGraphQLのクエリ毎のSwiftのモデルを自動的に生成することが可能です。この場合はAPI.swiftに出力されます。

apollo-codegen generate **/*.graphql --schema schema.json --output API.swift

前項の前準備をすることで、この一連の流れをビルド毎に実行することになります。

Apollo iOSを用いてGitHub GraphQL APIにリクエストを送信する

準備が完了したので、Apolloを利用して先に実行していたクエリと同じものを投げてみます。
まずはRepositoriesViewController.graphqlとしてクエリをGraphQLで記述します。
(Apolloではクエリを実行するコンポーネントの名前を.graphql側にも与えることを推奨しています。今回、RepositoriesViewControllerでの実行を想定しているためこのような名前にしました。)

RepositoriesViewController.graphql
query SearchRepositories($query: String!, $count: Int!) {
    search(query: $query, type: REPOSITORY, first: $count) {
        edges {
            node {
                ... on Repository {
                    name
                    owner {
                        resourcePath
                    }
                    stargazers {
                        totalCount
                    }
                    url
                }
            }
        }
    }
}

この状態でビルドすると、先述のapollo-codegenによりこのクエリに対応するモデルが定義されたAPI.swiftが生成されます。
他のクエリ定義も入ってしまっていますが、以下のような形です。

GitHub-GraphQL-API-Example-iOS/API.swift at master · shingt/GitHub-GraphQL-API-Example-iOS · GitHub

手元にIntrospectionによって取得したスキーマ情報があるので、定義された型ごとにモデルを生成することも可能なわけですが、Apolloではこのようにクエリごとに生成します。
GraphQLのスキーマ記述の言語ではnullabilityを記述できるのですが、ここであるフィールドがnon-nullとして定義されていたとしても、リクエストを送信する側がそのフィールドをクエリ中に指定しなかった場合のことを考えると、型ごとにモデルを生成する場合は結局全てのプロパティをOptionalで定義する必要が出てきてしまいます。
一方、クエリごとにモデルを生成すれば、クエリで指定されており、かつスキーマ内でnon-nullとして定義されたフィールドについてはOptionalで定義する必要はなくなり、その後扱いやすくなるというメリットがあります。この辺はApolloの作者の以下の記事で解説があります。

Mapping GraphQL types to Swift

(ちなみにGraphQLで定義したフィールドはデフォルトでnullableです。)

生成したクエリモデルを利用して、先の生のURLSessionで書いたコードを書き直してみましょう。
Apollo iOSではリクエストを送信する際にApolloClientのインスタンスを生成して利用します。

let url = URL(string: "https://api.github.com/graphql")!
let configuration: URLSessionConfiguration = .default
let apollo = ApolloClient(networkTransport: HTTPNetworkTransport(url: url, configuration: configuration))        

実際にクエリを投げるにはfetchメソッドを使用します。

let queryString = "GraphQL"
apollo.fetch(query: SearchRepositoriesQuery(query: queryString, count: 2), resultHandler: { (result, error) in
    // ... 
})

resultHandler内ではどのように結果に対してアクセスできるでしょうか。fetchの定義を見ると以下のようになっています。

public func fetch<Query>(query: Query, cachePolicy: Apollo.CachePolicy = default, queue: DispatchQueue = default, resultHandler: ((Apollo.GraphQLResult<Query.Data>?, Error?) -> Swift.Void)? = default) -> Cancellable where Query : GraphQLQuery

先のresultApollo.GraphQLResult<Query.Data>として受け取ることができます。
GraphQLResultではdataプロパティを、今回の例で言えば先のAPI.swiftに定義されたSearchRepositoriesQuery.Dataの型として持ち、以下のようにしてアクセス可能です。

apollo.fetch(query: SearchRepositoriesQuery(query: queryString, count: 2), resultHandler: { (result, error) in
    if let error = error { print("Error: \(error)"); return }

    result?.data?.search.edges?.forEach { edge in
        guard let repository = edge?.node?.asRepository else { return }
        print("Name: \(repository.name)")
        print("Path: \(repository.url)")
        print("Owner: \(repository.owner.resourcePath)")
        print("Stars: \(repository.stargazers.totalCount)")
    }
})

resultHandler内でrepository.stargazers.totalCountのように、一部は非オプショナルなフィールドとして扱えています。
実際、Introspectionで取得したスキーマ中のstargazersを部分を見てみると、以下のようにkindNON_NULLとなっていたことからも、この挙動は正しそうです。

{
  "name": "stargazers",
  "description": "A list of users who have starred this repository.",
  "args": [
    ...
  ],
  "type": {
    "kind": "NON_NULL",
    "name": null,
    "ofType": {
      "kind": "OBJECT",
      "name": "StargazerConnection",
      "ofType": null
    }
  },
  "isDeprecated": false,
  "deprecationReason": null
},

先のコードを実行すると以下のような結果が得られ、正しくレスポンスを受け取れていることが分かります。

Name: graphql
Path: https://github.com/facebook/graphql
Owner: /facebook
Stars: 7816


Name: graphql
Path: https://github.com/graphql-go/graphql
Owner: /graphql-go
Stars: 2813

一連の流れをまとめると以下のコードになります。省略しましたが、前と同様にAuthorizationヘッダへのtokenの指定等は必要です。

let queryString = "GraphQL"

let configuration: URLSessionConfiguration = .default
configuration.httpAdditionalHeaders = ["Authorization": "Bearer \(token)"]
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData // To avoid 412

let url = URL(string: "https://api.github.com/graphql")!
let apollo = ApolloClient(networkTransport: HTTPNetworkTransport(url: url, configuration: configuration))        
apollo.fetch(query: SearchRepositoriesQuery(query: queryString, count: 2), resultHandler: { (result, error) in
    if let error = error { print("Error: \(error)"); return }

    result?.data?.search.edges?.forEach { edge in
        guard let repository = edge?.node?.asRepository else { return }
        print("Name: \(repository.name)")
        print("Path: \(repository.url)")
        print("Owner: \(repository.owner.resourcePath)")
        print("Stars: \(repository.stargazers.totalCount)")
    }
})

fragment

GraphQLにはfragmentという、クエリ中のコンポーネントを再利用するための形式があります。
例えば先のSearchRepositoriesというクエリについて、Repositoryのname, urlなどのフィールドを指定している箇所(RepositoryDetailsとしました)を他のクエリにも用いたい場合、以下のように分割して定義できます。
(実際に再利用はしておらず、良い例ではないですが。)

RepositoriesViewController.graphql
query SearchRepositories($query: String!, $count: Int!) {
    search(query: $query, type: REPOSITORY, first: $count) {
        edges {
            node {
                ... on Repository {
                    ...RepositoryDetails
                }
            }
        }
    }
}
RepositoryDetails.graphql
fragment RepositoryDetails on Repository {
    name
    owner {
        resourcePath
    }
    stargazers {
        totalCount
    }
    url
}

fragmentは再利用するパターンに有用なほか、例えばあるviewからその下位viewに対してデータを渡す際、簡単に必要なものだけに制限できます。
上の場合で言えば、RepositoriesViewControllerがその下位のCellに渡すのはRepositoryDetailsだけで十分なので、そのようにできます。

以上をまとめ、簡単にviewにも反映させたプロジェクトを以下にあげておきました。

shingt/GitHub-GraphQL-API-Example-iOS

終わりに

この記事ではSwiftで(というかiOSで)GraphQLを利用する例を、Apollo iOSも交えつつ紹介しました。
ほとんどiOS側の話しかしていないですが、実際にGraphQLを導入するとなった時に負担が大きいのはサーバ側であることや(とはいえこのレイヤはクライアント側のエンジニアがある程度面倒見た方が良いと自分は考えてますが)、事例がそれほど多くないことからも敷居は高いですが、興味を持った方がいればぜひ触ってみて下さい。

おまけ1

以下のプラグインを使えば.graphqlのファイルに対し、Xcode上でシンタックスハイライトをつけることができます。

https://github.com/apollostack/xcode-apollo

おまけ2

Swiftからは離れますが、GraphQLを試したい場合は以下で手軽に試すことができます。
GitHubのほか、HackerNews, Reddit, Twitterなどのリソースに対してGraphQLのクエリを投げ、結果を受け取ることが可能です。

GraphQLHub

ちなみに今回利用したGitHubも似たようなものを用意しています。
(実はクエリを構築するときはある程度はこれを用いていました。)

GraphQL API Explorer | GitHub Developer Guide

参考資料