June 19, 2020

FlutterでGraphQLを実用的に使う

WebやAndorid, iOSでおなじみのApollo Client はFlutterに対応していません。 Flutterでは代わりに、graphql_flutterというApollo Clientをインスパイアしたライブラリがあるのでこれを使います。

ただ、graphql_flutter単体ではGraphQLのSchemaからDartのファイルを生成してくれません。
そのために、artemis というライブラリを使ってSchemaからDartファイル生成します。

シンプルな使い方はgraphql_flutterのドキュメントを読めば書いてあるので、この記事ではprovider を使ったクリーンアーキテクチャっぽい設計のアプリに当てはめて、実際にプロダクトで使えるように説明したいと思います。

サーバ側はGithubのAPIを使いたかったのですが、認証が必要なのでこちらのリポジトリを使わせて頂きます。

国の一覧が取得できるエンドポイントになっています。

  • GraphQL API

Schemaファイルもそのページからダウンロード出来ます。(クラス図 )

シンプルな使い方

導入

graphql_flutterをpubspecに書きます。

dependencies:
  flutter:
    sdk: flutter

  # graphql
  graphql_flutter: 3.0.1

Flutterコード

import 'package:graphql_flutter/graphql_flutter.dart';

void main() {
  final HttpLink httpLink = HttpLink(
    uri: 'https://api.github.com/graphql',
  );

  final AuthLink authLink = AuthLink(
    getToken: () async => 'Bearer <YOUR_PERSONAL_ACCESS_TOKEN>',
    // OR
    // getToken: () => 'Bearer <YOUR_PERSONAL_ACCESS_TOKEN>',
  );

  final Link link = authLink.concat(httpLink);

  ValueNotifier<GraphQLClient> client = ValueNotifier(
    GraphQLClient(
      cache: InMemoryCache(),
      link: link,
    ),
  );

  ...
}
  ...

  return GraphQLProvider(
    client: client,
    child: MaterialApp(
      title: 'Flutter Demo',
      ...
    ),
  );

  ...

READMEに書いてある一番シンプルな書き方はこれです。
GraphQLClientを作ってあげて、それをValueNotifierでWrapしてあげたものを上位のWidget(ここではMaterialApp)から読み込みます。 作成したClientからqueryを呼ぶ形になります。

当然この書き方だと実運用する場合にViewと密結合になってしまうので、このまま使うことは難しいです。 そこでproviderとChangeNotifierを使って実運用に適した形にして使う一例を紹介します。

Provider+flutter_graphql

アーキテクチャ

今回はこのようなアーキテクチャにしました。
名前は便宜上このようになっていますが各階層の責務が別れていれば何でも良いと思います。
今やっているプロダクトではViewModelの層とRepository層の間にUseCase層のようなものを置いているのですが、今回は省略します。

-------      -------------------      ------------       ------------------
| View | <=> |    ViewModel     | <=> | Repository | <=> |    API Client   |
|      |     | (ChangeNotifier) |     |            |     | (GraphQLClient) |
-------      -------------------       ------------       -----------------

導入

モデル作成用のfreezed とproviderを追加します。

dependencies:
  flutter:
    sdk: flutter

  # graphql
  graphql_flutter: 3.0.1
  # provider
  provider: 4.1.3
  # freezed
  freezed_annotation: 0.11.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  # freezed
  json_serializable: 3.3.0
  build_runner: 1.10.0
  freezed: 0.11.0

Flutterコード

Api Client

まずはGraphQLClientを作ります。

class GraphQLApiClient {
  GraphQLApiClient(AppConfig config)
      : graphQLClient = ValueNotifier<GraphQLClient>(
          GraphQLClient(
            cache: InMemoryCache(),
            link: HttpLink(
              uri: config.baseUrl,
              httpClient: LoggerHttpClient(http.Client()), // Logging用HTTP client
            ),
          ),
        );

  final ValueNotifier<GraphQLClient> graphQLClient;

  Future<QueryResult> query(
    String query, {
    Map<String, dynamic> variables,
  }) async {
    final QueryResult result = await graphQLClient.value.query(QueryOptions(
      documentNode: gql(query),
      variables: variables,
    ));

    if (result.exception != null) {
      // エラー処理
      print(result.exception);
      for (final GraphQLError error in result.exception.graphqlErrors) {
        print(error.message);
      }
    }

    return result;
  }
}

QueryOptions にはintervalであったり、Fetch, Error時のオプションがあるので適宜設定してください。ここではシンプルにしておきます。

実際のプロダクトではDev環境, Staging環境など複数の環境が想定されます。そのため、引数に渡している AppConfig にはGraphQLの接続先ホスト名を持ったクラスを渡しています。 ホスト名だったり環境毎に変えたい変数を入れておくと良いでしょう。

class AppConfig {
  const AppConfig({
    @required this.baseUrl,
  });

  final String baseUrl;
}

もう1つ注目して欲しいのはHttpLinkに渡しているHttpClientです。 flutter_graphqlは通常だと通信のログを表示してはくれません。そのため、HttpClientを継承してLoggerHttpClientを作成し、ログを表示できるようにしています。 私は別途Loggerライブラリを使ってリリースビルド時にログを表示しないようにしていますが、単純に bool.fromEnvironment('dart.vm.product') とかを使って表示、非表示を切り替えても良いと思います。(例では出し分けをしていません。)

class LoggerHttpClient extends BaseClient {
  LoggerHttpClient(this._client);

  final Client _client;
  final JsonEncoder _encoder = const JsonEncoder.withIndent('  ');
  final JsonDecoder _decoder = const JsonDecoder();

  @override
  void close() {
    _client.close();
  }

  @override
  Future<StreamedResponse> send(BaseRequest request) {
    return _client.send(request).then((StreamedResponse response) async {
      final String responseString = await response.stream.bytesToString();

      debugPrint('''
=> request: ${response.request.toString()},
=> headers: ${_encoder.convert(response.headers)},
<- statusCode: ${response.statusCode},
<- responseString: ${_encoder.convert(_decoder.convert(responseString))},
      ''');

      return StreamedResponse(
        ByteStream.fromBytes(utf8.encode(responseString)),
        response.statusCode,
        headers: response.headers,
        reasonPhrase: response.reasonPhrase,
        persistentConnection: response.persistentConnection,
        contentLength: response.contentLength,
        isRedirect: response.isRedirect,
        request: response.request,
      );
    });
  }
}

Model

JSONのパーサーを自分で書いても良いのですが、面倒なのでモデルの作成にはfreezed を使います。

@freezed
abstract class Countries with _$Countries {
  const factory Countries({
    @JsonKey(name: 'Country') @required List<Country> countries,
  }) = _Countries;

  factory Countries.fromJson(Map<String, dynamic> json) =>
      _$CountriesFromJson(json);
}
@freezed
abstract class Country with _$Country {
  const factory Country({
    @required String name,
    @required Flag flag,
  }) = _Country;

  factory Country.fromJson(Map<String, dynamic> json) =>
      _$CountryFromJson(json);
}
@freezed
abstract class Flag with _$Flag {
  const factory Flag({
    @required String emoji,
    @required String svgFile,
  }) = _Flag;

  factory Flag.fromJson(Map<String, dynamic> json) => _$FlagFromJson(json);
}

freezedに関しては詳しく説明しません。モデルを作成したらbuild_runnerで生成します。

$ flutter pub run build_runner build

Repository

GraphQLClientにアクセスするためのRepositoryです。
Repositoryでは、freezedを使ってModelにマッピングします。
flutter_graphqlはGraphQLのSchemaからDartファイルを生成する機能は持っていません。そのためqueryはStringで渡すことになります。
Schemaからの自動生成は後で説明します。

abstract class CountryRepository {
  Future<List<Country>> getCountries();
}

class CountryRepositoryImpl implements CountryRepository {
  CountryRepositoryImpl(this._client);

  final GraphQLApiClient _client;

  @override
  Future<List<Country>> getCountries() async {
    final QueryResult result = await _client.query(query);
    return Countries.fromJson(result.data as Map<String, dynamic>).countries;
  }
}

const String query = '''
query {
  Country {
    name
    flag {
      emoji
      emojiUnicode
      svgFile
    }
  }
}
''';

View Model

class HomeModel extends ChangeNotifier {
  HomeModel(this._countryRepository) {
    getCountries();
  }

  final CountryRepository _countryRepository;

  List<Country> countries = [];

  Future<void> getCountries() async {
    try {
      countries = await _countryRepository.getCountries();
      notifyListeners();
    } catch (e) {
      print(e);
    }
  }
}

View

View側ではproviderのselectを使ってViewModelにあるcountriesのリストを監視してListViewに表示しています。

class HomePage extends StatelessWidget {
  const HomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<HomeModel>(
      create: (_) => HomeModel(
        context.read(),
      ),
      child: const _Body(),
    );
  }
}

class _Body extends StatelessWidget {
  const _Body({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('GraphQL Example'),
        ),
        body: _contents(context));
  }

  Widget _contents(BuildContext context) {
    final List<Country> countries =
        context.select((HomeModel model) => model.countries);
    return ListView.separated(
      itemBuilder: (BuildContext context, int index) {
        return _listItem(context, countries[index]);
      },
      itemCount: countries.length,
      separatorBuilder: (_, __) {
        return const Divider();
      },
    );
  }

  Widget _listItem(BuildContext context, Country country) {
    return ListTile(
      title: Text(
        country.name,
        style: Theme.of(context).textTheme.bodyText1,
      ),
      leading: Text(country.flag.emoji),
    );
  }
}
Screen Shot

GraphQL Schemaからの自動生成

flutter_graphqlだけでは、Schemaからの自動生成はしてくれません。そのため、上記ではクエリ用のStringを定義してそれをGraphQL Clientに渡して使いました。 これでは、せっかくのGraphQLのメリットが薄れてしまいます。

そこでartemisというライブラリを使ってSchemaファイルからDartのファイルを自動生成したいと思います。
この記事ではQueryの説明のみですが、もちろんMutationも同じ様に使えます。

導入

dev_dependencies:
  flutter_test:
    sdk: flutter

  artemis: 6.4.4-beta.1

この例では、freezedで既に使っていますが、もし使わない場合はbuild_runnerとjson_serializableも必要です。

Schema定義

今回はプロジェクトのルートディレクトに graphqlディレクトリを作成してそこにschemaとqueryのgraphqlファイルを入れます。

Schemaファイルはここ からダウンロードできます。

クエリ用のgraphqlは自分で用意します。 IntelliJのGraphQLプラグインを使うとSchemaファイルがあれば、サジェストしてくれるのでおすすめです。

query GetCountries {
    Country {
        name
        flag {
            emoji
            svgFile
        }
    }
}

graphqlファイルを用意したら、次に同じくルートディレクトリにbuild.yamlを追加します。

targets:
  $default:
    sources:
      - lib/**
      - graphql/**
    builders:
      artemis:
        options:
          schema_mapping:
            - schema: graphql/schema.graphql
              queries_glob: graphql/countries.query.graphql
              output: lib/generated/countries_query.dart

schemaファイルとqueryのGraphQLファイル, 出力先のdartファイルを指定するだけです。
定義を終えたらbuild_runnerコマンドで生成しましょう。

$ flutter pub run build_runner build

そうすると lib/generated/ にdartファイルが3つ作成されます。

Flutterコード

クエリがDartのコードで用意出来たので、先程のコードを一部変えます。 ArtemisClientというのもあるのですが、今回ArtemisはGraphQLのDocumentNode自動生成のみで使います。

Repositoryはこのように変更しました。

class CountryRepositoryImpl implements CountryRepository {
  CountryRepositoryImpl(this._client);

  final GraphQLApiClient _client;

  @override
  Future<List<Country>> getCountries() async {
    // queryが自動生成されている
    final QueryResult result =
        await _client.query(GetCountriesQuery().document);

    final List<Country> countries =
        GetCountries$Query.fromJson(result.data as Map<String, dynamic>)
            .country
            .map((GetCountries$Query$Country item) => _mapCountry(item))
            .toList();

    // Remove items that failed to parse
    countries.removeWhere((Country value) => value == null);

    return countries;
  }

  // mapperを用意
  // try-catchにすることでリストのアイテムの内1つエラーがあっても全体でエラーにならない
  Country _mapCountry(GetCountries$Query$Country item) {
    try {
      return Country(
        name: item.name,
        flag: _mapFlag(item.flag),
      );
    } catch (e) {
      print('Parse error: $e');
      return null;
    }
  }

  Flag _mapFlag(GetCountries$Query$Country$Flag item) {
    return Flag(
      emoji: item.emoji,
      svgFile: item.svgFile,
    );
  }
}

artemisなしではクエリを手書きしていましたが、query.graphqlファイルからGetCountriesQuery().document というDocumentNodeが生成されています。 Client側にはこちらを渡すようにします。同時にgraphql_client.dart側の引数の型もDocumentNodeに変更しておきましょう。

リクエストのマップ部分ですが、手動でやっています。 一度にパースされるともし一箇所でもエラーが発生した際に全てエラーになってしまい、リスト全てが表示できなくなってしまうからです。 今回の例では起こり得ないと思いますが、こういったことは実サービスではケアしておくべきでしょう。

各層の責務が別れているので、RepositoryとClientの修正だけで動くようになりました。

独自タイプ

今回のSchemaにはないので使っていないのですが、GraphQLのScalarをDartの型にmapすることも可能です。
その場合はscalar mappingをbuild.yamlに追加します。

GraphQLのscalar FooをDart側のBarクラスにマッピングしたい場合はこのように書きます。

targets:
  $default:
    sources:
      - lib/**
      - graphql/**
    builders:
      artemis:
        options:
          scalar_mapping:
            - custom_parser_import: 'package:fluttergraphql/graphql_converter.dart'
              graphql_type: Foo
              dart_type:
                name: Bar
Bar fromGraphQLFooToDartBar(String value) {
  return Bar(value);
}

Foo fromDartBarToGraphQLFoo(Bar bar) {
  return Bar.value;
}

GraphQLからDartへのConverterは、fromGraphQL___ToDart___ の形
DartからGraphQLへのConverterは、fromDart___toGraphQL___ の形で書くのがポイントです。

デメリット

一見便利なようなartemisですが、GraphQLのSchemaにextendsが使えなかったり、fragmentは使えますが生成されたDartファイルの型にクエリ名が入ってしまうので、同じようなクエリのmapperを共通化できない等まだ不十分なところも多いです。
情報もほとんどないので、ソースコードを読んで推測する力が求められます。

まとめ

FlutterでGraphQLを実用的に使う方法をまとめてみました。
FlutterでGraphQLを使っているプロダクトが少ないのか、情報があまりないのも難点です。英語の記事はもちろん日本語記事は執筆時点でGoogle検索に引っかかるのは1つだけでした。
DartはProtoBufを公式サポートしているのでGraphQLよりもそちらを使う場合のほうが多いのかもしれません? (そもそもまだWidgetの使い方みたいな記事ばかりで実践的な内容の記事が少ない)

今回のサンプルで使ったコードはこちらになります。

© AAkira 2023