FlutterでAWS AppSyncのSubscriptionを購読したい

## この記事はなに？

- Flutterで
- AWS AppSyncの
- Subscriptionを購読する

ことを目的とした記事だよ。

「意図は分かったから、あとはソースコードだけ見せてくれ」という人は、下記リポジトリを参照してね。

https://github.com/gel1123/otameshi_appsync_subscription

## 用語解説

### AWS AppSync

AWSにおける GraphQL + Pub/Sub API サービスだよ。

### GraphQL

GraphQLはAPIの問い合わせ言語であり、同時にランタイムでもある存在だよ。端的にいえば、Web-APIをSQLのselect文のように扱える存在で、従来のREST-APIとは色々な面で異なる性質を持っているよ。

### Pub/Sub

メッセージを送る『パブリッシャー』と、事前に購読を申し込んで、その都度メッセージを受信する『サブスクライバー』によるメッセージの送受信方式のことだよ。GraphQLにはSubscriptionという機能があって、WebSocketを利用してPub/Sub方式のメッセージ送受信を実現できるよ。GraphQLをサービスとして提供するAWS AppSyncも、このSubscription機能に対応しているよ。

## 筆者プロフィール

Kenpal株式会社でITエンジニアとして色々いじってる faable01 です。

https://www.kenpalinc.com/

かつては創作仲間と小説を書いたり、製菓業界で楽しくやっていたはずが、紆余曲折を経て、サーバーレス技術を触るのが好きなITエンジニアになっていました。AWSのIaC兼サーバレス爆速開発ツール **「SST」** が好きです。個人ブログでもたまに記事を書いています。

https://signote.link

それから、業務日報SaaS **「RevisNote」** を運営しています。リッチテキストでの日報と、短文SNS感のある分報を書けるのが特徴で、組織に所属する人数での従量課金制です。アカウント開設後すぐ使えて、無料プランもあるから、気軽にお試しください。

https://revisnote.com?1710501811980


## 環境構築

まずは雛形プロジェクト作成して、ちゃんと起動するか確認するよ。

```sh
flutter create flutter_otameshi_appsync_subscription
cd flutter_otameshi_appsync_subscription

# iPhoneのシミュレータを起動してから
flutter run
```

雛形が動くことを確認できたら、FlutterでAppSyncを利用するために必要な次のふたつのパッケージをインストールしようか。


```sh
flutter pub add amplify_flutter
flutter pub add amplify_api
```

どちらもAWS Amplifyというサービスのためのパッケージだけど、サービスとしてのAmplifyを使わずにAppSyncだけを使いたい場合でも、結局はこのパッケージを利用することになるよ。

今回の記事では取り扱わないけれど、こういう **『サービスとしてのAWS Amplifyは使わないけど、実装側が利用するライブラリとしてはAmplifyパッケージを使いたい』** というパターンは他にもあって、例えばAWSの認可・認証サービスであるところの **『Amazon Cognito』** 同じようにAmplifyパッケージを介して利用することがあるよ。

今回の記事でも、最小限の構成で行きたいから、あえて **『サービスとしてのAWS Amplifyは使わずに、AppSync単体で利用する方針』** で進めていくからね。

## AWS AppSyncのリソースを作成する

環境構築が終わったら、次は必要なAWSのリソースを作成していこうか。

今回必要となるのは次のふたつのリソースだよ。

- AWS AppSync（AWSにおける GraphQL + Pub/Sub API のサービス）
- Amazon DynamoDB（AWSの代表的なNoSQLデータベース。AppSyncのデータソースとして使う）

とはいえ後者のDynamoDBは、今回のお手軽なやり方だとAppSyncのリソース作成時に自動で生成されるから、特に自分で作成する必要はないよ。

そういうわけだから、さっそくAWSマネジメントコンソールから、AppSyncのリソースを作成していこう。

### 手順1. APIをウィザードで作成

AWSマネジメントコンソールでAppSyncのページに遷移して、メニュー『API』を開くと、画面内に『APIを作成』というボタンがあるはずだから、まずはそれをクリックするよ。

すると次のような画面が開くから『ウィザードで作成』を選んで上にある『開始』ボタンを押してね。

![スクリーンショット 2022-11-04 22.17.59.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/dc507a38-a655-1f83-9a09-df159111f965.jpeg)

### 手順2. モデルを作成

次に進むと今度は『モデルを作成』という見出しの画面が表示される。いくつかフォームが表示されるから、次のように入力するよ。

- モデル名： `otameshi_subscription`
- モデルフィールドを設定: `id: ID (必須)`, `message String`
- モデルテーブルを設定(オプション)： テーブル名 `otameshi_subscriptionTable`, プライマリーキー `id`, ソートキー `None`

