この記事は iOS #2 Advent Calendar 2020 の18日目の記事です。
概要
今業務でApollo-iOSを導入してます。
ネット上のいろんなサイトを参考し実装することができましたが、思い返すとApollo-iOSの使い方について言及されている記事は多いのですが、実際使ってみての感想はあまり見たことがないので、今回はApollo-iOSのイケてると思った所とイケてないなと思った所について、主観で好き勝手書いてみました。
これからGraphQL導入しようか迷っているという方に流し目に読んでいただけると幸いです.
捉え方は要件や環境によると思うので、当てはまらない場合もあると思います。
また、私の知識が及んでいないゆえの間違いなど気づいた点があればTwitter等でコメントいただけると嬉しいです。
GraphQLとは
簡単に言うとサーバーとクライアントとの通信をする際のプロトコルのようなもので、特定のライブラリやツールを指すものではありません。
詳しくはすでに様々なサイトで紹介されているので、参考になるであろうリンクを記載しておきます。
GraphQL.org
世のフロントエンドエンジニアにApollo Clientを布教したい
Web API初心者と学ぶGraphQL
Apollo-iOSとは
iOSアプリからGraphQLでサーバーとやり取りするためのライブラリです。
こちらも参考となるリンクを貼っておきます。
Apollo-iOS
Apollo-iOS Doc
イケてる所
サーバーエンジニアとの認識齟齬がなくなる
これはApollo-iOSというよりもGraphQLについてですが、GraphiQLやPlayGroundなどのツールを導入することで、ブラウザ上でリクエストを簡単に試したり、付属するDoc機能でAPIの仕様を簡単に把握することができます。
初めてのGraphQLという本でも紹介されていた サンプルでPlayGroundを実際に触ることができます。
こうしたGraphQLから提供されたツールを利用することで、API仕様についてのレビューをより効率的に行えるようになり、
先にインターフェースの仕様をかっちりと決めて、その後にサーバーとクライアントがそれぞれ実装に入るというスキーマ駆動がしやすい点がイケてました。
また、ページネーションや認証などのよくある処理について、実現パターンがGraphQLコミュニティで策定されており、これを参照することで実装方法や仕様の一貫性を保つことができる点もいいなと思いました。
Interceptorによって通信処理がカスタマイズしやすい
こちらはApollo-iOSについてです。
Apollo-iOSでは、通信、パース、キャッシュの操作などの処理をInterceptorとして個別に定義し、それらを配列にして登録することで順番に処理されるようになります。
デフォルトではLegacyInterceptorProviderが定義されていて、この中にInterceptorの配列が定義されています。
Interceptorプロトコルに準拠した構造体を独自のInterceptorProvider
に組み込むことで、通信前後に任意の処理を行うことができます。
例えば、リクエストヘッダーにアクセストークンを付与することも簡単に行なえます。
イケてない所
エラーはスキーマで定義されない
GraphQLでは、エラーはHTTPステータス200として以下のようなJSON形式で返ってきます。
message
やlocations
の値はライブラリによって自動的に格納されます。
extensions
にはサーバーエンジニアが任意で設定した情報が格納されます。
{
"errors": [
{
"message": "Cannot query field \"n\" on type \"Lift\".",
"locations": [
{
"line": 3,
"column": 5
}
],
"extensions": {
"code": “HogeError”
“message”: “HogeMessage”
}
}
]
}
エラーの情報は正常系とは違いスキーマ等には明示されません。
また、GraphQLの仕様としてかっちり仕様がきまっているわけではないようです。
認証エラーやその他ビジネスロジックに関わる独自のエラーはextensions
に格納されて返ってくるはずですが
何をどのタイミングで返すのか、どんな構造なのかはサーバーエンジニアと認識を合わせる必要があります。
Apollo-iOSでextensions
内のcode
にアクセスするには以下のようにします。
response?.parsedResponse?.errors?[0].extensions?[“code”] == “HogeError”
ここでもし、extensions
内の構造が想定と違ったり、HogeError
をタイポしたりするとエラーハンドリングに失敗してしまうので、個人的な思いとしてはエラーも可能な限りスキーマで管理できたり、型セーフに扱えたら良かったのにと思った次第です。
また、このような200系で返ってくるエラーはResult.failure
ではなくResult.success
に包まれてハンドラーに返ってくる点にも注意が必要です。
各Interceptorのエラー型についてApollo-iOSの内部実装を確認する必要がある
Interceptorにそれぞれ独自のエラーが定義されており( 例: MaxRetryInterceptor)、それぞれの内部実装を確認しながら、Errorをそれぞれの具体的な型にキャストして適したハンドリングをする必要があります。
構造さえ知れば特に不便なこともないんですが、普通にライブラリ内のコードリーディングが求められているので割とハードル高めだなと思った次第です。
DDD的な思想とGraphQLの特長がマッチしない部分がある
GraphQLの思想として「(画面ごとに)必要な情報だけ取得できる」という特長がありますが、
DDD的な思想を元にアーキテクチャを組んでいる場合は以下の点でGraphQLの思想とバッティングするんじゃないかと思います。
- 画面ごと構造体を作る == 画面駆動になってしまう。
- 自動生成されたコードをそのままViewで使いたくない。本来データ層だけをApollo-iOSに依存させたい。
- 自動生成されたコードのモックのデータが用意が難しくてドメイン層のテストがしづらい。
- クエリのプロパティをフラグメントで共通化するとコードの構造も変わる。。API都合の変更でView側に修正を加えたくない。
ということで結局は、アプリ内部で使いやすいドメインオブジェクトにマッピングすることになります。その時は複数の画面でも使用できるようにオプショナルな値を持つことになると思います。そしてマッピングのためには扱いづらい自動生成されたコードと向き合う必要があります。
また、マッピングのテストをする場合自動生成された構造体のモックを作る必要があります。
モックをつくるには、以下のようなイニシャライザに渡す引数を用意する必要があります。
public init(unsafeResultMap: [String: Any?]) {
self.resultMap = unsafeResultMap
}
引数を作る手順は以下です。
-
自動生成されたコードに以下のプロパティがあるので、
public private(set) var resultMap: [String: Any?]
実行中にデバッグコンソールでpo print(resultMap)
で値を表示する。 -
1.の出力はそのままSwiftのコードとしても機能するはずなので、
[String: Any]?
の変数として定義すればコンパイルが通るはずです。(膨大な量のコードになるので、型を明示しないとコンパイルが通らないかもしれません。)
Interceptorの単体テストは難しい
Interceptorの処理は以下のメソッド内部に書くので、テストするためには引数のモックを用意する必要があります。引数は4つとも独自の型で構成されており、正しいモックを作るには内部のコードを知る必要があり、かなりハードルが高いなと思いました。
func interceptAsync<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void)
ちなみに、Assertionについては、RequestChainを継承したモックを作って引数に渡し、chain.proceedAsync()
やchain.handleErrorAsync()
などの実行をトラップするのがいいのかなと思います。
QueryとMutationで実行するメソッドが違う
GraphQLでは特性に応じて主に3種類のオペレーションが存在します。
その内のQueryはサーバーから値を取得するとき、Mutationはサーバーの値を変える時に使用されます。
Apollo-iOSではQueryのリクエストはfetch
、Mutationのリクエストはperform
を使用する必要があるのですが、
fetch
とperform
を単純に両方使用すると同じような処理を2つ実装する必要がでてくるので、共通化するためにちょっと複雑なコードを書く必要がありました。
自動生成したコード内で強制アンラップ祭り
これ原因でクラッシュしたことはありません。ただ、できれば強制アンラップはしてほしくないなって思いました。