👨

WebSocket API x Serverless Framework x Node.js/Goでチャットアプリをつくってみた

2022/11/21に公開

もうすぐ 2 歳になる次男がよく寝てくれるようになったせいか、2 年ぶりくらいに早起きが習慣化してきました w
エンジニアの Naoya です。

AWS のチュートリアルで作成した API Gateway の WebSocket API を使ったチャットアプリを、Serverless Framework で再現してみました。
言語は、普段から使用している Node.js と勉強中の Go の 2 種類使いました。

AWS のチュートリアル
チュートリアル: WebSocket API、Lambda、DynamoDB を使用したサーバーレスチャットアプリケーションの構築

つくったもの

リポジトリ
aws-tutorial-chat-app

元々のチュートリアルのファイル(Cloudformation)
tutorial-ver

Node.js x Serverless Framework
node-ver

Go x Serverless Framework
go-ver

WebSocket とは

WebSocket プロトコルは仕様 RFC 6455 で説明されており、これは永続的な接続を介してブラウザとサーバ間でデータを交換する方法を提供します。接続の切断や追加の HTTP リクエストをすることなく、データを “パケット” として双方向に渡すことができます。
WebSocket は継続的にデータ交換を必要とするようなサービスに特に適しています。例えば、オンラインゲームやリアルタイムの取引システムなどです。
https://ja.javascript.info/websocket

チャットでのメッセージのやりとりのように、リアルタイムで接続者に対してデータの変更を反映させたいといった場合に使用されます。

ws://wss://プロトコルがあって、wss(WebSocket over TLS)では HTTPS と同じようにデータが暗号化されるようです。

アーキテクチャ

(チュートリアルより転載)

今回はチュートリアルの構成そのままですが、接続・切断のタイミングで接続 ID(connectionID)を DynamoDB に反映させて、sendMessageイベントが発生した際に、受信したメッセージをサーバーから各接続者に送信しています。

Chrome のネットワークタブで確認したところ、最初の接続確認を行なっている GET リクエストだけ確認できました。

(101 Switching Protocolsが返ってきている)

Go 言語による Web アプリケーション開発で作成したチャットアプリを動かして確認しました。

AWS Lambda・API Gateway・DynamoDB とは?

Lambda はアプリケーション(関数)の実行ファイルを zip 化してアップロードするだけでアプリケーションを実行することができる、サーバーレスな実行環境(サーバー)です。
日本語がおかしいような気もします w が、「サーバーレス」といったときは、サーバーの設定やメンテナンスが不要で、負荷に応じてスケーリングする機能を備えているなど、サーバーのことを意識しないで済むクラウドサービスのことを指しているように思います。

S3 や API Gateway など AWS の他のサービスと紐づけて、何かイベントが起きた時に アプリケーションを実行させるというような使い方ができます。

実体(実行環境?)はコンテナで、一定時間実行されないと再度実行される際はコンテナの生成から始まるので時間がかかったり(コールドスタート)、実行時間が 15 分に限られているなどの制約があります。

API Gateway は クライアントからのリクエストを受け付けて Lambda や AWS の他のサービスを呼び出すような API を構築することができるサービスで、主に REST API と WebSocket API の 2 種類あります。

DynamoDB は Key: Value 形式の NoSQL データベースです。

AWS Lambda
Amazon API Gateway
Amazon DynamoDB

Lambda のコールドスタートを改めて整理する
落とし穴にハマるな!AWS Lambda を利用するときの 6 つの注意点

※メモ: サーバーレスとフルマネージド: その違いとは?
なるほど、サーバーレスはマネージドを一歩進めて、実行環境(サーバー)に関してはほとんど意識する必要がない(マシンが見えない・制御もほとんどできない)、もはや利用する側からしてみれば「サーバーがないのと一緒」といった感じでしょうか。

APIGateway でのルーティング

API Gateway での WebSocket API について
WebSocket API のルートの操作

既定のルーティングが 3 つあって、それぞれ接続時($connect)・切断時($disconnect)・どのルーティングにも当てはまらない時($default)に使用されます。

