FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

iOS アプリから GraphQL API を利用する

こんにちは。GANMA!のiOSアプリを開発している宗像です。

GANMA!では7月にホーム画面をリニューアルしました。新しいホーム画面では、GraphQL の API を利用しています。アプリから GraphQL を利用するまでにやったことなどをまとめました。

GraphQL の学習

GraphQL 導入前に GraphQL の基本的な知識について学習をしました。主にやったのは次の2つです。

  1. 「初めての GraphQL 」を個人で読む
  2. 「Production ready GraphQL」をチームの読書会で読む

1つ目の初めての GraphQL の方は GraphQL が生まれるまでの歴史的な経緯や、グラフ理論の紹介から始まり、クエリやスキーマの仕様、JavaScript でのサーバーとクライアントの実装例までのっている入門にはちょうどいい本でした。開発中にクエリの書き方でわからなくなったときなどはこちらを参照して進めていきました。

2つ目の Production ready GraphQL は名前の通りより実践的な内容が多く、スキーマ設計のベストプラクティスやパフォーマンスモニタリングなど、サーバーサイドの知見が多く書かれていました。こちらはチームのメンバーで担当を決めながら読書会をしました。

クライアントライブラリの検討

GraphQL API と HTTP で通信する場合、リクエストのボディにクエリを渡して送信することでレスポンスが得られます。なのでライブラリの利用は必須ではなくライブラリを導入するかどうかという点から検討を始めました。

