【超ミニマム】AWS AppSync + AmplifyでiOSチャットアプリを作る

## はじめに
この記事は[and factory Advent Calendar 2020](https://qiita.com/advent-calendar/2020/andfactory) の4日目の記事です。
昨日は@ykkdさんの[Swiftlint autocorrectでコードを自動修正する](https://qiita.com/ykkd/items/ae47b4e5e6841842ce75)でした！👏

## AWS AppSync / Amplify
チャットアプリをはじめとしたモバイルのバックエンドとしてFirebaseが利用されることが多いと思いますが、 
AWSのマネージドサービスには、他のAWSサービスとの連携が容易という大きなメリットがあります。
私自身iOSエンジニアではあるもののAWSの提供しているサービスや構成には興味があったので、
今回はAppSyncとAmplifyを使ってみることにしました。

AppSyncとはAWSが提供しているGraphQLでのサービス開発をサポートするマネージドサービスで、
GraphQLで用意されているサブスクリプション機能を使うことでチャット機能を比較的用意に作ることができます。
また、AmplifyはクライアントがAppSyncにアクセスするために提供されているツールで、
iOSではAmplify SDKを利用することで、AppSyncとのデータの受け渡しをSDK側が請け負ってくれます。

こんなイメージだと思っています↓
![スクリーンショット 2020-12-04 1.26.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/0ef39de9-6744-a040-3c13-b2a5745da8c6.png)


今回はAWS AppSync + Amplifyで爆速で（？）超ミニマムなチャットアプリを作ってみます。

# Amplifyのセットアップ

## 前提条件
- AWSアカウントを持っている
- Amplifyのセットアップが完了している
    - Amplifyを実行するコマンドラインツールのセットアップ、Podのインストールを行います。
    - 詳細は[こちら](https://docs.amplify.aws/start/getting-started/setup/q/integration/ios)を参照ください。

Amplifyのコマンドラインツールで以下を実行します。

## Amplifyの初期化

```
amplify init
```

その後プロジェクト名などの初設定を尋ねられるので回答します。今回は以下のように答えました。

```
? Enter a name for the project 
    -> SampleChatApp
? Enter a name for the environment
    -> dev
? Choose your default editor:
    -> Visual Studio Code
? Choose the type of app that you're building
    -> ios
? Do you want to use an AWS profile?
    -> Yes
? Please choose the profile you want to use
    -> default
```

✅ Amplify setup completed successfully.
と出たら完了です。

## APIのセットアップ

amplifyのセットアップが終わったら、コマンドラインツールを使ってAPIをセットアップしていきます。

プロジェクトルートで下記のコマンドを実行し、確認内容に沿って回答していきます。

```
amplify add api
```

再び質問されます。次はAPIについての初設定です。今回のケースの回答は以下です。

```
? Please select from one of the below mentioned services: 
    -> GraphQL
? Provide API name:
    -> samplechatapp
? Choose the default authorization type for the API
    -> API key
? Enter a description for the API key:
    -> SampleChatApp's API key.
? After how many days from now the API key should expire (1-365):
    -> 7
? Do you want to configure advanced settings for the GraphQL API
    -> No, I am done.
? Do you have an annotated GraphQL schema?
    -> No
? Choose a schema template:
    -> Single object with fields (e.g., “Todo” with ID, name, description)


GraphQL schema compiled successfully.

? Do you want to edit the schema now?
    -> Yes // Yesとするとエディタが開き、スキーマを編集できる

```

## モデル定義

APIの作成の最後の質問にYesで答えるとエディタが開きスキーマを編集できるようになります。
GraphQLではgraphqlファイルで定義されたスキーマをもとにAPIを作成します。
ここでは簡単にメッセージのモデルを以下のように定義しました。
テキスト、作成日（エポックマイクロ秒を想定）、UserIDの最小限のプロパティです。

```graphql:schema.graphql
type Message @model {
  id: ID!
  text: String!
  createdAt: String!
  user: String!
}
```

スキーマを定義したら、
これまでに作成したスキーマやらAPIの定義ファイルやらのローカルのリソースをリモートにpushします。

```
amplify push
```

pushに成功すると、例によって質問がなされます。
この回答に基づいて作成したAPIにアクセスするためのラップ処理の種類や命名などが決まります。

```
? Do you want to generate code for your newly created GraphQL API
    -> Yes
? Enter the file name pattern of graphql queries, mutations and subscriptions
    -> graphql/**/*.graphql
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscripti
ons
    -> Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested]
    -> 2
? Enter the file name for the generated code
    -> API.swift
```

ここまで行うとAmplifyのコマンドラインツールにてAPIが作成され、
AWSのコンソール->サービス→AppSyncから確認することができます。

![スクリーンショット 2020-12-03 22.09.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/79d717ac-4a7d-115b-4779-a4f25175a634.png)

# クライアント実装

## セットアップ

`amplify init`の際に作成された、
`amplifyconfiguration.json`と`awsconfiguration.json`のふたつのjsonファイルを
Xcodeのプロジェクト内に移し替えます。

また、アプリ起動時のAmplifyのセットアップ処理としてAppDelegateで以下を実行します。

```swift:AppDelegate.swift
import UIKit
import Amplify
import AmplifyPlugins

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        // Setup Amplify
        do {
            try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
            try Amplify.configure()
        } catch {
            print("An error occurred setting up Amplify: \(error)")
        }
        
        // 略

        return true
    }
}
```

## チャット機能実装

チャット機能の実装です。

### viewDidLoadでデータソースに保存されたメッセージを取得する

REST APIでのGetはGraphQLではqueryが請け負います。
Amplify SDKでは`Amplify.API.query(request:)`を実行すして、Messageの配列を取得します。
また、件数を指定するlimitや次の値の参照を持つnextTokenを組み合わせることでページネーションを行うこともできます。

```swift:ChatViewController.swift

    @IBOutlet private weak var tableView: UITableView!

    var messages: [Message] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        self.fetchMessage()
    }

    func fetchMessage() {
        // Amplify SDK経由でqueryオペレーションを実行しMessageの配列を取得
        Amplify.API.query(request: .list(Message.self, where: nil)) { event in
            switch event {
            case .success(let result):
                // GraphQLの場合、Query失敗時のerrorもレスポンスに含まれる
                switch result {
                case .success(let messages):
                    self.messages = messages
                    DispatchQueue.main.async {
                        // tableViewを更新
                        self.tableView.reloadData()
                    }
                case .failure(let error):
                    // サーバーから返されるエラーはこっち
                }
            case .failure(let error):
                // 通信エラー等の場合はこっち
            }
        }
    }
```

### 送信ボタンでデータを投稿する

GraphQLにおいてデータの作成、変更などの書き込み操作はmutateが行います。

```swift:ChatViewController.swift

    @IBAction func tappedSendButton() {
        // キーボード閉じる
        self.textField.resignFirstResponder()

        // メッセージ内容
        guard let text = self.textField.text, !text.isEmpty else {
            return
        }
        // 送信時間を取得
        let createdAt = String(Date().timeIntervalSince1970)
        // 別管理しているUserID
        let user = UserIdRepositoryProvider.provide().getUserId()
        let message = Message(text: text, ts: ts, user: user!)

        // mutateで新規メッセージを作成
        Amplify.API.mutate(request: .create(message)) { event in
            switch event {
            case .success(let result):
                switch result {
                case .success(let message):
                    print("Successfully created the message: \(message)")
                case .failure(let graphQLError):
                    // サーバーからのエラーの場合はこっち
                    print("Failed to create graphql \(graphQLError)")
                }
            case .failure(let apiError):
                // 通信まわりなどのErrorになった場合はこっち
                print("Failed to create a message", apiError)
            }
        }
        
        // 初期化しておく
        self.textField.text = ""
    }
```

### データソースの購読

最後にリアルタイムな結果の反映について実装します。
GraphQLではサブスクリプション機能を使うことによって、双方向のソケット通信を実現します。
Amplifyでは、`Amplify.API.subscribe()`を実行することで、データソースの変更をレスポンシブに反映できるようになります。

```swift:ChatViewController.swift

    override func viewDidLoad() {
        super.viewDidLoad()
        self.fetchMessage()
        self.subscribeMessage()
    }

    func subscribeMessage() {
        // 新たなメッセージの作成を購読する
        subscription = Amplify.API.subscribe(request: .subscription(of: Message.self, type: .onCreate), valueListener: { (subscriptionEvent) in
            // 購読したイベント内容をチェック
            switch subscriptionEvent {

            // サブスクリプションの接続状態の変更を検知
            case .connection(let subscriptionConnectionState):
                print("Subscription connect state is \(subscriptionConnectionState)")

            // データの更新を検知
            case .data(let result):
                switch result {
                case .success(let createdMessage):
                    self.messages.append(createdMessage)
                    DispatchQueue.main.async {
                        // テーブル更新
                        self.tableView.reloadData()
                                                
                        // 最新のメッセージまでスクロール
                        let indexPath = IndexPath(row: self.messages.count - 1, section: 0)
                        self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
                    }
                case .failure(let error):
                    print("Got failed result with \(error.errorDescription)")
                }
            }
        }) { result in
            switch result {
            case .success:
                print("Subscription has been closed successfully")
            case .failure(let apiError):
                print("Subscription has terminated with \(apiError)")
            }
        }
    }
```

## ひとまず完成
できてるっぽい〜〜〜

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/68a518d4-931f-026a-378f-0ac8abaf91ff.gif" width="100%">

## やりきれなかったこと

できてるっぽい雰囲気が若干しますが、実は今回ソートができませんでした。
（サブスクリプションで購読したものは時系列順で取得できるのでごまかせるのですが、queryで取得したものはKeyのMessageのIDでソートされてしまい順序性が狂ってしまいます）

|  時系列順  |  ID順（再度queryで取得した場合）  |
| :----: | :----: |
| ![Simulator Screen Shot - iPhone 11 - 2020-12-03 at 23.53.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/f5e71496-a941-625c-3b85-909ed3bd4fbb.png) |  ![Simulator Screen Shot - iPhone 11 - 2020-12-03 at 23.54.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/3dc637d7-c94a-6160-3227-530d9f1c0d13.png)  |


チャットであれば当然時系列順に並んでほしいところですが、超ミニマムということでお許しください。
（schema.graphqlをいじってソートキーにcreatedAtを指定することはできるのですが、
Amplify SDKで経由でどのように呼び出せばいいのかわからず。。知見がある人がいたら教えていただきたいです。）

## おわりに

本記事では全く触れられませんでしたが、チャット機能に関しては

- 認証情報との紐付け
- 送信開始時点で送信中というステータスがユーザーに伝わるようにする
- 送信に失敗したときにユーザーに通知して再送信を促す
- 送信中にアプリを落としても送信が行われるようにする
- 画像や動画などコンテンツの拡充
- データの永続化をしてユーザービリティを高める

など考え出すとどんどん検討項目が出てくるので、
SDKにどこまでおまかせするべきなのか難しいところだなと思いました。
とはいえAmplify iOS SDKに関してはまだまだ調査段階なので、
また色々触って理解度深めていきたいところです。

最後まで見ていただきありがとうございました🙇‍♂️