それ以外の独自のルーティングは、リクエストボディに含まれる特定の key(ルート選択式)の値を元に行います。

公式ドキュメントだと、request.body.actionがルート選択式(REST で言うところのルーティングの path だと思います)に指定されていて、今回のアプリでも同様に、{"action": "sendmessage", "message: "hello"}というリクエストボディを受け取ったら、他の接続者に対してメッセージ(hello)が転送されるようになっています。

複数の値で判断したり変数を含めることもできるようです。

Serverless Framework

Serverless Framework: Websocket

Lambda 関数及び AWS の関連リソースをサクッと作れるフレームワークです。

Lambda を起点としたサーバーレスな構成のアプリケーションを作る際によく使用される印象です。

使ってみたこともないので実際にどのくらい使用されているのかわかりませんが、ドキュメントを見ると、GCP などの AWS 以外のクラウドにも利用できます。

Serverless Google Cloud Functions Provider Documentation

Serverless Framework の開発自体も AWS 向けがメインになっているみたいなので、AWS と同じ様な便利さを想像して使うには現段階では少し厳しいかなと感じました。
Google Cloud Functions を Serverless Framework で環境構築してみる

なるほどですね!

動作

AWS のドキュメント内でも紹介されていますが、npm ライブラリのwscatをクライアントとして使用し、API Gateway に接続しています。

wscat -c {エンドポイント}で接続後、{"action": "sendmessage", "message": "hello"}を入力して Enter を押すと、メッセージがサーバーに送信され、sendmessageハンドラでメッセージを他の接続者に転送しています。

wscatのソースコードを見てみると、npm ライブラリのwsを使っているようですね。

リソースの作成

serverless.ts(go バージョンは.yml)に DynamoDB や IAM などのリソースを定義しています。

また、Node バージョンと Go バージョンで微妙に違いますが、connect/など関数名のディレクトリ以下で Lambda 関数を定義し、API Gateway との紐付けを記載しています(Go はserverless.ymlに)

IAM(DynamoDB へのアクセス許可)

serverless.ts
iam: {
      role: {
        statements: [
          {
            Effect: "Allow",
            Action: [
              "dynamodb:Get*",
              "dynamodb:Query",
              "dynamodb:Scan",
              "dynamodb:Delete*",
              "dynamodb:Update*",
              "dynamodb:PutItem",
            ],
            Resource: [{ "Fn::GetAtt": ["connectionsTable", "Arn"] }],
          },
        ],
      },
    },

DynamoDB

serverless.ts
resources: {
    Resources: {
      // Table for recording connectionId
      connectionsTable: {
        Type: "AWS::DynamoDB::Table",
        Properties: {
          TableName: "${self:custom.DB_TABLE_NAME}",
          KeySchema: [
            {
              AttributeName: "connectionId",
              KeyType: "HASH",
            },
          ],
          AttributeDefinitions: [
            { AttributeName: "connectionId", AttributeType: "S" },
          ],
          // NOTE: for on demand capacity, not provisioned
          BillingMode: "PAY_PER_REQUEST",
        },
        UpdateReplacePolicy: "Delete",
        DeletionPolicy: "Delete",
      },
    },
  },

Lambda 関数と WebSocket API の紐付け

src/functions/send-message/index.ts
export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      websocket: {
        route: "sendmessage",
      },
    },
  ],
};

デプロイ

$ npx sls deploy --stage {app stage e.g. production, dev} --region {region e.g. ap-northeast-1} --aws-profile {your aws profile name}

stage や region などはserverless.ts(yml)内で設定することもできます。

Serverless.yml Reference
Serverless Framework: Websocket

APIGatewayMagegementAPI

接続者へのデータ送信は APIGatewayMagegementAPI を使って行います。
DynamoDB のconnectionsテーブルから現在接続されているクライアントの接続者 ID を取得し、それとメッセージを APIGatewayMagegementAPI に渡してデータを送信しています。

Node バージョン

