スタディサプリ Product Team Blog

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

iOS GraphQL クライアントのデバッグツールを作った話

こんにちは、今年の4月から Quipper の iOS エンジニアになった @manicmaniac です。

ちょっと話題としてはニッチかもしれないのですが、今日は Apollo iOS client を利用したアプリで GraphQL のデバッグに苦労し、そしてコードを書いてそれを克服した話をします。

そもそも GraphQL とか Apollo ってなに?

GraphQL とは Web API のクエリ言語であり、またそのランタイム自体を指す言葉です1

対して、Apollo は GraphQL のサーバー・クライアント用のライブラリで、Meteor の開発元でもある Meteor Development Group 社が開発しています。 ApolloiOS 向けクライアントである Apollo iOS は単なる API クライアントを超えて、以下のような機能を備えています。

  • GraphQL スキーマに対応する Swift ソースコードを自動生成するため、型安全に API レスポンスを取り扱える
  • インメモリもしくは SQLite をバックエンドとするキャッシュ機構を備えており、オフライン環境でもクエリを発行できる

今携わっているプロジェクトでは Apollo iOS を介してほぼ全ての API と通信しています。

実際に現場でどう苦労したの?

GraphQL API とのやりとりを圧倒的に省力化してくれる Apollo iOS は素晴らしいライブラリですが、実際に開発をしていると以下のような問題に直面します。

実際にどんなクエリを発行しているのか簡単に知る方法がない

普段の開発、テストや障害発生時の調査など、iOS アプリで投げている query や mutation をサーバーサイドの開発者と共有したいことがあります。

これまでも REST API との通信については標準出力にログを書き出すようにしていましたが、Apollo iOS のようなフルスタックなライブラリでは通信がブラックボックスになってしまい、GraphQL API との通信のログを取ることができていませんでした。

また、仮にそれが可能だったとしても、実機のログの閲覧には iOS の開発環境をある程度整える必要があるため、iOS 開発者以外にとってはハードルが高いという問題がありました。

したがって iOS 開発者はしばしばサーバーサイドの開発者から、「この query はいつどのように使っているか」などの質問を受けては都度調査するような状況でした。

何がキャッシュされているのか外から観測することが難しい

Apollo iOS は賢いキャッシュ機構が特徴的なライブラリですが、実際にキャッシュの中身を見ることは簡単ではありません。 そもそも現行バージョンの Apollo iOS のキャッシュ機構には query を通してアクセスする以外の手段が公開されていないためです。

JavaScript 向けライブラリのデバッグツール

ところで、 Apollo client の JavaScript 実装では、Apollo client devtools なるツールを使って上記の問題を解決できます。

これは React に対する React Developer Tools によく似ていて、Google Chrome ウェブブラウザの拡張機能として提供されています。

この拡張機能はページ上の Apollo client と通信し、以下のような機能を提供します。

  • 組み込みの GraphiQL2 クライアントから任意の query や mutation を発行できる
    • コード補完などの高度な編集機能もついている
  • Apollo client がそのページで発行した query や mutation を記録して、一覧表示できる
    • この一覧から上述の GraphiQL を呼び出すこともできる
  • Apollo client のキャッシュツリー全体を表示できる

これらは開発を進めるうえで非常に便利な機能で、これと同等のものが Apollo iOS でも利用できれば、上述の問題が解決できると考えました。

そのような機能は Apollo iOS からは2019年6月時点では提供されていなさそうだったので、自分で実装してみることにしました。

どうやって実装したの?

実装方針

まず、Apollo client devtools の綺麗に整理された UI や機能をそのまま使うため、UI としては Apollo client devtools をそのまま使うことにしました。 つまりブラウザ上で何らかのウェブページを開き、その上で Apollo client devtools 拡張機能を開けば iOS アプリの Apollo client の情報が表示されるようなイメージです。

この 何らかのウェブページ を提供するウェブサーバーは iOS アプリと通信できる必要があるため、iOS アプリのどこかにウェブサーバーを立てるのが早そうです。