https://graphql.org/code/ のページに GraphQL 関連のライブラリが言語ごとに分類されており、ここを参考にしつつ3つの選択肢を検討し、最終的に apollo-ios を使うことにしました。

  1. apollo-ios https://github.com/apollographql/apollo-ios を使う

    現状 iOS の GraphQL Client ライブラリで最も利用されているライブラリです。

    クエリ結果をマッピングする型を生成できる、クエリ結果のキャッシュに対応しているなど機能も豊富です。Apollo が提供している Auto Persisted Query へ対応するのも簡単です。

    一方でまだ Beta 版なため、仕様の変化が早く破壊的な変更が入ることはあります。また生成されるマッピング用の型が Equatable に準拠していないのはやや使いづらいかもしれません。(導入時はBetaでしたが、最近初めてのメジャーバージョンがリリースされたようです!Release 1.0.0 · apollographql/apollo-ios · GitHub

  2. SwiftGraphQL https://www.swift-graphql.com/ を使う

    こちらもマッピングする型を生成することができ、Equatable にも準拠できるのが良い点です。また Swift でクエリを定義できるというのはこのライブラリ独自の機能です。

    クエリ結果キャッシュには対応しておらず、Auto Persisted Query への対応を自分たちで実装しないといけません。Swift でクエリを定義できるのは面白いと思いましたが、クエリを Swift で書く方法を覚える必要があり、Android 側とクエリを共有したかったので今回は採用しませんでした。

  3. クライアントライブラリを使わずに GraphQL APIを利用する

    単純にリクエストのボディにクエリを渡して送信する方法です。

    既存の REST API 用のコードをそのまま使えるので、リクエストに関連するコード、例えば Cookie などのセッション情報の利用、エラー時のリトライ処理、サーバーメンテナンス中の処理などを書かなくてよいというメリットがありました。

    最初はこちらの方針でも良いと思っていたのですが、実際に試してみるとリクエスト結果をマッピングするための型を自分たちで用意するのが案外手間がかかることがわかりました。

    具体的には quicktypeCLI を使って、マッピングする型を生成できないかと試してみましたが、そのままでは使えず手で修正する必要がありました。 GraphQL のクエリが増えるたびにこれを行うのは手間がかかること、また今後は GraphQL のAPI が GANMA! でメインになっていくことを考えると既存コードをそのまま使うよりは GraphQL を利用しやすい基盤を整えるべきではないかということで採用しませんでした。

apollo-ios の利用

プロダクトに導入する前に、公式ドキュメントのチュートリアル(https://www.apollographql.com/docs/ios/v0-legacy/tutorial/tutorial-introduction)を一通り進めました。Apollo はドキュメントとチュートリアルが充実していて取り組みやすかったです。(この記事の内容は apollo-ios v0.51.2 に対応したものです。リンクのチュートリアルは過去のバージョンのものなので、これから使い始める際には v1.0.0 に対応したドキュメントを見るのが良いでしょう。)

ライブラリの使い方としては以下のような手順になります。

  1. クエリを作成し、Hoge.graphql のようなファイル名にしてプロジェクト内に配置する
  2. Build Phase にスクリプトを書くか、swift run で実行できるスクリプトhttps://www.apollographql.com/docs/ios/swift-scripting)があるのでどちらかで GraphQL のスキーマを取得する
  3. Build Phase のスクリプトを書くか、swift run コマンドでマッピングコードを生成する

チュートリアルには Build Phase にスクリプトを書く方法が載っていますが、ビルドのたびに実行する必要はない作業なので GANMA! では Swift のスクリプトを使う方法を選びました。https://github.com/apollographql/iOSCodegenTemplateリポジトリを落としてきて、クエリやスキーマを置くパスなどを適宜変更して利用しました。

また公式ドキュメントでは以下のように ApolloClient クラスを利用する例が載っています。

import Foundation
import Apollo

class Network {
  static let shared = Network()

  private(set) lazy var apollo = ApolloClient(url: URL(string: "http://localhost:4000/graphql")!)
}

/// 利用例
Network.shared.apollo.fetch(query: LaunchListQuery()) { result in
  switch result {
  case .success(let graphQLResult):
    print("Success! Result: \(graphQLResult)")
  case .failure(let error):
    print("Failure! Error: \(error)")
  }
}

GANMA!で使う際はテストしやすくするため、apollo-ios の ApolloClient クラスに直接依存しないように protocol を作成しました。

/// ApolloClient クラスが持つメソッド定義と同じメソッドを持ったプロトコルを作成
protocol NetworkInterface {
    @discardableResult func fetch<Query: GraphQLQuery>(query: Query,
                                                       cachePolicy: CachePolicy,
                                                       contextIdentifier: UUID?,
                                                       queue: DispatchQueue,
                                                       resultHandler: GraphQLResultHandler<Query.Data>?) -> Cancellable

    @discardableResult func perform<Mutation: GraphQLMutation>(mutation: Mutation,
                                                               publishResultToStore: Bool,
                                                               queue: DispatchQueue,
                                                               resultHandler: GraphQLResultHandler<Mutation.Data>?) -> Cancellable
}

/// 実装部を抜粋、一部改変

class Network: NetworkInterface {
    
    func fetch<Query>(query: Query, cachePolicy: CachePolicy, contextIdentifier: UUID?, queue: DispatchQueue, resultHandler: GraphQLResultHandler<Query.Data>?) -> Cancellable where Query: GraphQLQuery {
        apollo.fetch(query: query, cachePolicy: cachePolicy, contextIdentifier: contextIdentifier, queue: queue, resultHandler: resultHandler)
    }

    func perform<Mutation>(mutation: Mutation, publishResultToStore: Bool, queue: DispatchQueue, resultHandler: GraphQLResultHandler<Mutation.Data>?) -> Cancellable where Mutation: GraphQLMutation {
        apollo.perform(mutation: mutation, publishResultToStore: publishResultToStore, queue: queue, resultHandler: resultHandler)
    }

    public static let shared = Network()

    private let apollo: ApolloClient

    public init() {
        let client = URLSessionClient(sessionConfiguration: URLSessionConfiguration.default)
        let store = ApolloStore(cache: InMemoryNormalizedCache())
        let provider = NetworkInterceptorProvider(client: client, store: store)
                /// Auto Persisted Query をオンにする
        let transport = RequestChainNetworkTransport(interceptorProvider: provider, endpointURL: URL(string: "http://localhost:4000/graphql")!, autoPersistQueries: true)
        self.apollo = ApolloClient(networkTransport: transport, store: store)
    }

Auto Persited Query への対応

GraphQL を導入するにあたって、サーバーサイドから Auto Persisted Query という仕組みに対応してほしい、という要望がありました。これは大きいサイズのクエリを送る回数を減らして通信のパフォーマンスを上げるための仕組みです。一度クエリとハッシュ値をセットでサーバーに送ると、以降はハッシュ値を送ることで対応したクエリのレスポンスを返すといった仕組みなのですが、apollo-ios では RequestChainNetworkTransport のプロパティの autoPersistQueries を true にするだけで、リクエスト時にハッシュ値を送ってくれます。

Combineを使って GraphQLClient を実装

GANMA! では The Composable Architecture(TCA)を利用しており、TCA の Reducer から呼ぶのに Combine を使って Apollo Client をラップした GraphQLClient を実装しました。

protocol GraphQLAPIClient {

    func fetchPublisher<Query: GraphQLQuery>(query: Query) -> AnyPublisher<GraphQLResult<Query.Data>, GraphQLAPIError>

    func performPublisher<Mutation: GraphQLMutation>(mutation: Mutation) -> AnyPublisher<GraphQLResult<Mutation.Data>, GraphQLAPIError>
}

class GraphQLAPIClientImpl: GraphQLAPIClient {
      private let network: NetworkInterface

        func fetchPublisher<Query>(query: Query) -> AnyPublisher<GraphQLResult<Query.Data>, GraphQLAPIError> where Query: GraphQLQuery {

                return Deferred {
            Future<GraphQLResult<Query.Data>, GraphQLAPIError> { [weak self] promise in
                guard let weakSelf = self else { return promise(.failure(.contextDeallocated)) }
                                /// GraphQL API へのリクエストを実行
                weakSelf.network.fetch(query: query, cachePolicy: .fetchIgnoringCacheCompletely, contextIdentifier: nil, queue: .main) { result in
                    switch result {
                    case let .success(graphqlResult):
                        promise(.success(graphqlResult))
                    case let .failure(error):
                        promise(.failure(weakSelf.errorHandler(error)))
                    }
                }
            }
        }
                .handleEvents(
            receiveOutput: { [weak self] result in
                                /// パフォーマンス計測
            },
            receiveCompletion: { [weak self] result in
                                switch result {
                case .failure:
                                         /// エラーログを送信
                case .finished: return
                }
            }
        )
        .eraseToAnyPublisher()
        }
}

async await 対応

ホーム画面を実装してからしばらく経って、TCA が Swift Concurrency に対応したこともあり、以下のように async を使ったインターフェースを追加しました。

public protocol GraphQLAPIClient {
    @MainActor
    func fetchAsync<Query: GraphQLQuery>(query: Query) async throws -> GraphQLResult<Query.Data>

    func fetchPublisher<Query: GraphQLQuery>(query: Query) -> AnyPublisher<GraphQLResult<Query.Data>, GanmaGraphQLAPIError>

    @MainActor
    func performAsync<Mutation: GraphQLMutation>(mutation: Mutation) async throws -> GraphQLResult<Mutation.Data>

    func performPublisher<Mutation: GraphQLMutation>(mutation: Mutation) -> AnyPublisher<GraphQLResult<Mutation.Data>, GanmaGraphQLAPIError>
}

実装は Publisher の方をラップして作成して、リトライや計測部分を再実装しないで済むようにしています。

func fetchAsync<Query>(query: Query) async throws -> GraphQLResult<Query.Data> where Query: GraphQLQuery {
        let canceller = Canceller()
        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                canceller.cancellable = fetchPublisher(query: query)
                    .sink(receiveCompletion: { completion in
                        switch completion {
                        case let .failure(e):
                            continuation.resume(throwing: e)
                        case .finished:
                            break
                        }
                    }, receiveValue: { value in
                        continuation.resume(returning: value)
                    })
            }
        } onCancel: {
            canceller.cancellable?.cancel()
        }
    }

GraphQL 導入の感想

最初にホーム画面のためのクエリを書いたのですが、様々な情報をとる画面の性質上クエリが140行ほどの長さになり、正しいクエリを書くのに時間がかかりました。しかしその後に追加されたクエリは10行程度だったり、ホーム画面実装時に慣れることができたのですぐ書けるようになりました。

画面の小さな修正を行う際に、サーバーの変更を待つことなくクエリを変更しコード生成して画面に表示するという流れが非常にスムーズになり、これは GraphQL 導入のメリットだなと感じています。

参考にした資料