APIGatewayでWebSocketが利用可能になったのでチャットAPIを構築してみた

# はじめに

本日からAPIGatewayでWebSocket APIを構築することができるようになりました！！
従来、WebSocket通信ができるアプリケーションやAPIを構築するためにはWebsocket通信の接続を管理するホストとなるサーバを立てておく必要がありました。

よくやる構成としては以下のようなものだったのではないでしょうか？

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/47c5505e-69b2-ef84-94ff-0803c72c98d1.png)

アクセスはALBで負荷分散したいが、EC2にステートを持たせられないのでWebSocket通信の接続情報はElasticCacheなどに退避させておくことでスケールする環境をなんとか作るといった具合でしょうか。

この構成は安定した作りではあるものの、個人的には

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/de2f9c83-61cd-1825-fa73-0bb2862ab3e1.png)

の３重苦で、あまり好きではないです。昔苦労したなぁ・・・
そこで待望のAPIGatewayがWebSocketをサポートした（2018年12月19日から）とのことらしいので早速使ってみようと思います。ワクワク🎵

# APIGatewayでWebSocketの設定をしてみる

APIGatewayでWebSocket APIを構築することで、双方向通信ができるアプリケーションを簡単に実装することができるようになります！
WebSocket APIを使うことでチャットや双方向動画配信などが可能になり、Webアプリケーションの可能性が広がりますね！！

まずはじめにAPIGatewayのコンソール画面をのぞいてみましょう。
**APIの作成**から新規にAPIGatwayを作成しようとすると通信プロトコルをRESTにするかWebSocketにするかが選べます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/db51c748-cda3-5dc5-7a56-b5754a2e7563.png)


WebSocketにチェックを入れるとAPI名、Route Selection Expression、説明の入力が求められます。
**Route Selection Expression**という項目は初登場ですね。

```text:RouteSelectionExpression
$request.body.action
```

のように入力しておきましょう。

クライアントからAPIを送信する際にリクエストメッセージは以下のようなJSON形式で送ります。

```json:リクエストメッセージ
{
    "service" : "chat",
    "action" : "join",
    "data" : {
        "room" : "room1234"
   }
}
```

serviceはrouteKey、評価された値と正確に一致するルートを使用します。**Route Selection Expression**を `$request.body.action` として設定しておくと、actionプロパティに基づいてAPIの動作を選択することができます。あとはdataが実際に送られるメッセージデータですね。


### ルートとは？

WebSocket APIはルートという単位で構成されています。RESTの場合はリソースパスでしたね。特定のリクエストで使用するルートをどう使うか？という設定を**Route Selection Expression（選択式）**にて行います。式はルートのrouteKeyの値に対応するものに接続されます。

| 3つの特別な<br>routeKey値 | 説明 |
|:---------------------|:--|
| $default | 選択式がAPIルート内の他のrouteKeyに<br>一致しない値を生成する場合に使う  |
| $connect | クライアントがWebSocket APIに<br>最初に接続するときに使う|
| $disconnect |クライアントがAPIから切断するときに使う|

詳細は[こちら](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-selection-expressions.html)に記載されています。


# チャットAPIを作ってみる

今回作成する構成はこちらです。ユーザはOnConnect Lambdaを叩いて接続します。SendMessage Lambdaはユーザからのメッセージ送信を受け取ると、このWebSocket通信で繋がるすべてのユーザにPUSHします。OnDisconnect Lambdaを叩いてWebSocket通信を切断します。接続情報はDynamoDBに保持することでLambdaをステートレスに保ちます。LambdaはAWSからサンプルとして提供されている[こちら](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:729047367331:applications~simple-websockets-chat-app)のServerlessApplicationRepositoryのアプリを使用します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/183e6506-04a1-f3b5-ec5c-3d5cebb8a3cc.png)

### サンプルアプリケーション（Lambda）のデプロイ

まずは[ServerlessApplicationRepositoryのアプリ](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:729047367331:applications~simple-websockets-chat-app)を自分のAWS上にデプロイします。

```bash:bash
# 今回使用するアプリケーションのリポジトリをクローン
$ git clone https://github.com/aws-samples/simple-websockets-chat-app.git
# クローンしてきたディレクトリに移動
$ cd simple-websockets-chat-app
# SAMテンプレートをpackageコマンドを使用してLambdaのソースコードと共にS3に格納
$ sam package --template-file template.yaml --output-template-file output.yaml --s3-bucket <自分のS3バケット>
# デプロイ
$ aws cloudformation deploy --template-file output.yaml --stack-name  <スタック名>  --capabilities CAPABILITY_IAM
```

今回はスタック名 `websocket` としてデプロイしました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/6e4119d1-bc87-0320-1c4e-730aa618f6f0.png)

Lambdaが3種類
![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/b8bd4e61-e192-9b9c-c910-435dbe448b48.png)

DynamoDBは simplechat_connections という名前で出来上がります。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/30acbc64-daa6-beb0-52a5-29ba805dcb20.png)

### APIGatewayの設定

さて、いよいよAPIGatewayの設定を進めていきます。
構成図どおり、３つのルートを作成します。まずはsendmessageルートから作成しましょう。

**New Route Key**にsendmessageを入力して確定します。
あとはこのルートとLambda関数を接続しましょう。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/cf5bcf56-52c6-0874-7a31-c0f89b390acc.png)

#### SendMessage Lambda

ここでSendMessage Lambda関数に注目してみます。
ユーザの誰かが “sendmessage”アクションでメッセージを送信すると、
DynamoDBテーブルから、現在接続されているすべてのユーザを検索します。 
検索されたすべてのユーザに対して、postToConnectionを呼ぶことでメッセージを送信します。

