スケーラブルFlutterアーキテクチャ GraphQLのススメ編

8 min読了の目安(約7600字TECH技術記事


の続きです。
番外編的な趣もあるのでなんとなくZennを使って書いてみました。なかなか快適に記事が書けて良いサービスですね。

では本題にうつりましょう。

まず大前提です。
今現在のDartはお世辞にもGraphQLのサポートが手厚いとは言えない状況です。また型システム的にもnull安全性などの機能が劣後しているためGraphQLのメリットを存分に受け取ることは難しいです。

なのでFlutterでGraphQLを採用するには多くの手間が要求されます。

とはいえそれを補ってあまりあるメリットがGraphQLに存在しているので本シリーズでは基本的にGraphQL(知見があるならgRPCでもよい)を推奨しています。

Why GraphQL

人々がGraphQLについて語るとき、そこに生じる暗黙の対立軸はREST APIになるわけです。

個人的に、この対立軸はひと昔前に一部で熱くなる人が生じていたReact vs jQueryの構図に似て、そもそも比較対象にしてはいけねぇよと、そういった物ではないかと。
ツールの用途としては二つとも同等のことができますし、それぞれpros and consがある中で未来はReactにありjQueryは消えていく宿命。
RESTも同様に、GraphQLと同じことをやろうとすれば出来るけれど時代の要請に対してデザインコンセプトがそわなくなってきていると筆者は感じています。まぁこの辺はSwaggerとかOpen API周りを頑張ったことがある(あまり思い出したくはない経験ですね)人なら肌でわかることかとおもいます。

技術的優位点

わかりやすくするためにフロント側の感じるGraphQLの優位点を述べます。

  • 柔軟なデータ取得を前提とした仕様がある

    GraphQLといえば皆さんがぱっと思い浮かべるメリットはこれですね。
    REST APIでも同様のことはできます(それってRESTって読んで良いの?RESTの定義ってふわふわしすぎじゃねえ?)。FacebookのAPIを叩く仕事をしたことがある人ならFQL、Graph APIと聞くだけで辛い思い出が蘇ってくるんじゃないかとおもいます。

    複数リソースの同時取得、バッチリクエスト……仕様を満たしたフレームワークを使えばプロジェクトごとの独自形式を調べることなく簡単に行えます。

  • 非同期対応が最初から仕様に組み込まれている

    同上ですね。RESTでも作るのは簡単(それってRESTって読んで良いの?)ですが仕様化されている恩恵よ。

  • それなりにモダンな型が仕様に組み込まれている

    型イズ大事。雑にサーバーサイドの返り値を変えても大丈夫なんだよねヤッター!
    Swaggerの話はやめてくれたまえ。二度と思い出したくない。

  • Schemaファーストの世界観

    Schemaを先に決めることでサーバーサイドとクライアント側の作業を同時並行で進められるよね、っていうアレのことです。
    理想論じゃない?実運用でうまくいくの?と疑う向きもあるかもしれないですが、少なくとも私が今回担当した開発ではかなりうまくいっていました。
    Schemaファーストと簡単に言っても、うまく動作するには超えるべき技術的ハードルがいくつもあります。いくつかハマりどころはあったものの、各ツールの完成度はかなり高かったですね。

    JSON Schema……?

優位点から生まれる開発上のメリット

REST APIの時代は1リクエスト1リソースが基本でしたね。
SPAでこれを愚直に守った場合、例えばログイン後のトップページの初期化なんかにはそれはもう大量のリクエストが飛び交います。

状態の更新にしても、例えばユーザープロファイルの更新みたいな機能を考えてください。
よくあるのが、更新ページに飛ばなくてもユーザーアイコンだけは簡単に編集できるようになっているケースです。

REST APIでこの要求仕様を満たすのは案外めんどくさいです。大抵はアイコンや名前などの項目に必須のチェックが入っていたりするのでめんどくささは加速です。
一番簡単に実装するのはアイコンだけを更新するためのエンドポイントを実装することですね。

似たようなケースはいくらでも出てきます。
例えばブログの記事、というRESTリソースに対する更新は

  • autherによる各種内容の編集
  • like的なものやコメントなどの付与

などなど、様々なケースが考えられます。ある程度以上の複雑度をもったサービスであれば一つのリソースの更新がかなりの数のバリエーションを持ち、あるいは同時に複数のリソースの更新をして、そしてそれぞれのリクエストのパターンで特有のバリデーションを要求するなんてのはザラです。

モバイルアプリ(あるいはSPA)のように有機的で複雑な挙動をするクライアントアプリケーションにおいて、REST APIが想定しているリソース単位での分割はあまりにも柔軟性に欠けており、あっという間に独自形式による拡張が入り込みます。

何が言いたいかというと、CRUD理想世界におけるTODOアプリ実装と違って現実的なクライアントアプリにおけるAPIリクエストというのはリソースに紐づいたものではなく、ユースケースに対して紐づいているということです。

対してGraphQLやgRPC(筆者はJSON RPC好きでしたよ)は「ユースケースに対するAPIリクエストの構築」というのが無理なく行えます。
個人的にはここがGraphQLを採用する圧倒的なメリットです。

ユースケース云々のくだりは割と主張の強い意見だとおもいます。
しかしここに賛同できない人はGraphQLを使ってもそこまで幸せになれないとおもうので採用には慎重になっても良いかもしれません。

FlutterでGraphQL行為するための手順

0. 依存モジュールのインストール

執筆時点で筆者が使っているモジュール及びバージョンは以下の通りです。
似たような名前の似たような機能のモジュールが乱立しているので気をつけましょう。

  # 実際にGraphQLのリクエストを送るためのクライアントです
  graphql: ^3.0.0
  # .graphqlファイルから上記のgraphqlクライアントが利用できる.ast.dart形式のファイルをコンパイルするためのライブラリです
  gql_code_gen: ^0.1.5
  # よく覚えてないけどGraphQLのパーサーだったとおもいます。gql_code_genが利用しています
  gql: ^0.12.0

Artemisはfragmentsを絡めた動作の不安定さ、ツールの複雑さ & ドキュメントの貧弱さなどの問題が解決出来る気がしなかったので見送りしました。
チャレンジブルな人でかつ余裕のたっぷりあるプロジェクトに属しているのではない限りは避けた方がよさそうです。

gql_code_genは大した機能がない分、build.yamlの内容は非常にシンプルです。

targets:
  $default:
    builders:
      ast_builder:

簡単ですね。
Apollo風にFlutterのcontextにclientを乗っけるライブラリもありますが、メリットがないので採用しませんでした。

1. GraphQLファイルを書く

流派は色々とあるようですが、GraphQLはORMのような内部DSLからのリクエスト自動生成 ではなく 、逆に生のGraphQLを書いてコードを生成することが公式では推奨されています。
これによって言語ごとの対応状況やサポートする型機能の誤差に影響されることなく、スキーマに対するGraphQLリクエストの整合性のチェックなどが行えます。

「別のファイルをいちいち書くなんて嫌だ!ORM方式のほうがいいやんけ!」
そんなふうに考えていた時期が俺にもありました。
やればわかりますがGraphQLファイルを先に書くのは最適解です。

書いたGraphQLファイルの置き場所はプロジェクトそれぞれで決めればいいとおもいますが、grepなどで探索しやすいようにルールを定めておくことをおすすめします。
筆者的には、まず $ mkdir lib/data_sources/graphql/{queries,mutations} という階層を掘り、ファイルを生成するときはそれぞれクエリとミューテーションで .query.graphql というような拡張子にすることをおすすめしていますが、わかりやすければなんでもいいでしょう。

2. スキーマの整合性をチェックできるようにしておく

残念なお知らせですが、筆者の探し方が悪かったのかDartでGraphQLのスキーマ整合性をチェックできるツールはありませんでした。なのでNodeを入れます。

{
  "scripts": {
    "check-schema": "eslint lib/ --ext .query.graphql --ext .mutation.graphql --ext .fragment.graphql"
  },
  "devDependencies": {
    "eslint": "^7.2.0",
    "eslint-plugin-graphql": "^4.0.0"
  },
  "dependencies": {
    "@babel/parser": "^7.10.2",
    "babel-eslint": "^10.1.0",
    "graphql": "^15.1.0"
  }
}

eslintのバージョンとかは古いので良い感じに更新しておいてください。
CIの設定がちょっと面倒になりますが、なんにせよGraphQLの対応度合いでいえばJavaScriptが他に劣る日はこないとおもうのでここは素直に大船に乗りましょう。

3. GraphQLからastファイルを生成する

いつものやつですね。

$ flutter pub pub run build_runner build

freezedなどをフル活用する僕の記事の手順全てにしたがっているとbuild_runnerにかかる時間が日に日に肥大化していき、Dartの実質的コンパイル時間お前このやろーという気持ちになるんじゃないかとおもいますが、必要経費なので諦めましょう。

4. リクエストを送ってみる

リクエストを送るファイルですが、例えば queries/app_required_state.graphql に対しては queries/app_required_state.g.dart が生成されるので、そこに対してさらに手動で queries/app_required_state.dart を作り、その中で行うのがいいでしょう。

// ここはfreezedでJSONのデシリアライズをやってる人にとっては超重要で、Dartのimportはクソ
// 馬鹿だからデフォルトでなんでもかんでもimportして名前被りを起こしてbuild_runnerがコケてしまう。
// 普通のコンパイルエラーと違ってbuild_runnerのエラーは死ぬほどわかりにくいから注意。
import 'package:graphql/client.dart' show QueryOptions;
// これがGraphQLから生成されたastファイル
import 'app_required_state.query.ast.g.dart' as app_required_state_query;

// ......

Future<FetchAppResult> queryAppRequiredResources() async {
  final op = QueryOptions(documentNode: app_required_state_query.document);
  final result = await graphqlClient.query(op);
  return result.map(succeed: (r) => FetchRequiredAppResult.fromJson(r.data as Map<String, dynamic>));
}

FetchRequiredAppResult なんてクラスないけど???とおもった方、そこは自分で書きましょう。
マジです。

Artemisを使えばその辺の生成もやってくれることになっているんですが、そこも考慮した上で諦めているあたりから色々と察してください。
GraphQLの定義ファイルをみながらfreezedでシリアライザを書きましょう。めんどくせーけどRESTよりはマシです。

なお、前の記事などで言及していたnormalize(ノーマライズ)処理もここで行います。

5. "Repository" からこの関数を呼び出す

………改めて過去の記事やコードを読み返していますが、原義のRepositoryパターンとは違う思想で組まれているものなのでRepositoryという命名は良くないですね。後で加筆修正します。

GraphQLリクエストを抽象化した関数ですが、こいつをwidgetなどから直接呼び出すのは避けておきましょう。
"Repository" から呼び出すようにします。

この辺の詳細は話すと長くなるので次の記事で述べます。

おもったよりめんどくさいなとおもったあなたへ

手でシリアライザ/デシリアライザを書くなんて正気か?! とおもった人は多いかとおもいますが、本アーキテクチャではIDを UserID などの強めの型制約をかけたValueObject的な内部表現で扱う点や、Storeとしてアプリケーションでキャッシュするデータはnormalize処理をかけること(つまりリクエストの返したJSONがそのまま使われることはない)などの理由から結局どこかで変換層を設けることになります。
なので実際の手間はそこまで変わらないでしょう。freezedは結構柔軟ですし。

APIとのデータ連携部分というのはクライアントアプリで最もバグが発生しやすい場所ではありますが、どちらにしろGraphQLの型表現力と比べてDartが劣っているので何を使おうが避けられないところです。ここは人間の筋力で解決しましょう。
とはいえGraphQLファイルのスキーマチェックをCIで行っていれば変換部分で失敗してバグることはほぼ起きないんじゃないかとおもいます。

天性のうっかり属性をもった筆者ですが、今のところ本番に持ち越すようなバグがここで発生したことはないです。

おまけ

GraphQLクライアント部分の改造

  1. タイムアウトのサポート

    https://github.com/zino-app/graphql-flutter/issues/327
    デフォルトではサポートしてないので上記あたりを参考に作ります。

  2. 認証方法のカスタマイズ

    https://github.com/zino-app/graphql-flutter/issues/173
    デフォルトではサポートしていないのでこの辺を参考に。
    内部でのきめ細かいエラーハンドリングをやりたい場合も、constructorの引数でコールバックを受け取ってCustomAuthLink内部のcontrollerで行えます。

  3. ベターなエラーハンドリング

    拡張メソッドを使いましょう。

extension GraphQLClientMappable on QueryResult {
  T map<T>({
     T Function(QueryResult result) succeed,
    Exception Function(QueryResult result) failed,
  }) {
    if (!hasException) {
      return succeed(this);
    }
    // ここのくだりは各で良い感じに調整する
    final exp = failed != null
        ? failed(this)
        : GraphQLResultException(
            '${exception.graphqlErrors.map((err) => err.message).join('\n')}'
            '${exception.clientException.toString()}',
          );

    ErrorService.create().showError((context) => exp.toString());
    throw exp;
  }
}

まとめ

  • GraphQLは現代APIサーバー構築の最適解
  • ただしDartでのサポートは限定的
    • Artemisはやめておけ
  • その上でRESTよりもベター、RESTはもう捨てろ
    • FlutterのNNBDサポート次第では今後もっと良くなる
  • がんばれ