APIGatewayのwebsocketを使ったチャットをServerless Frameworkで作成する

# はじめに

AWSによるチャットのサンプルのServerlessFrameworkバージョンを作りました。

APIGatewayのwebsocketを使ったチャットのサンプルです。サンプルではAWS SAMが使われていますが、サンプルをベースにチャットをこれから開発して行くのに、ServerlessFrameworkを使う方が楽そうだなー、と言うモチベーションで作りました。

# 目標物

下記リンク先のサンプルと同じものをServerlessFrameworkで作ります。



[[発表]Amazon API GatewayでWebsocketが利用可能](https://aws.amazon.com/jp/blogs/news/announcing-websocket-apis-in-amazon-api-gateway/)
[simple-websockets-chat-app](https://github.com/aws-samples/simple-websockets-chat-app)


# 実装
## ディレクトリ構造

```
.
├── onconnect
│   └── app.js
├── ondisconnect
│   └── app.js
├── sendmessage
│   └── app.js
└── serverless.yml
```

## serverless.yml

```yml
service:
  name: SimpleChatWebSocket

provider:
  name: aws
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'ap-northeast-1'}
  runtime: nodejs10.x
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource:
        - Fn::GetAtt: [ ConnectionsTable, Arn ]
  environment:
    TABLE_NAME:
      Ref: ConnectionsTable
  websocketsApiName: ${self:service.name}
  websocketsApiRouteSelectionExpression: $request.body.message

functions:
  connect:
    handler: onconnect/app.handler
    events:
      - websocket:
          route: $connect
  disconnect:
    handler: ondisconnect/app.handler
    events:
      - websocket:
          route: $disconnect
  sendmessage:
    handler: sendmessage/app.handler
    events:
      - websocket:
          route: sendmessage

resources:
  Resources:
    ConnectionsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: simplechat_connections
        AttributeDefinitions:
          - AttributeName: connectionId
            AttributeType: S
        KeySchema:
          - AttributeName: connectionId
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 5
          WriteCapacityUnits: 5
        SSESpecification:
          SSEEnabled: True
```

## 各Lambda

それぞれAWSが用意してくれているサンプルコードそのままです。
リンクのみで済まそうかと思いましたが、
リンク先に変更があると動かなくなる可能性もあると思い現時点で動いたものを貼ります。

### onconnect/app.js

[simple-websockets-chat-app/onconnect/app.js](https://github.com/aws-samples/simple-websockets-chat-app/blob/master/onconnect/app.js)

```js
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) {
  var putParams = {
    TableName: process.env.TABLE_NAME,
    Item: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };

  DDB.putItem(putParams, function (err) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to connect: " + JSON.stringify(err) : "Connected."
    });
  });
};
```



### ondisconnect/app.js

[simple-websockets-chat-app/ondisconnect/app.js](https://github.com/aws-samples/simple-websockets-chat-app/blob/master/ondisconnect/app.js)

```js
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) {
  var deleteParams = {
    TableName: process.env.TABLE_NAME,
    Key: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };

  DDB.deleteItem(deleteParams, function (err) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to disconnect: " + JSON.stringify(err) : "Disconnected."
    });
  });
};
```

### sendmessage/app.js

[simple-websockets-chat-app/sendmessage/app.js](https://github.com/aws-samples/simple-websockets-chat-app/blob/master/sendmessage/app.js)

```js
const AWS = require('aws-sdk');

const ddb = new AWS.DynamoDB.DocumentClient({ apiVersion: '2012-08-10' });

const { TABLE_NAME } = process.env;

exports.handler = async (event, context) => {
  let connectionData;
  
  try {
    connectionData = await ddb.scan({ TableName: TABLE_NAME, ProjectionExpression: 'connectionId' }).promise();
  } catch (e) {
    return { statusCode: 500, body: e.stack };
  }
  
  const apigwManagementApi = new AWS.ApiGatewayManagementApi({
    apiVersion: '2018-11-29',
    endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
  });
  
  const postData = JSON.parse(event.body).data;
  
  const postCalls = connectionData.Items.map(async ({ connectionId }) => {
    try {
      await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: postData }).promise();
    } catch (e) {
      if (e.statusCode === 410) {
        console.log(`Found stale connection, deleting ${connectionId}`);
        await ddb.delete({ TableName: TABLE_NAME, Key: { connectionId } }).promise();
      } else {
        throw e;
      }
    }
  });
  
  try {
    await Promise.all(postCalls);
  } catch (e) {
    return { statusCode: 500, body: e.stack };
  }

  return { statusCode: 200, body: 'Data sent.' };
};
```

# デプロイ

```sh
$ sls deploy -v
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
CloudFormation - CREATE_IN_PROGRESS - AWS::CloudFormation::Stack - SimpleChatWebSocket-dev
CloudFormation - CREATE_IN_PROGRESS - AWS::S3::Bucket - ServerlessDeploymentBucket
CloudFormation - CREATE_IN_PROGRESS - AWS::S3::Bucket - ServerlessDeploymentBucket
CloudFormation - CREATE_COMPLETE - AWS::S3::Bucket - ServerlessDeploymentBucket
CloudFormation - CREATE_COMPLETE - AWS::CloudFormation::Stack - SimpleChatWebSocket-dev

~ 略 ~

CloudFormation - UPDATE_COMPLETE - AWS::CloudFormation::Stack - SimpleChatWebSocket-dev
Serverless: Stack update finished...
Service Information
service: SimpleChatWebSocket
stage: dev
region: ap-northeast-1
stack: SimpleChatWebSocket-dev
resources: 24
api keys:
  None
endpoints:
  wss://2yy181eel2.execute-api.ap-northeast-1.amazonaws.com/dev
functions:
  connect: SimpleChatWebSocket-dev-connect
  disconnect: SimpleChatWebSocket-dev-disconnect
  sendmessage: SimpleChatWebSocket-dev-sendmessage
layers:
  None

Stack Outputs
SendmessageLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:SimpleChatWebSocket-dev-sendmessage:6
DisconnectLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:SimpleChatWebSocket-dev-disconnect:6
ConnectLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:SimpleChatWebSocket-dev-connect:6
ServiceEndpointWebsocket: wss://2yy181eel2.execute-api.ap-northeast-1.amazonaws.com/dev
ServerlessDeploymentBucketName: simplechatwebsocket-dev-serverlessdeploymentbucke-rbjshnd8b2xf

Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.
```

# テスト

wscatコマンドを使い（[インストールはこちらを参照](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-wscat.html)）、出力されたendpointsにアクセスします。

```sh
$ wscat -c wss://2yy181eel2.execute-api.ap-northeast-1.amazonaws.com/dev
connected (press CTRL+C to quit)
> 
```

入力待ちになるのでJSON形式でメッセージを送ると…

```sh
$ wscat -c wss://2yy181eel2.execute-api.ap-northeast-1.amazonaws.com/dev
connected (press CTRL+C to quit)
> {"message":"sendmessage", "data":"hello world"}
< hello world
> 
```

ちゃんとhello worldが返って来ました！

CloudFormationへの出力などディテールは違いますが実装としてはAWSが用意してくれているサンプルと同じものが作れました。

# おわりに

よくあるお客様サポートみたいなチャットに改造して行きたいです。
ServerlessFrameworkの初学者ゆえにそもそものHowToから始めました。



**参考にしたサイト**

* [API Gateway の WebSocket でチャットを実装 w/Serverless Framework](https://riotz.works/articles/lulzneko/2018/12/22/implement-chat-using-websocket-of-api-gateway-with-serverless-framework/)
* [Serverless Framework - AWS Lambda Events - Websocket](https://serverless.com/framework/docs/providers/aws/events/websocket/)
* [examples/aws-node-websockets-authorizers at master · serverless/examples](https://github.com/serverless/examples/tree/master/aws-node-websockets-authorizers)
* [Serverless.yml Reference](https://serverless.com/framework/docs/providers/aws/guide/serverless.yml/)