API Gatewayを使ってWebSocketで遊んでみた。（with cdk、Vue3）

# 私の中のWebSocket

`WebSocket`とは、私的に**用語は知っていて概略は解ってるけどよく知らない事の一つ**になります。
クライアント側とサーバー側で双方向にやり取りできる技術という事は聞いています。チャットなどで使用されているという事は知っていますが、本質的にどんな技術で、どんな事に使えるのか実際に試す事で肌で感じてみたいと常々思っていました。

# きっかけ

そんな中こちらの記事を見つけました。AWS上で簡単に試せそうと思い、やってみる事にしました。

https://qiita.com/G-awa/items/472bc1a9d46178f3d7a4

https://github.com/aws-samples/simple-websockets-chat-app

# 目標

今回は、前述の記事を参考にしつつ、以下のポイントを満たすように改造していきたいと覆います。

- サーバーサイドだけでなく、クライアントサイドも構築する。
- チャットルームを複数設けて、入室や退室の制御をやれるようにする。
- LambdaやAPIGateway部分などAWSリソースは、cdk2で構築する。

# 使用技術情報

- AWS aws-cdk: 2.33.0 (typescript)
    - APIGateway
    - Lambda
    - DynamoDB
- Vue3
- [vue3-beautiful-chat](https://github.com/Sitronik/vue3-beautiful-chat)

# 技術要素理解

WebSocketに関して初心者なので、サンプルソースや記事を見ながら基本的な技術要素を理解します。

## 「ルート」の理解

自分でWebSocketサーバーを構築すると使用するライブラリなどにより実装など異なっていそうですが、AWSのAPIGatewayでWebSocketを構築する時は、ルートと呼ばれる処理分岐を活用する事になりそうです。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/websocket-api-develop-routes.html#apigateway-websocket-api-route-selection-expressions

### 接続/切断時のルート

- $connect
- $disconnect

名前の通りですが、接続時、切断時にそれぞれのルートが呼び出されるとの事です。

### 接続後、各種処理をする時のルート

WebSocketで処理をする際には、jsonで情報を送信する様です。そして、そのどの情報をルートとして使用するかを、APIGatewayのAPIレベルで指定する`routeSelectionExpression`プロパティで決定するとの事です。このプロパティに`$request.body.action`を指定しておくと、`join`ルートが選択されるという事の様です。

```json:前述ページの送信jsonサンプル
{
    "service" : "chat",
    "action" : "join",
    "data" : {
        "room" : "room1234"
   }
}
```

- $default

そして、前述プロパティに基づいてルートを処理した時、対応するものが無かった時に選択されるルートとの事です。あまりこのルートは使う事なさそうです。強いて言えばルート処理に失敗したログやイベント発生を行うルートにする感じでしょうか。

## クライアント情報の理解

サーバー側からクライアント側へ信号を送るからにはクライアント側の情報を持っていなければ不可能です。冒頭に紹介した[サンプルソースの接続部分](https://github.com/aws-samples/simple-websockets-chat-app/blob/master/onconnect/app.js)からすると、接続時に`$connect`のルートに処理が渡され、そこに設定されているLambda関数で、`event.requestContext.connectionId`をDynamoDBに保持しています。
また、サンプルの[メッセージを処理する部分](https://github.com/aws-samples/simple-websockets-chat-app/blob/master/sendmessage/app.js)を見てみると、サーバー側からの送信に`postToConnection`メソッドを使用し、その時の引数に先ほどDynamoDBに保存したclientIdを使用している様です。

APIGateway側で、クライアント側との接続制御はしてくれていて、クライアント側に送信する時には、`$connect`処理時に渡されたclientIdで制御するという考え方の様です。

# APIGateway作成単位に関する考察

一つのチャットだけのアプリならあまり考える必要はありませんが、複数のチャットルームがあったり、色々な処理でWebSocketを使いたい場合にはどうすべきか考えてみます。※指摘点その他気づかれた点などありましたら教えて頂けると有り難いです。
簡単なのは必要な分だけAPIGatewayを作成する事です。ただ、チャットルームを増設する度に動的にAPIGatewayを作成するのは現実的ではありません。さらに、料金的にも接続時間だけ課金されてしまいます。

https://aws.amazon.com/jp/api-gateway/pricing/#WebSocket_APIs

先ほどの送信jsonサンプルを見ると、`service`、`action`という項目があります。複数のサービスで一つのWebSocketを使う想定をしている事がうかがえます。これを踏まえて今回の目的を実現する為の制御を考えます。

# 制御考察

今回の様に、複数のチャットルームを制御したいという場合、以下の様に構築すれば行けそうです。ルート名も決めておきます。

- `$connect`で接続してきたクライアント情報を保持、`$disconnect`で解放
- チャットルームが新設された時には全クライアントに通知（`add`）
- チャットを受信するにはルームに入る事（`join`）
- チャットルームにメッセージが追加された時、ルームに入っている全クライアントにメッセージ送信（`sendmessage`）
- ルームに入っていなかったり退出したクライアントには送信しない（`leave`）
- ルームが削除されたら全クライアントには通知する（`delete`）

# サーバー側構築

AWSリソースとして以下を整備します。※ロールなど細かいものは省いています。詳細は後述ソースを参照してください。

- APIGateway
    - $connectルート
    - $disconnectルート
    - addルート
    - deleteルート
    - joinルート
    - leaveルート
    - sendmessageルート
- Lambda
    - APIGatewayそれぞれのルートの処理をするLambda
- DynamoDB
    - websocket-sessioninfo-test：clientId情報を保持するテーブル
    - websocket-roominfo-test：ルーム情報を保持するテーブル

それぞれ説明していくと長くなってしまいますし、先ほどの参考記事と被る部分が多いので割愛させてもらいます。
違う所は、clientIdを保持するテーブルに`socketkey`というパーティションキーを設け、`roomid`を入れるようにしています。そうする事で、それぞれのルームに入っている人だけにメッセージを送信する形をとっています。

結果としては以下の様なソースになりました。cdk2で構築しているので、その参考にもなるかと思います。今回の記事の本質とは外れるので詳細説明は割愛させてもらいます。コメントなど頂ければ対応する事は可能かと思います。

https://github.com/silverbox/my-wstest/tree/main/cdk

※APIGatewayのdeployだけ一度に作成出来なく、一旦その部分コメントアウトしてデプロイし、その後アンコメントして再度デプロイしています。deployにはrouteを一つ設定する必要があるとか言われる。ただ、routeにdependency追加とかしたら無限依存になってると言われてしまう。本来は、deployは別のスタックにする必要があると思われます。

# 動作確認

一旦、ここまででサーバーサイドのテストをします。参考にさせてもらってる記事に倣って`wscat`コマンドを使用します。
APIGatewayのアドレスはAWSコンソールから確認しておきます（もちろんhogehogeはマスク文字です）。
入力順は以下の通りです。
1. コンソール１で、`testroomid`,`testroomname`のルーム作成コマンド送信
1. コンソール１で、`testroomid`へ`join`
1. コンソール２で、`testroomid`へ`join`
1. コンソール２で、`testroomid`へ`sendmessage`
1. コンソール１で、`testroomid`から`leave`
1. コンソール２で、`testroomid`を`delete`

```console:コンソール１
$ wscat -c wss://hogehoge.execute-api.ap-northeast-1.amazonaws.com/dev
Connected (press CTRL+C to quit)
> {"action": "add", "roomid": "testroomid", "roomname": "testroomname"}
< {
  "action": "add",
  "roomid": "testroomid",
  "roomname": "testroomname"
}
> {"action": "join", "roomid": "testroomid"}
< {
  "action": "join",
  "roomid": "testroomid"
}
< {
  "action": "join",
  "roomid": "testroomid"
}
< {
  "action": "sendmessage",
  "roomid": "testroomid",
  "data": "hello world"
}
> {"action": "leave", "roomid": "testroomid"}
< {
  "action": "leave",
  "roomid": "testroomid"
}
< {
  "action": "delete",
  "roomid": "testroomid"
}
```

```console:コンソール２
$ wscat -c wss://hogehoge.execute-api.ap-northeast-1.amazonaws.com/dev
Connected (press CTRL+C to quit)
> {"action": "join", "roomid": "testroomid"}
< {
  "action": "join",
  "roomid": "testroomid"
}
> {"action": "sendmessage", "roomid": "testroomid", "data": "hello world"}
< {
  "action": "sendmessage",
  "roomid": "testroomid",
  "data": "hello world"
}
< {
  "action": "leave",
  "roomid": "testroomid"
}
> {"action": "delete", "roomid": "testroomid"}
< {
  "action": "delete",
  "roomid": "testroomid"
}

```

ルーム作成/削除は接続している状態ならメッセージが届き、ルーム参加/退出/送信はそのルームに参加していないと送られてこない様な結果になっています。サーバーサイドはこれで大丈夫そうです。

# フロント側構築

チャットウィンドウのコンポーネントは各種ありますが、Vue3対応している`vue3-beautiful-chat`を使ってみようと思います。

https://github.com/Sitronik/vue3-beautiful-chat

初期構築時のコマンド

```bash
yarn global add @vue/cli
vue create vuefront # vue3, yarnを選択
cd vuefront
yarn add vue3-beautiful-chat
```

## 実装

`vue3-beautiful-chat`のサンプルソースを基本にComposition APIを使用した形に修正していきます。

ルームの追加などもブラウザ側で制御したかった所ですが、今回は固定の`roomid`を使う事にします。
チャット窓を開く時にjoinし、閉じる時にleaveする事にします。
メッセージ送信時、コンポーネントが渡してくれる情報を、今回作成したwebsocket用に変換します。
`websocket.onmessage`にwebsocketのサーバーからの信号を受信して処理します。

websocketと関係ある部分を抜き出してみたのが以下です。

```vue:
<template>
  <div>
    <beautiful-chat
      :participants="participants"
      :onMessageWasSent="onMessageWasSent"
      :messageList="messageList"
      :newMessagesCount="newMessagesCount"
      :isOpen="isChatOpen"
      :close="closeChat"
      :open="openChat"
      :showTypingIndicator="showTypingIndicator"
      :showLauncher="showLauncher"
      :showCloseButton="showCloseButton"
      :colors="colors"
      :alwaysScrollToBottom="alwaysScrollToBottom"
      :disableUserListToggle="disableUserListToggle"
    />
  </div>
</template>
<script>
  // ・・中略・・
  setup(){
    const websocket = new WebSocket(process.env.VUE_APP_WS_URL); // wss://hogehoge.execute-api.ap-northeast-1.amazonaws.com/dev が入ります
    const currentRoomId = ref('testroomid');
    // ・・中略・・

    const onMessageWasSent = (message) => {
      websocket.send(getWsMessageJsonStr(message));
      messageList.value.push(message);
    };
    const closeChat = () => {
      websocket.send(getWsLeaveJsonStr());
      isChatOpen.value = false;
    };
    const openChat = () => {
      isChatOpen.value = true;
      websocket.send(getWsJoinJsonStr());
    };

    const getWsJoinJsonStr = () => {
      return `{"action": "join", "roomid": "${currentRoomId.value}"}`;
    };
    const getWsLeaveJsonStr = () => {
      return `{"action": "leave", "roomid": "${currentRoomId.value}"}`;
    };
    const getWsMessageJsonStr = (message) => {
      return `{"action": "sendmessage", "roomid": "${currentRoomId.value}", "data": "${message.data.text}"}`;
    };
    websocket.onmessage = (event) => {
      console.log('websocket.onmessage()');
      if (event && event.data) {
        const resData = JSON.parse(event.data);
        // TODO 自身の発言をチェックして除外する
        if (resData.action == 'sendmessage') {
          const newmsg = {
            type: 'text',
            author: 'user1',
            data: {
              text: resData.data
            }
          }
          messageList.value.push(newmsg);
        }
      }
    };

    // ・・中略・・
  }
</script>
```

結果としてのソースは以下参考してください。

https://github.com/silverbox/my-wstest/blob/main/vuefront/src/components/ChatBox.vue

## 動作確認

片方はブラウザ、片方は先ほどのwscatにて一つのルームに参加して発言する事をテストしてみました。

こんな感じになりました。送信者の判断をしていないので、送信時に自分発言として追加した分と、送信後、websocketから戻ってきた自分発言を両方表示されている状態です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866006/23a2ba6a-5c9d-5e68-c4e1-0cca44cd115e.png)

wscat側の出力はこんな感じです。ブラウザ側の処理がちゃんと届いていて、wscat側からのメッセージがちゃんとブラウザ側に届いています。

```console:wscat側
< {
  "action": "join",
  "roomid": "testroomid"
}
< {
  "action": "sendmessage",
  "roomid": "testroomid",
  "data": "ブラウザから送信"
}
> {"action": "sendmessage", "roomid": "testroomid", "data": "wscatコンソールから送信"}
< {
  "action": "sendmessage",
  "roomid": "testroomid",
  "data": "wscatコンソールから送信"
}
>
```

本気でやり始めたらまだまだやる事はありますが、今回の本質的な目的である**websocketを実際に動かして試してみる**事は実現できたかなと思います。

# 今回やらなかった事

今回は自分がWebSocketで遊ぶ記事なので深くは作っていませんが、本来的にやる時は以下の対応も必要だと思います。

- connect時に認証/認可を行う
- その時にconnection毎のユーザー識別子を保持
- ルームの追加削除に関するフロント側機能
- サーバーからの配信時には誰が配信したかの情報を付与
- フロント側ではユーザー情報の処理
- roomidの存在チェックなどのサーバーサイドチェック
- 接続時に既存のルーム情報を取得
- ルーム参加時、それまでの会話情報を取得

# 感想

WebSocketサーバーを自前で立てる記事もいくつか読みましたが、結構大変そうでした。常時サーバー起動でもあるのでAWSのEC2とかでやろうとするとお金もかかります。
APIGatewayでサーバーサイド（WebSocketサーバー）を構築するとサーバーレスで動くので安価ですし、自分もサンプルを参考に簡単に構築する事が出来ました（cdk部分でいくつか躓きましたが、今回の記事とは関係ないので割愛）。

今回の記事では試していませんが、aws-sdkが使える部分では、対象APIGatewayのドメインとclientIdが解れば、対象APIGateway以外のリソース内でもクライアント側への送信が出来そうです。DeveloperIO様の記事 [CDK + API Gateway + Web Socket を使ってみた](https://dev.classmethod.jp/articles/cdk-api-gateway-web-socket/) では、S3にアップロードされた事をクライアントに通知しています。例えば、Batchなどが終わった時、通知用のWebSocketからその処理を待ってるクライアントに通知するとか色々応用できると思いました。

# 参考にさせて頂いた記事

## 公式ページ

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api-overview.html

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/websocket-api-develop-routes.html#apigateway-websocket-api-route-selection-expressions

https://github.com/aws-samples/simple-websockets-chat-app

https://aws.amazon.com/jp/api-gateway/pricing/#WebSocket_APIs

https://developer.mozilla.org/ja/docs/Web/API/WebSocket

## DevelopersIO様ページ

https://dev.classmethod.jp/articles/cdk-api-gateway-web-socket/

## 皆さまの良記事

https://qiita.com/G-awa/items/472bc1a9d46178f3d7a4