![スクリーンショット 2022-11-04 22.23.48.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/df0e5990-b368-4d23-84cb-ce65fca5edc1.jpeg)

ここまで入力したら、画面右下のCreateボタンを押す。

### 手順3. リソースを作成

すると『リソースを作成』という画面になるから、そうしたらAPI名のフォームに `otameshi_appsync_subscription` と入力して、『作成』ボタンを押そう。

![スクリーンショット 2022-11-04 14.54.54.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/4067da3a-ae98-ea28-80f7-9754fe40bd17.jpeg)

これでAppSyncのリソースが作成される。作成されたら、どんなスキーマが用意されているかを一応見てみようか。

定義したモデルに合わせて、次のようなスキーマが作成されているはずだよ。長いからこのあと関係する部分だけ抜粋するね。

```
input CreateOtameshi_subscriptionInput {
	message: String!
}

// ---- 略 ----

type Mutation {
	createOtameshi_subscription(input: CreateOtameshi_subscriptionInput!): otameshi_subscription
    // ---- 略 ----
}

type Query {
    // ---- 略 ----
}

type Subscription {
	onCreateOtameshi_subscription(id: ID, message: String): otameshi_subscription
		@aws_subscribe(mutations: ["createOtameshi_subscription"])
    // ---- 略 ----
}

type otameshi_subscription {
	id: ID!
	message: String!
}

// ---- 略 ----
```

このあとの解説で関係のあるスキーマはこんなところだね。

### 手順4. 設定の確認

次は作成したAppSyncのエンドポイントやAPIキーの設定を見てみよう。

サイドバーの設定メニューから開けるからね。

![スクリーンショット 2022-11-04 22.35.52.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/cfb63daa-5be3-ec30-ec84-9d406b6560f2.jpeg)

こんな風に設定画面からAppSyncのエンドポイント（API URL）や、コールするためのAPIキーを確認できるよ。

この後のFlutterの実装で使うから、自分が作成したAppSyncのエンドポイント（API URL）とAPIキーを今のうちにメモしておこうか。

ちなみに、上記キャプチャのエンドポイントやAPIキーは、この記事を書き終えた頃にはもうリソースごと消えているはずだから、使っても特に意味はないからね。

メモしたら、AWSマネジメントコンソールでやるべきことはここまで。次は早速Flutterの実装に進むよ。

## Flutter実装

### lib/amplifyconfiguration.dart

まずはAppSyncの設定をベースに、Amplifyパッケージを利用するための設定ファイル `lib/amplifyconfiguration.dart` を作成しよう。

```lib/amplifyconfiguration.dart
const amplifyconfig = '''{
  "api": {
    "plugins": {
      "awsAPIPlugin": {
        "otameshi_appsync_subscription": {
          "endpointType": "GraphQL",
          "endpoint": "[AppSyncのエンドポイント]",
          "region": "[AppSyncのリージョン]",
          "authorizationType": "API_KEY",
          "apiKey": "[AppSyncのAPIキー]"
        }
      }
    }
  }
}''';
```

`AppSyncのエンドポイント` と、 `AppSyncのAPIキー` はさっき設定画面でメモしてくれた値を入力するといいよ。

それから、`AppSyncのリージョン` は、東京リージョンなら `ap-northeast-1` だよ。自分が作成したAppSyncのリージョンが分からないという人は、AWSマネジメントコンソールの画面右上のリージョン選択の表示から、自分が作成したリソースがどのリージョンにあるものなのかを確認してね。

### lib/main.dart

`lib/amplifyconfiguration.dart` を実装したら、次はmain.dartを書くよ。AppSyncのスキーマに定義されたサブスクリプション `onCreateOtameshi_subscription` を購読して、誰かが `createOtameshi_subscription` を実行するたびに、その結果を随時受信するようコードを実装していくね。

順を追って解説してもいいけど、かえって分かりにくいだろうから、あえてmain.dartの全文をずらっと書き出しちゃうよ。

```main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_api/amplify_api.dart';
import 'amplifyconfiguration.dart';

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

// 上記でサブスクリプションするアプリを作る
class _MyAppState extends State<MyApp> {
  /// サブスクリプションの結果を受け取るためのStream
  StreamSubscription<GraphQLResponse<String>>? subscription = null;

  /// 受信したJSONを文字列のリストで管理するstate
  List<String> _receivedJsons = [];

  /// Amplifyの初期化を行うメソッド
  void _configureAmplify() async {
    try {
      Amplify.addPlugins([AmplifyAPI()]);
      await Amplify.configure(amplifyconfig);
      print('Amplify configured');
    } catch (e) {
      print('Error occurred while configuring Amplify: $e');
    }
  }

  /// サブスクリプション購読を開始するメソッド
  void _startSubscribe() async {
    const graphQLDocument = '''subscription otameshi_subscription {
      onCreateOtameshi_subscription {
        id
        message
      }
    }''';
    final Stream<GraphQLResponse<String>> operation = Amplify.API.subscribe(
      GraphQLRequest<String>(document: graphQLDocument),
      onEstablished: () => print('Subscription established'),
    );
    setState(() {
      subscription = operation.listen((event) {
        print('Subscription event data received: ${event.data}');
        setState(() {
          // event.dataに現在日時のラベルを [YYYY-MM-DD hh:mm:ss] で追加する
          _receivedJsons.add(
              '[${DateTime.now().toLocal().toString().split('.')[0]}] ${event.data}');
        });
      }, onError: (e) {
        print('Error in subscription stream: $e');
      });
    });
  }

  /// サブスクリプション購読を終了するメソッド
  void _stopSubscribe() {
    setState(() {
      subscription?.cancel();
      subscription = null;
      _receivedJsons = [];
    });
  }

  /// 初期化時にAmplifyの設定を行う
  @override
  void initState() {
    super.initState();
    _configureAmplify();
  }

  /// 画面のフッター近辺にサブスクリプション購読開始/終了ボタンを配置する
  /// また、画面中央には枠線で囲まれたスクロール可能エリアを配置し、
  /// その中にサブスクリプションで受信したJSONを表示する
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: [
            Expanded(
              child: Container(
                margin: const EdgeInsets.fromLTRB(
                  30,
                  60,
                  30,
                  30,
                ),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.black),
                ),
                child: Scrollbar(
                  child: ListView.separated(
                    itemCount: _receivedJsons.length,
                    itemBuilder: (context, index) {
                      return Container(
                        margin: const EdgeInsets.all(20),
                        child: Text(_receivedJsons[index]),
                      );
                    },
                    separatorBuilder: (context, index) {
                      return Container(
                        height: 1,
                        color: Colors.grey[500],
                      );
                    },
                  ),
                ),
              ),
            ),
          ],
        ),
        bottomNavigationBar: BottomAppBar(
          child: Container(
            width: double.infinity,
            height: 80,
            child: TextButton(
              onPressed: () {
                if (subscription == null) {
                  _startSubscribe();
                } else {
                  _stopSubscribe();
                }
              },
              style: ButtonStyle(
                backgroundColor: MaterialStateProperty.all<Color>(
                  subscription == null ? Colors.green : Colors.red,
                ),
              ),
              child: Text(
                subscription == null ? 'サブスクリプションを開始する' : 'サブスクリプションを停止する',
                style: TextStyle(
                  color: subscription == null ? Colors.black : Colors.white,
                  fontSize: 20,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

void main() {
  runApp(MyApp());
}
```

こんな感じだね。ちなみに筆者はFlutterの状態管理をStatefulWidgetしか知らないから、上記のように実装しているけど、もっと今時の優れた状態管理を知ってる人は、自分の好きな状態管理アプローチに置き換えてコードを読む or 実装してね。

さて、全体としてはこの通りだけど、ただ全体像を提示しただけでは分かりにくいと思うから、重要な部分をピックアップするね。

#### Amplifyパッケージ利用のための設定処理

Amplifyパッケージを利用するために `lib/amplifyconfiguration.dart` を実装してもらったけど、これを実際にアプリケーションに組み込むための設定処理は、次の通りだよ。

```dart:main.dartのうち設定処理に関するもの
import 'amplifyconfiguration.dart';

// ---- 略 ----

class _MyAppState extends State<MyApp> {

  // ---- 略 ----

  void _configureAmplify() async {
    try {
      Amplify.addPlugins([AmplifyAPI()]);
      await Amplify.configure(amplifyconfig);
      print('Amplify configured');
    } catch (e) {
      print('Error occurred while configuring Amplify: $e');
    }
  }

  // ---- 略 ----

  @override
  void initState() {
    super.initState();
    _configureAmplify();
  }

  // ---- 略 ----
}
```

initStateのタイミングで `lib/amplifyconfiguration.dart` をAmplify.configureの引数に渡して設定する処理を実行しているんだね。

#### AppSyncのSubscriptionの購読を開始・終了するコード

Subscriptionは引数の指定で、「特定の条件に一致するレコードだけを受信する」ことが可能だけど、今回は単にAppSyncのSubscription購読がちゃんと動くかを確かめたいだけだから、引数は指定なしのまま全レコードを購読するようにしているからね。

さて、さっそくSubscriptionの購読開始・終了処理の実装を見ていくよ。

```dart:main.dartにおけるSubscription購読処理
class _MyAppState extends State<MyApp> {
  /// サブスクリプションの結果を受け取るためのStream
  StreamSubscription<GraphQLResponse<String>>? subscription = null;

  // ---- 略 ----

  /// サブスクリプション購読を開始するメソッド
  void _startSubscribe() async {
    const graphQLDocument = '''subscription otameshi_subscription {
      onCreateOtameshi_subscription {
        id
        message
      }
    }''';
    final Stream<GraphQLResponse<String>> operation = Amplify.API.subscribe(
      GraphQLRequest<String>(document: graphQLDocument),
      onEstablished: () => print('Subscription established'),
    );
    setState(() {
      subscription = operation.listen((event) {
        print('Subscription event data received: ${event.data}');
        setState(() {
          // event.dataに現在日時のラベルを [YYYY-MM-DD hh:mm:ss] で追加する
          _receivedJsons.add(
              '[${DateTime.now().toLocal().toString().split('.')[0]}] ${event.data}');
        });
      }, onError: (e) {
        print('Error in subscription stream: $e');
      });
    });
  }

  /// サブスクリプション購読を終了するメソッド
  void _stopSubscribe() {
    setState(() {
      subscription?.cancel();
      subscription = null;
      _receivedJsons = [];
    });
  }
```

こんな感じだね。GraphQLのクエリを文字列で定義して、Amplify.API.subscribeメソッドの返り値をlistenすることで、Subscriptionを購読しているんだ。購読を終えるときは、同じくAmplify.API.subscribeメソッドの返り値を元にcancelメソッドを実行すればいいよ。

ちなみにSubscriptionの購読にはもう一つ方法があって、ざっくり紹介するとこんな風にfor文をbreakするまで自動で購読し続けさせることができるよ（ https://pub.dev/packages/amplify_api より引用）

```dart:Subscriptionの購読方法その２（今回は採用しなかった。クエリや処理内容はサンプルのままであり、ここまでに実装してきた処理とは別物です）
Future<void> subscribe() async {
  final graphQLDocument = '''subscription MySubscription {
    onCreateBlog {
      id
      name
      createdAt
    }
  }''';
  final Stream<GraphQLResponse<String>> operation = Amplify.API.subscribe(
    GraphQLRequest<String>(document: graphQLDocument),
    onEstablished: () => print('Subscription established'),
  );

  try {
    // Retrieve 5 events from the subscription
    var i = 0;
    await for (var event in operation) {
      i++;
      print('Subscription event data received: ${event.data}');
      if (i == 5) {
        break;
      }
    }
  } on Exception catch (e) {
    print('Error in subscription stream: $e');
  }
}
```

２つの異なるやり方でSubscriptionの購読ができることは、頭の片隅に入れておくといいよ。

実装はここまで！　書き終えたら、さっそく `flutter run` で動かしてみよう。

アプリが起動したら、画面最下部に『サブスクリプションを開始する』というボタンがあるから、それを押せば購読が始まるよ。

購読しているSubscriptionは、誰かが Mutation `createOtameshi_subscription` を実行するたびに、その実行結果を受信してくれるはず。

試してみるには、AWSマネジメントコンソールからAppSyncの『クエリ』画面が便利だよ。実際にクエリ画面から `createOtameshi_subscription` を実行してやると……次のGIFのように、実行のたびに購読中のアプリがちゃんとメッセージを受信できているのが分かるよね。

![subscriptioin_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/38e698dd-89c0-ec1f-1e25-4bfe015e5e78.gif)


そういうわけで、これで **『FlutterでAWS AppSyncのSubscriptionを購読する』** という目的は達成だね。

## まとめ

FlutterでAWS AppSyncのSubscriptionを購読するには、次の２つのパッケージを使うよ。

- [amplify_flutter](https://pub.dev/packages/amplify_flutter)
- [amplify_api](https://pub.dev/packages/amplify_api)

いずれもAmplifyというAWSのサービスのパッケージだけど、サービスとしてのAmplifyを使わない状態でも、これらのパッケージを使ってAppSyncを利用できる。

また、実装時にはまず `Amplify.configure(amplifyconfig);` でAPIキーやエンドポイントの設定を行ない、その後、サブスクリプションの購読を開始・終了するコードを実行させるよ。

その際、サブスクリプションの購読にはふたつの方法がある（listenパターン、for文パターン）ので、適宜好きなものを選んで実装してね。

なお、この記事で解説したコードは、下記のリポジトリで公開しているよ。コードをあらためて確認したくなった時などに参照してね。

https://github.com/gel1123/otameshi_appsync_subscription