スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

GraphQL + Apollo の世界 ~Android 編~

こんにちは。Android アプリ開発者の geckour です。 今回は、Android における GraphQL と Apollo についてお話しします。

はじめに

Quipper では現在新規プロジェクトに取り組んでいて、その技術スタックの議論の中で「GraphQL を使っていきたい」という意見が出ました。
ただ、同プロジェクトの Android チーム内は GraphQL に明るくないメンバーがほとんどで、その選定に乗るか乗らないかの判断が難しい状況だったため、様々な調査・検討を行いました。

本記事では、そんな「GraphQL にあまり詳しくない方」を主対象に調査・検討の成果を簡単にお伝えできればと思います。

GraphQL

まずは GraphQL についてのお話です。

GraphQL を学ぶ

先述の通り弊チームは GraphQL の知見があまりなかったため、勉強会を開くことからスタートしました。

教材としてはこちらの公式チュートリアルを使い、適宜実際にリクエストを発行してみたりしながら学んでいきました。

Schema (型情報) /Query (リクエスト情報) の記法については本記事では割愛するので、気になる方はサッと目を通していただければと思います。

GraphQL ってどんなもの?

GraphQL と REST はよく対比して語られますが、ここでも同様に対比してみようと思います。

まず、REST とは皆さん御存知の通り HTTP の仕様に則ってリソースベースの API を提供するためのアーキテクチャスタイルです。
これに対し、GraphQL とは特定の通信プロトコルを規定せずスキーマベースAPI を提供するためのクエリ言語及びそのランタイムです。

GraphQL での リクエストとレスポンス

つまり、GraphQL をやっていく上で必ずしも HTTP 上で通信をする必要はありませんが、便宜上一般的には HTTP を使うことが多いようです。

プロトコルに HTTP を使う場合、基本的にリクエストには POST メソッドを使うことになります。
また、エンドポイントは慣習上単一のものとする場合が多いです。

これは、GraphQL の大きな特色の一つである「クライアントがサーバから提供される型システムから欲しいデータのみをリクエストできる」という点による影響が大きいのだと思います。

例えば、

type School {
  id: ID!
  name: String!
}

type User {
  id: ID!
  login: String!
  name: String!
  school: School
}

type Query {
  users: [User!]
}

このような型情報が提供されているときに、クライアント的には User のリストがほしいが、その要素としては login しか必要ないというケースがあったとします。

REST だと、レスポンスをマップして捨てるしかないと思いますが、GraphQL ではリクエストを

query Users {
  users {
    login
  }
}

とすれば、login 要素だけを持ったリストが返されます。

GraphQL と クライアントの型システム

ここで問題になってくるのが、クライアントで使用している言語における型システムと GraphQL との相性です。
リクエストの作り方によって返ってくるデータの型が変わってしまうため、Kotlin/Java のような型システムだと愚直に考えるとリクエストのバリエーションの数だけ型を定義する必要が出てきます。

この型の定義を楽にする方法として、サーバ側の型情報・クライアントのリクエスト情報から自動的にクライアントで利用する型を生成するツールを使うというものがあります。
世にはいくつかの同様のツールが公開されていますが、弊チームでは

という点から Apollo について検証をすることに決めました。

Apollo についての検証結果は後述します。

GraphQL と schema driven development

Schema driven development とは、サーバ/クライアントでチームが異なるときの開発スタイルの一種です。
データスキーマを先に定義して、それに基づいてサーバ/クライアントの開発を進めることで一定の効率化を図ります。

新プロジェクトでは現在のところ GraphQL を採用することに決定しているのですが、その動機のひとつとして schema driven で開発をしたいというものがあります。

というのも、API 設計において、これまでは Andoid/iOS チームは積極的には関わらず、Web backend/frontend に最適化しつつ作られたエンドポイントを利用する形で開発してきました。

その結果、Android/iOSユースケースに最適化されていなかったり、フィールドの nullable/non-null が分からなかったり、そもそもそのフィールドはなんのためのものなのか分からなかったり… と様々な問題を生んでいました。

これを解消するために、schema driven development を取り入れつつ

  • Android/iOS/Web が一緒に schema を考える
  • Schema を適切な形で表現する

方法を模索しました。

REST でも OpenAPI などのツールを使うことで schema driven な開発を助けることはできるのですが、

  • GraphQL は強力に型情報を表現・強制できる
  • リクエストを柔軟に作成できるため、プラットフォームごとの最適化もやりやすい

という点から、GraphQL が最適であるという判断に至りました。

Apollo

さて、先述の通り、弊チームではサーバ側の型情報・クライアントのリクエスト情報から自動的にクライアントで利用する型を生成するツールとして Apollo を検証しました。