```javascript:sendmessage/app.js
// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0

var AWS = require('aws-sdk');
AWS.config.update({ region: process.env.AWS_REGION });
var DDB = new AWS.DynamoDB({ apiVersion: "2012-10-08" });

require('aws-sdk/clients/apigatewaymanagementapi');

exports.handler = function (event, context, callback) {
  var scanParams = {
    TableName: process.env.TABLE_NAME,
    ProjectionExpression: "connectionId"
  };

  DDB.scan(scanParams, function (err, data) {
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify(err)
      });
    } else {
      var apigwManagementApi = new AWS.ApiGatewayManagementApi({
        apiVersion: "2018-11-29",
        endpoint: event.requestContext.domainName + "/" + event.requestContext.stage
      });
      var postParams = {
        Data: JSON.parse(event.body).data
      };
      var count = 0;

      data.Items.forEach(function (element) {
        // ユーザの誰かが “sendmessage”アクションでメッセージを送信すると、
        // DynamoDBテーブルから、現在接続されているすべてのユーザを検索します。 
        // 検索されたすべてのユーザに対して、postToConnectionを呼ぶことでメッセージを送信します。
        postParams.ConnectionId = element.connectionId.S;
        apigwManagementApi.postToConnection(postParams, function (err) {
          if (err) {
            if (err.statusCode === 410) {
              console.log("Found stale connection, deleting " + postParams.connectionId);
              DDB.deleteItem({ TableName: process.env.TABLE_NAME,
                               Key: { connectionId: { S: postParams.connectionId } } });
            } else {
              console.log("Failed to post. Error: " + JSON.stringify(err));
            }
          } else {
            count++;
          }
        });
      });

      callback(null, {
        statusCode: 200,
        body: "Data send to " + count + " connection" + (count === 1 ? "" : "s")
      });
    }
  });
};
```

#### OnConnect Lambda

次にWebSocket通信を開始するLambdaです。APIGatewayの設定は**$connect**ルートに接続させるようにします。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/e6b1aae2-928d-0efb-666d-c25dba59a78a.png)

関数の中身は非常にシンプルです。リクエストメッセージ（requestContext）のconnectionId値をDynamoDBテーブルに保存します。

```javascript:onconnect/app.js

// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0

var AWS = require("aws-sdk");
AWS.config.update({ region: process.env.AWS_REGION });
var DDB = new AWS.DynamoDB({ apiVersion: "2012-10-08" });

exports.handler = function (event, context, callback) {
  // リクエストメッセージからconnectionIdを取得して
  var putParams = {
    TableName: process.env.TABLE_NAME,
    Item: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };

  // DynamoDBテーブルに保存する
  DDB.putItem(putParams, function (err) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to connect: " + JSON.stringify(err) : "Connected."
    });
  });
};
```

#### OnDisConnect Lambda


![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/fc7f67db-df6c-6f64-3aec-c7d6ecdb7ac5.png)

こちらもシンプルですね。OnConnectと真逆の処理になります。DynamoDBに保持してあるデータを削除するだけです。

```javascript:ondisconnect/app.js
// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0

var AWS = require("aws-sdk");
AWS.config.update({ region: process.env.AWS_REGION });
var DDB = new AWS.DynamoDB({ apiVersion: "2012-10-08" });

exports.handler = function (event, context, callback) {
  // リクエストメッセージからconnectionIdを取得して
  var deleteParams = {
    TableName: process.env.TABLE_NAME,
    Key: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };
  
  // DynamoDBのテーブルから削除
  DDB.deleteItem(deleteParams, function (err) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to disconnect: " + JSON.stringify(err) : "Disconnected."
    });
  });
};
```

### ステージへのデプロイ

ここは従来の方法と変わらないようです。

**Action＞Deploy API**を押すと設定画面が立ち上がりますので
![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/e3114dc7-b12a-f159-bfc5-4cd7492febf4.png)

デプロイ先のステージ名などを入力します。ここでは**prod**ステージにデプロイします。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/9178981b-5aac-6919-25d0-e076ec73891b.png)

ステージが正常にデプロイされるとWebSocket URLとConnection URLが発行されます。これで構築作業は完了です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/163591/e517c0a6-94c1-496b-c95b-ebf3749a25ac.png)

# Websocketを体感する（テストしてみる）

ここまでできたら生成されたWebSocket URLに対してwssコネクションを貼りましょう。

コマンドラインツール の **wscat** を使って検証してみます。

```bash:bash
# wscatコマンドのインストール
$ npm install -g wscat
# APIGateayのステージエンドポイントにwssコネクションを貼る
$ wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/prod
connected (press CTRL+C to quit)

# メッセージを送信
> {"action":"sendmessage", "data":"hello world"}
< hello world
```

#### 双方向通信の様子

コンソールを複数開いてメッセージを送信すると、すべてのコンソールにメッセージがPUSHされていることがわかります。
下の動画ではで真ん中と上のコンソールがwscatコマンドでWebSocket通信を行なっている様子が見て取れます。一番下のコンソールはWebSocket通信を開始すると、DynamoDBにconnectionIdが保存されてWebSocket通信を切断するとconnectionIdが破棄される様子を観測しています。

![websocket.gif](https://qiita-image-store.s3.amazonaws.com/0/163591/0fef281a-dcf8-4a86-8e97-24cd82d542ca.gif)

# さいごに

いかがだったでしょうか？今回はAPIGatewayの新機能であるWebSocket通信のサポートを細かく追って解説してみました。
これでフルサーバレスでのWebSocket通信が可能になります。チャットでも双方向動画通信でもゲームでも！活躍する機会はどんどん出てきそうですね！！活用していきましょう:muscle: