Vue.js と AWS AppSync(GraphQL)を使ってチャットアプリを作った

## はじめに

以前、[「Vue.js×GraphQL(AppSync)×AmplifyでTODOリストを作る」](https://qiita.com/is_ryo/items/b9feeb017366b0cee544)という記事を書きました。
この記事をベースにチームメンバーにハンズオンをしてみると、割と簡単にGraphQLを使うことができると好評でした。
前回は `Mutation` と `Query` を使ってTODOリストチックなメモアプリを構築しました。
今回はGraphQLの `Subscription` を使ってチャットアプリを作ってみました。

## まとめ

いきなりまとめます。
というかこの記事で伝えたいことを先に書いておきます。

* GraphQLはRESTAPIに比べてハードルは高いかもしれない…
* `Subscription` はGraphQLの中で一番便利！(120%主観)
* Messangerなどのチャットアプリの裏側をなんとなく知ることができる

## ソースコード

ソースコード一式はgithubにpushしています。
ソースコードの一部を参照しながら説明していきますが、詳細はこちらから確認していただければと思います。
注意事項として、Vueファイルの構成は pug + Typescript + scss となっています。
[vue-graphql-chat-source](https://github.com/ryo-is/vue-graphql-chat-source)

## アーキテクチャ
基本的にクライアント(Vue.js)からAWSリソースへのリクエストはAWS Amplify経由で行います。
Cognitoでログインをして、そのTokenをAppSyncの認証に利用して、DynamoDBにメッセージを書き込んだり、取得したりします。
![vue-graphql-chat.png](https://qiita-image-store.s3.amazonaws.com/0/95875/7f1e5ece-80c4-6e7f-916b-5386c80738d8.png)

## つくったもの
左右のブラウザは違うユーザでログインしています。
メッセージを送信したら、リアルタイムで自分のタイムラインにも反映されます。あと自分のメッセージは削除することができるようになってます。
![vue-graphql-chat.gif](https://qiita-image-store.s3.amazonaws.com/0/95875/163c1859-62a1-dfe4-3306-91c5fb1f903e.gif)

## ログイン関係の実装

AWS AmplifyのVuejs版である `aws-amplify-vue` を使って実装しています。
`aws-amplify-vue` にはログインフォームなどのComponentが用意されています。
詳しいことは[aws-amplifyを利用してログイン画面を爆速で実装する .ver Vue.js](https://qiita.com/is_ryo/items/cf9265d2d19238bdd664)を見てください。この記事にまとめてます。

## チャット部分の実装

本題はここからです。
GraphQLを使って、チャットアプリのコア部分を実装していきます。

### チャットの仕組み

そもそもチャットアプリの大きな機能としては以下が挙げられます。

1. メッセージを送信する
2. メッセージを受信する

すごく雑ですが、この2つの機能があれば、とりあえずチャットアプリと言えるのではないでしょうか？
メッセージの送信はクライアントサイドからのトリガーなので、RESTでも簡単に実装できます。
ですがメッセージ受信をRESTで実現しようとするとどうでしょう？
一番最初に思い浮かぶのが、ポーリングですかね。数秒または数分ごとにデータを取得して、変更があれば画面の更新をかける。みたいな流れになります。
ですがこの場合、本当のリアルタイムに更新がかかるとは言えません…特にチャットの場合はメッセージ送信されたら即時に画面の更新をかけてほしいですよね。
そんな悩みを解決してくれるのがGraphQLの `Subscription` です。

### Subscription

簡単に説明すると、GraphQL(ここではAppSync)からクライアントサイドにデータを送信する仕組みです。
**データを送信する** と書きましたが、実際はセッションを確立してポーリングしているようにも感じます…(ココらへん詳しい方教えてください…)
私の主観ですが、この `Subscription` がRESTとの違いの一つであり、GraphQLの特徴の一つだと思っています。
![graphql_pubsub.png](https://qiita-image-store.s3.amazonaws.com/0/95875/73d12463-18ab-ee1b-6f7e-97d12fc35221.png)

GraphQL側では特定の `Mutation` が実行されたのをトリガーに、クライアントサイドに `Subscription` としてメッセージを送信します。

```json:メッセージ例
{
  "onCreateChatMessage": {
    "create_time": "2019-02-05 15:51:43",
    "message_body": "テスト投稿",
    "user_id": "sato"
  }
}
```

### Vue.jsでAppSync(GraphQL)の基本的な使い方

#### Amplifyの初期設定

`main.ts` にモジュールをimportして、Vueに読み込ませて、Amplifyの初期設定を行います。
※ここの `aws-exports.js` はCognitoやAppSyncの情報が書かれているファイルです。githubにはpushしていないのでご自身で用意していただくか、AmplifyCLIで生成してください。

```ts:main.ts
import Amplify, * as AmplifyModules from "aws-amplify";
import { AmplifyPlugin } from "aws-amplify-vue";
import aws_exports from "./aws-exports";

Amplify.configure(aws_exports);
Vue.use(AmplifyPlugin, AmplifyModules);
```

```js:aws-exports.js
const awsmobile =  {
  "aws_project_region": "",
  "aws_cognito_identity_pool_id": "",
  "aws_cognito_region": "",
  "aws_user_pools_id": "",
  "aws_user_pools_web_client_id": "",
  "aws_appsync_graphqlEndpoint": "",
  "aws_appsync_region": "",
  "aws_appsync_authenticationType": ""
};

export default awsmobile;
```

#### AppSyncを使う

Amplifyに用意されている `API` モジュールを利用します。

```ts:script
import { API, graphqlOperation } from "aws-amplify";

async appSyncFunction() {
  await API.graphql(graphqlOperation(gqlParams));
}
```

### AppSyncのSchema定義(一部)

AppSyncのSchema定義の一部です。殆どはAppSyncがデフォルトで生成してくれるものですが、一部追加したりしています。

```
type ChatMessage {
  user_id: ID!
  create_time: String!
  message_body: String!
}

type Mutation {
  createChatMessage(input: CreateChatMessageInput!): ChatMessage
  updateChatMessage(input: UpdateChatMessageInput!): ChatMessage
  deleteChatMessage(input: DeleteChatMessageInput!): ChatMessage
}

type Query {
  getChatMessage(user_id: ID!, create_time: String!): ChatMessage
  listChatMessages(filter: TableChatMessageFilterInput, limit: Int, nextToken: String): ChatMessageConnection
}

type Subscription {
  onCreateChatMessage: ChatMessage
    @aws_subscribe(mutations: ["createChatMessage"])
  onCreateChatMessageByUserId(user_id: ID, create_time: String, message_body: String): ChatMessage
    @aws_subscribe(mutations: ["createChatMessage"])
  onUpdateChatMessage(user_id: ID, create_time: String, message_body: String): ChatMessage
    @aws_subscribe(mutations: ["updateChatMessage"])
  onDeleteChatMessage: ChatMessage
    @aws_subscribe(mutations: ["deleteChatMessage"])
  onDeleteChatMessageByUserId(user_id: ID, create_time: String, message_body: String): ChatMessage
    @aws_subscribe(mutations: ["deleteChatMessage"])
}
```

### メッセージ送信処理(Mutation)

メッセージの送信は `Mutation` を使います。

```ts:script
/**
 * チャットメッセージ送信
 */
public async createMessage() {
  const gqlParams: string = `
    mutation put {
      createChatMessage(
        input: {
          user_id: "${VueStore.state.userID}",
          create_time: "${dayjs().format("YYYY-MM-DD HH:mm:ss")}",
          message_body: "${this.putMessage}"
        }
      ) {
        user_id,
        create_time,
        message_body
      }
    }
  `;
  await API.graphql(graphqlOperation(gqlParams));
}
```

### メッセージ取得処理(Query)

メッセージの取得は `Query` を使います。

```ts:script
/**
 * 最新50件分チャットメッセージを取得
 */
private async getMessages() {
  const gqlParams: string = `
    query list {
      listChatMessages(limit: 50) {
        items {
          user_id,
          create_time,
          message_body
        }
      }
    }
  `;
  const result: any = await API.graphql(graphqlOperation(gqlParams));
  // 取得したメッセージ
  const messages: ChatMessagesType[] = result.data.listChatMessages.items;
  // scanで取得してくるので時系列をソートする必要がある
  this.chatMessages = messages.sort((a, b) => {
    return dayjs(a.create_time).unix() - dayjs(b.create_time).unix();
  });
}
```

### Subscribe処理(Subscription)

`Subscription` を使っていきます。
この例ではすべてのユーザが送信したメッセージにトリガーして、クライアント画面の更新処理を行っています。

```ts:script
/**
 * メッセージ新規作成のsubscribe
 */
private async createGqlSubscriber() {
  const gqlParams: string = `
    subscription subCreateChatMessage {
      onCreateChatMessage {
        user_id,
        create_time,
        message_body
      }
    }
  `;
  this.subCreateChatMessageObservable = await API.graphql(graphqlOperation(gqlParams, {
    deleted: false
  })) as Observable<object>;
  // subscribeセッションを確立する
  this.subCreateChatMessageClient = this.subCreateChatMessageObservable.subscribe({
    // GraphQLからメッセージを受け取ったときの処理
    next: (result: any) => {
      console.log(result.value.data);
      this.getMessages();
    },
    error: (err: any) => {
      console.error(err);
    }
  });
}
```

特定のユーザだけのメッセージ送信に絞ることもできます。
下記の例は `user_id` が `sato` がメッセージを送信したときをトリガーにしたい場合です。

```ts:script
const gqlParams: string = `
  subscription subCreateChatMessage {
    onCreateChatMessageByUserId (
      user_id: "sato"
    ) {
      user_id,
      create_time,
      message_body
    }
  }
`;
```

## さいごに

GraphQLの `Subscription` はポーリングを自前で書くよりも、簡単に実装することができる上に、リクエストを投げ続けているわけではないので、リソースに対する負荷が少なくなります。
今までリアルタイムでグラフを更新するアプリを作ってきましたが、一定間隔でデータを取得するAPIを叩いているだけだったので、間隔が短ければ短いほどDBの負荷が高まるという問題がありました。GraphQLの `Subscription` を使えば、そういった問題も解消されるのではないか？と思って検証しています。
ただGraphQLは冒頭にも書いたように、RESTに比べてハードルが高いように思います。もっとGraphQLが流行ってハードルが低くなる日を待っています。
ではまた！！