Apollo ってどんなもの?

Apollo とは、公式ページ曰く

Simplify app development by combining APIs, databases, and microservices into a single data graph that you can query with GraphQL

とありますが、その特徴としてGraphQL の実装や周辺ツールを様々なプラットフォームに対して包括的に提供しているというものがあります。

これは一見便利なようで、しかし今回のように「自動型生成システムだけを利用したい」という場合には不必要な依存を生みがちでデメリットと取ることもできます。
デファクトかつ他に代わりがほぼ存在しないので、何かあったときに大きな障害点となる可能性があるからです。

弊チームでは結果的には自動型生成システムだけでなく Apollo が持つ他の機能も利用することに決めたのですが、利用する際のユースケースに合わせて適切なツールやその利用方法を都度決めていくと良いのかなと思います。

Apollo を学ぶ

弊チームは Apollo に関する知見も持ち合わせていなかったため、色々と試しつつ検証していきました。

その上で、ドキュメントがとても薄いことに苦労させられたのですが、幸い iOSHello World app であるところの frontpage-ios-app を発見したので、こちらの Android 版を探しました。

…が、存在しないことがわかったので、「ならば作ってみよう」と Android で同等のことをやるとどうなるのか?というリポジトリ apollo-frontpage-android-app を作り、公開しました。
現在オーソドックスであると思われるアーキテクチャ構成をいくつかのパターンで実装してありますので、気になる方はチェックしてみてください。

また、新規プロジェクトでの実際的なアーキテクチャでの使い心地や問題点などを検証するために、モックアプリを別途作って検証を進めました。
以下ではそこで得た知見を中心に紹介していきたいと思います。

Apollo Android

ここからは Apollo Android を実際に試してみてわかったことを紹介します。

Apollo Android の Kotlin 対応

Apollo Android の内部実装は徐々に Kotlin 化されているようで、また機能面での Kotlin サポートも結構充実しています。

例えば、オプションを指定すれば自動生成するクラスを data class にできたり、Kotlin Coroutines の Flow の対応が入っていたりします。

とても助かるなあと思っていたのですが、この「Kotlin でのクラス自動生成」が少し曲者でハマりました。

data class の構造

例えば、次のようなスキーマとクエリからクライアントの型を生成するとします。

type School {
  id: ID!
  name: String!
}

type User {
  id: ID!
  login: String!
  name: String!
  school: School
}

type Class {
  id: ID!
  school: School!
  teacher: User!
  students: [User!]!
}

type Query {
  class(classId: ID!): Class!
}
query Class($classId: ID!) {
  class {
    id
    school
    teacher
    students
  }
}

構造的に型を作るとすると以下の様になるかと思うのですが、

data class Class(
    val id: String,
    val school: School,
    val teacher: User,
    val students: List<User>
) {

    data class School(
        val id: String,
        val name: String
    )

    data class User(
        val id: String,
        val login: String,
        val name: String,
        val school: School
    ) {
        data class School(
            val id: String,
            val name: String
        )
    }
}

実際には以下のようなものが作られます。

data class Class(
    val id: String,
    val school: School1,
    val teacher: User1,
    val students: List<User2>
)

data class User1(
    val id: String,
    val login: String,
    val name: String,
    val school: School2
)

data class User2(
    val id: String,
    val login: String,
    val name: String,
    val school: School3
)

data class School1(
    val id: String,
    val name: String
)

data class School2(
    val id: String,
    val name: String
)

data class School3(
    val id: String,
    val name: String
)

階層構造化されていないので名前空間が 1 つしかなく、重複する名前の型にナンバリングの接尾辞がついています。
更に悪いことに、このナンバリングはスキーマやクエリを変更するたびに変わるため、コード上での参照時に苦労する可能性があります。

特に、ドメインモデルを作ってそれに変換するようなユースケースだとかなり大変な思いをすることになるでしょう。

data class DomainClass(
    val id: String,
    val school: DomainSchool,
    val teacher: DomainUser,
    val students: List<DomainUser>
) {

    data class DomainSchool(
        val id: String,
        val name: String
    )

    data class DomainUser(
        val id: String,
        val login: String,
        val name: String,
        val school: DomainSchool
    ) {
        data class DomainSchool(
            val id: String,
            val name: String
        )
    }
}

fun Class.toDomainModel(): DomainClass =
    DomainClass(
        id,
        school.toDomainModel(),
        teacher.toDomainModel(),
        students.map { it.toDomainModel() }
    )

fun School1.toDomainModel(): DomainClass.DomainSchool =
    DomainClass.DomainSchool(id, name)

fun User1.toDomainModel(): DomainClass.DomainUser =
    DomainClass.DomainUser(id, login, name, school.toDomainModel())

fun School2.toDomainModel(): DomainClass.DomainUser.DomainSchool =
    DomainClass.DomainUser.DomainSchool(id, name)

fun User2.toDomainModel(): DomainClass.DomainUser =
    DomainClass.DomainUser(id, login, name, school.toDomainModel())

fun School3.toDomainModel(): DomainClass.DomainUser.DomainSchool =
    DomainClass.DomainUser.DomainSchool(id, name)

大量の変換メソッドが必要な上に、ドメインモデルの階層構造と自動生成クラスのナンバリングの対応関係がスキーマ/クエリの変更によって変わりうるため、その度変換メソッドが壊れてしまいます。

弊チームでは、別の検証において "GraphQL らしいデータハンドリング" を探っていた際に、"利用するコンポーネント単位で fragment を切り、変換せずそのまま使う" という方法を試していました。
この方法は、React においては Apollo が公式に "colocating" として推奨しています。

その過程で、適切に fragment を切れば、プロパティ参照することによってクラス名を意識せずアクセスできることに気づき、現在はこの方法を採用しています。


余談: "GraphQL らしいデータハンドリング" と fragment

GraphQL ではクライアントが欲しいデータをクエリに表現することができます。
つまり、リクエストの段階でドメインが求めるデータを表現しているため、本来ならそもそもドメインモデルというものは必要なく、レスポンスをそのまま使うことができると言えます。

例えば、

type Class {
  id: ID!
  school: School!
  teacher: User!
  students: [User!]!
}

このような型があるとき、teacher の中身は name だけでいいが、students の各アイテムの中身は全フィールドほしい、というケースを考えます。

query Class($classId: ID!) {
  class {
    id
    teacher {
      id
      name
    }
    students
  }
}

このようなクエリを作れば簡単に要件が実現できます。
ここで、このデータを当てる UI 構成が teacher ブロックと students ブロックに分かれていたとすると、それぞれを fragment という表現法で固めてあげると見通しが少し良くなります。

query Class($classId: ID!) {
  class {
    id
    teacher {
      ...Teacher
    }
    students {
      ...Student
    }
  }
}

fragment Teacher on User {
  id
  name
}

fragment Student on User {
  id
  login
  name
  school
}

そして、なんとこれによって User1, User2 という型を使う必要がなくなり、それぞれ TeacherStudent という型で引いてこれるようになります。

GraphQL の interface と Kotlin

GrahphQL のスキーマには interface という記法があります。
これは名前通り、「ある共通要素を持つ型同士を関連付け、共通部分を持つ型を継承した型として表現する」というものです。

御存知の通り、Java には同様のものの表現法として interface が存在しますし、Kotlin には更に sealed classes というものもあります。

しかし、Apollo Android の自動生成においてはそのどちらも使われず、該当部分が nullable なフィールドになるという対応に終わっています。

ここが sealed classes で表現されていれば格好良かったし使うときもシュッとできるのになあと思いつつ、nullable をハンドリングすることで要件自体は満たせるのでなんとか凌いでいます。

スキーマ

interface Animal {
  id: ID!
  height: Int!
  weight: Int!
  age: Int!
}

type Human implements Animal {
  id: ID!
  height: Int!
  weight: Int!
  age: Int!
  name: String!
  languages: [String!]!
  hobbies: [Sring!]!
}

type Cat implements Animal {
  id: ID!
  height: Int!
  weight: Int!
  age: Int!
  isPet: Boolean!
}

type Query {
  animals: [Animal!]!
}

クエリ

query Animals {
  animals {
    id
    height
    weight
    age
    ... on Human {
      name
      languages
      hobbies
    }
    ... on Cat {
      isPet
    }
  }
}

生成された型

data class Animal(
    val id: String,
    val height: Int,
    val weight: Int,
    val age: Int,
    val asHuman: AsHuman?,
    val asCat: AsCat?
)

data class AsHuman(
    val name: String,
    val languages: List<String>,
    val hobbies: List<String>
)

data class AsCat(
    val isPet: Boolean
)

Apollo におけるキャッシュ

Apollo は、Redux における Store のような機能を提供してくれるキャッシュ機構を提供しています。
単なるキャッシュに留まらず、アプリ全体の状態を保持し、それを登録された購読者に通知してくれます。

簡単に使うことができるのですが、何も考えずに導入してしまうと思わぬところでハマる可能性もあるので注意が必要です。

ドキュメントが薄い

このキャッシュ機構を使う際に、レスポンスからキャッシュのキーを解決する必要があり、そのサンプル実装もドキュメントに掲載されています。

さて、GraphQL では、REST で言うところの POST/PUT/DELETE にあたる操作を行うときに mutation というリクエストを発行します。

Mutationスキーマで定義するときにはその返り値の型も指定できるのですが、これを使ってキャッシュ機構を更新しようとすると、ドキュメント通りのキー解決方法を用いるとうまく行きません。

この問題を解決するには少しキーの生成法を工夫し、querymutation のキーを一致させる必要があります。
今回は型の名前とデータの ID を連結する方法を取りました。

val resolver: CacheKeyResolver = object : CacheKeyResolver() {

    override fun fromFieldRecordSet(
        field: ResponseField,
        recordSet: Map<String, Any>
    ): CacheKey = formatCacheKey(field.type.name, recordSet["id"] as String?)

    override fun fromFieldArguments(
        field: ResponseField,
        variables: Operation.Variables
    ): CacheKey = formatCacheKey(
        field.type.name,
        field.resolveArgument("id", variables) as String?
    )

    /**
     * @param id: This will be null when query (fragment) not provides it's own ID
     */
    private fun formatCacheKey(typeName: String, id: String?) =
        id?.let { CacheKey.from("$typeName.$it") } ?: CacheKey.NO_KEY
}

また、別の問題としてキャッシュ管理の細かい調整、例えば特定の型についてキャッシュの生存期間を変更する、といったことをするのは大変であるというものがあります。
例えば、学習コンテンツのデータは頻繁に更新されないので長期のキャッシュが可能であるが、ユーザの取組状況などのデータは基本的にキャッシュを使わないほうが良い、というような状況において問題となります。

提供されている API から無理やり操作することはある程度可能なのですが、ドキュメントにはそもそもそういった API の説明がなかったりするのでそれなりの覚悟が必要です。

Persisted queries

GraphQL には persisted queries という仕組みがあります。

これは、クエリをサーバ側に予め登録しておいて、クライアントからは登録されているクエリを呼び出してもらうリクエストを発行するというものです。

この仕組によって、

  • 自由すぎるクエリの発行を防ぐ
  • HTTP キャッシュが使えるようにする

ということが可能になります。

なぜ「HTTP キャッシュが使える」ようになるのか、むしろなぜこの仕組を使わないと HTTP キャッシュが使えないのか。
それは、HTTP キャッシュは GET リクエストを前提とした仕組みであり、GraphQL on HTTP ではデフォルトで POST リクエストを使うからです。
単一のエンドポイントに流動的なクエリをリクエストをする都合上、リクエストのペイロードが大きくなりがちで GET を使うのが難しく (URL のパラメータとして追加すると文字数制限にかかる可能性が大きく) なります。

ここで persisted queries を導入すると、予め発行されるクエリがわかっている状態、すなわち "専用のエンドポイントが生えた状態" になると見做すことができます。
Persisted queries でも POST を引き続き使用することができますが、同時に GET も使用することができるようになります。

この persisted queries についても Apollo に実装が存在し、更には Automatic persisted queries という便利な仕組みも提供してくれています。

しかし、ここでも Apollo に問題がありました。

Apollo の 実装では、クエリからハッシュを生成してサーバに登録し、クライアントはリクエストにそのハッシュを乗せることでサーバ側でクエリを発行してもらう、という様なものになっています。

ここで重要なのがハッシュ生成のロジックです。
同じクエリから同じハッシュが生成されることが約束されていないと、クエリの管理をそれぞれで行っている場合には persisted queries がうまく動きません。

Apollo iOS にはこのハッシュ生成ロジックにバグがあったので、弊チームの iOS 開発者が PR を作ってくれました。

また、このハッシュ生成ロジックを自前のものにカスタマイズするのはかなり骨が折れるので覚悟したほうが良さそうです。

Apollo にはこういったプラットフォーム間の実装差異がちょくちょく見られるので、なにか機能を取り入れる際には安定版だからとエイヤで入れず、しっかりと検証してから導入することをおすすめします。

おわりに

Schema driven development を強力に助けてくれる GraphQL と、その導入を手厚く助けてくれる Apollo
どちらも結構癖が強いものですが、うまく付き合っていければ色々な問題を解決してくれるかもしれません。

まずは新しい小さなプロジェクトから取り入れてみてはと思います。


こちらの記事を読まれて Quipper に興味をお持ちいただいた皆様、ぜひ Quipper に遊びに来ませんか? 以下 Wantedly ページよりお気軽にご連絡ください! https://www.wantedly.com/companies/quipper/projects