src/functions/send-message/handler.ts
// Send message to all clients
const sendMessages = connections.Items.map(async ({ connectionId }) => {
  // exclude the sender of the message
  if (connectionId === event.requestContext.connectionId) return;
  try {
      await callbackAPI
      .postToConnection({ ConnectionId: connectionId, Data: message })
      .promise();
  } catch (e) {
      console.log(e);
  }
});

try {
  await Promise.all(sendMessages);
} catch (err) {
  return handleError(err);
}

Go バージョン

send_message/main.go
// Send message to all clients
var body Body
if err := json.Unmarshal([]byte(req.Body), &body); err != nil {
    return Response{StatusCode: 500}, err
}

managementAPI := apigatewaymanagementapi.New(session, &aws.Config{
    Endpoint: aws.String(req.RequestContext.DomainName + "/" + req.RequestContext.Stage),
})

for _, id := range connectionIDs {
    if id == connectionID {
        continue
    }
    if _, err := managementAPI.PostToConnection(&apigatewaymanagementapi.PostToConnectionInput{ConnectionId: &id, Data: []byte(body.Message)}); err != nil {
        return Response{StatusCode: 500}, err
    }
}

aws-cli を使って、ローカルから APIGatewayMagegementAPI 経由で接続者にメッセージを送信することもできます。

$ aws apigatewaymanagementapi post-to-connection \
--data $(echo '{ "name": "hello" }' | base64) \
--endpoint-url 'https://324v05mks9.execute-api.ap-northeast-1.amazonaws.com/dev' \
--connection-id 'bZYNAdtjNjMCKCw=' \
--profile {AWSプロファイル名}

別ターミナル
$ wscat -c wss://324v05mks9.execute-api.ap-northeast-1.amazonaws.com/dev
Connected (press CTRL+C to quit)
< { "name": "hello" }

AWS API Gateway の WebSocket API をちゃんと理解する
AWS Lambda 関数の呼び出しが AWS CLI v2 にアップデートすると失敗する

Node バージョン

3rd party libraries

- [json-schema-to-ts](https://github.com/ThomasAribart/json-schema-to-ts) - uses JSON-Schema definitions used by API Gateway for HTTP request validation to statically generate TypeScript types in your lambda's handler code base
- [middy](https://github.com/middyjs/middy) - middleware engine for Node.Js lambda. This template uses [http-json-body-parser](https://github.com/middyjs/middy/tree/master/packages/http-json-body-parser) to convert API Gateway `event.body` property, originally passed as a stringified JSON, to its corresponding parsed object

自動生成された README に記載されていましたが、デフォルトではjson-schema-to-tsmiddyというライブラリがバリデーションやミドルウェアとして使われていて、ハンドラ関数が export する際にラップされています。

/functions/hello/handler.ts
import { middyfy } from '@libs/lambda';
export const main = middyfy(hello);

今回は WebSocket API ではどう使うのか調べるのが面倒で、エラーも出てしまったので外してしまいましたが、便利そうなので使ってみたいですね。

Serverless Framework の aws-nodejs-typescript のテンプレートを詳しく見る

Go バージョン

Serverless Framework での Go プロジェクトの作り方は公式のブログ記事があったので、こちらを参考にしました。

Serverless Framework example for Golang and Lambda

最初、記事で使われているaws-go-depというテンプレートを使ったらビルドエラーが出て、地味にハマりました。

There are a couple Go templates already included with the Framework as of v1.26—aws-go for a basic service with two functions, and aws-go-dep for the basic service using the dep dependency management tool. Let's try the aws-go-dep template. You will need dep installed.

記事にちゃんと書いてありましたが、こちらのテンプレートは、Go Modules 以前のデファクトであったdepという依存ライブラリ管理ツールを使用するのが前提だったようです。

テンプレート一覧に載っていたaws-go-modを使ったところ、無事にビルドが通るようになりました。

感想

Node.js と Go x Serverless Framework x Websocket API(API Gateway)を使って、簡易なチャットアプリを再現できました。

Go x Serverless Framework の組み合わせも初めて試してみましたが、問題なく開発することができました。

個別のチャットルームを作ったり、過去のメッセージを表示したりなど、本格的なチャットアプリを作ってみたくなりますね。

GitHubで編集を提案

Discussion