なお、Apollo client devtools は一部 Apollo client 側のイベントを購読している箇所があるので、これを表現するためにサーバーからクライアントに通知する技術も必要です。 WebSocket と迷いましたが、より実装が小さくなりそうな Server-sent events を利用することにしました。

また、JavaScriptApollo client では実装されている query や mutation を記録する機能は Apollo iOS では実装されていないため、これらを実装する必要がありそうです。

そこまでできれば、あとは 何らかのウェブページ 上に あたかも Apollo client のように振る舞うが、実際は iOS アプリと通信して結果を返す JavaScript オブジェクト を置いておけば、Apollo client devtools がよしなにやってくれそうです。

とはいえ、Apollo iOS 自体にこれらのデバッグ用途の機能を実装するのは気が引けました。 製品のコア機能そのものではないので PR が取り込んでもらえるかわからないですし、当時 Apollo iOS の開発リソースは不足気味だったため、この機能にコア開発者の時間を使って欲しくなかったという事情もありました。

そこで、今回は別のライブラリとして実装し、可能な限り Apollo iOS そのものへの変更をせずに実装することにしました。

結果

上記方針で実装したのが ApolloDeveloperKit です。

最初のリリース後に製品に組み込んでテストしてみたところうまく動きそうだったので、そのまま導入し、2ヶ月ほど実用しています。

写真はシミュレーターですが、実機でもこの機能は利用できるようにしています。

apollo-developer-kit-1080p

実装に苦労したところは?

仕様がわからないので実装から推測する必要がある

実装にあたって Apollo client と Apollo client devtools 間の通信をどのようにしているか知る必要がありました。 しかし、これはあまり末端の開発者が利用することを想定されていないのか、仕様としてまとめられていないため、地道にコードを読んだり実際に使いながら break point を打ったりして背後の仕様を推測しました。

一つ簡単な例を挙げると、Apollo client の ApolloClient.tsにあるこのコードとコメントから、 window.__APOLLO_CLIENT__ の存在が devtools の開始に必要なことがわかります。

// Attach the client instance to window to let us be found by chrome devtools, but only in
// development mode
const defaultConnectToDevTools =
  process.env.NODE_ENV !== 'production' &&
  typeof window !== 'undefined' &&
  !(window as any).__APOLLO_CLIENT__;

if (
  typeof connectToDevTools === 'undefined'
    ? defaultConnectToDevTools
    : connectToDevTools && typeof window !== 'undefined'
) {
  (window as any).__APOLLO_CLIENT__ = this;
}

反対に、Apollo client devtools の src/backend/links.js は一見すると何をしているかわからず時間がかかりました。 これは実際には GraphiQL 画面などから Apollo client に操作を依頼し、その結果を受け取る場面で使われており、結局はそのように実装した3のですが、apollo-link-state など独特の概念も登場するため、理解が難しくなっています。

Server-sent events を接続している端末ごとに管理しなければならない

上述のようにサーバーからウェブブラウザ経由で iOS アプリにイベントを通知するために Server-sent events を利用することにしました。 サーバーには複数のブラウザから接続することができるので、その台数分のイベントの送信を管理する必要があります。

もともと利用していた GCDWebServer というライブラリでは Server-sent events のサポートは限定的で、単に chunked encoding が利用できるという程度のものでした4

したがって、複数のブラウザに適切にイベントを通知するためにスレッドセーフなキューをコネクションの数分用意し、これを利用することにしました。 どのキューを利用するかを決定するために HTTP リクエストに当たるオブジェクトをキーにした辞書を用意する必要がありましたが、Swift の Dictionary 型を利用せずキーを弱参照にした NSMapTable を利用する5ことでメモリリークを防いでいます。

Carthage の依存関係管理が難しい

一般に Swift でネストした依存関係を持つライブラリの開発は難しく、容易に dependency hell に突入しがちです。

実は Apollo iOS 自体は3つのライブラリに分かれていて、以下の図のような依存関係を持っています。

dependencies

このうち Apollo 以外はオプションなので、SQLite へのキャッシュ永続化や websocket による通信をサポートしなくても良い場合は省略でき、多くのユーザーがそうしていると思います。

これを CocoaPods ではうまく指定できるのですが、 Carthage には CocoaPods の subspec に相当する機能がない6ため、常に全体をビルドすることになってしまいます。 これは時間の無駄であるだけでなく、孫の依存先である SQLite.swift が semantic versioning に従っていない7ため、これのバージョンを別途指定しないとしばしばビルド自体できなくなります。

上記は Apollo iOS とその依存先の問題ではあるのですが、ApolloDeveloperKitApollo iOS の上に構築する以上、避けられない問題でした。

これはひとまず Apollo iOS の Cartfile にオプションの依存関係を含めないように修正8して、ApolloDeveloperKit のレイヤーでは解決しました。 ただ依存先を含めた根本的な問題を修正するのは難しく、Apollo iOS のドキュメントは Carthage でのインストールが CocoaPods に比べてやや複雑になると明記する9ようになりました。

結局解決したの?

組み込んでから2ヶ月ほど経ちますが、上述の問題が解決しチーム全体の Developer Experience が向上したと実感しています。

もともと問題だと思っていた点以外のものも含め、以下のような効果を得られています。

不具合調査がより早く、正確になった

本番環境で障害が発生した時など、問題を起こしていると考えられる query や mutation を素早く特定できるようになり、問題の切り分けも正確になったと感じています。

これまでは Sentry などに残された手がかりから調査を始めていたため、アプリ側の問題でサーバーに到達できなかった場合は手がかりが少なく調査が難しいことがありました。

現在は query や mutation が失敗した場合のエラーログもこのツールから得ることができるので、調査の初動がスムーズになっています。

誰でも仕様を理解しやすくなった

アプリがいつどんな GraphQL リクエストを送信してどんな結果が返ってくるかリアルタイムにわかるようになったため、iOS アプリのソースコードを読まなくてもある程度の仕様が把握できるようになりました。

サーバーサイド開発者でも閲覧可能なため、彼らから iOS からのリクエストの詳細について質問を受けることも少なくなったように感じています。

Apollo や GraphQL そのものに詳しくなった

これはもともと意図していた効果ではありませんが、実装の過程で JavaScriptiOS 両方の Apollo client の実装を知る必要があり、結果的に詳しくなれました。 内部で実際にどういう動作をしているかや、両プラットフォームでの実装の差異がある部分、あるいはあまり知られていないが有用な機能などをいくつか知ることができました。

終わりに

今回作成したライブラリは純粋に開発・デバッグ用途のもので、こういったものを業務として開発するのは初めての経験でした。

業務として行う開発ではプロダクトに入るコード以外を書くのは優先順位が低くなりがちだったり、あるいはそもそも機会自体なかったりします。 しかし、モバイルチームを支える小さな作業効率化たちの記事にもあるように、Quipper の開発チームはこういった 斧を研ぐ 時間を大事にする雰囲気があり、プロダクトに直接入らない部分についても各自が積極的に改善を進めています。

製品そのものも、またそれを開発する過程自体もまだまだ完璧にはほど遠い状態ですが、こうやって少しずつ改善を重ねてより良いものを生み出していきたいと思っています。


  1. GraphQL の仕様や思想背景はとても興味深いのですが、紙面と私の体力の都合上、ここでその全てを詳らかにすることはできません…。もしご興味があれば公式サイトやインターネット上に多数あるブログポストをご参照ください。
  2. GraphiQL とは GraphQL の typo ではなく、GUI から GraphQL クエリを発行できるツールです。Postman の GraphQL 版のような感じです。
  3. https://github.com/manicmaniac/ApolloDeveloperKit/blob/0.3.0/src/ApolloClientPretender.ts
  4. 現在は自前実装の HTTP サーバーを利用していますが、状況は同じです。
  5. https://github.com/manicmaniac/ApolloDeveloperKit/blob/0.3.0/Sources/Classes/EventStream/EventStreamQueueMap.swift#L16
  6. https://github.com/Carthage/Carthage/issues/588
  7. https://github.com/stephencelis/SQLite.swift/pull/902
  8. https://github.com/apollographql/apollo-ios/pull/635
  9. https://github.com/apollographql/apollo-ios/commit/a4c09a321bf317f9fee291c51bb1546aa0bc9193