💬

【AWS】WebSocket APIでリアルタイムチャット作ってみた(Amplify+Next+WebSocket API使用)

に公開

概要

会社で AWS を触ることになり、基本から学んでいこうと思ったため備忘録として記事を書き始めました。
今回は API Gateway で使用できる WebSocket API を使用して、AWS 上でリアルタイムチャットを作成してみようと思います。
もし理解が違うよというところ等ありましたら優しく教えて頂けると幸いです 🙇‍♀️

今回作る物

簡単なリアルタイムチャットを WebSocket API を利用して作成してみようと思います。
以下デモです。

WebSocket API とは

以下 WebSocket API の説明です。
少し長くなるので、リアルタイムチャットの作成方法だけ知りたい方はリアルタイムチャットを作成してみるの章をご覧ください 😌

WebSocket API とは

公式ドキュメント引用。
WebSocket API とは、API Gateway で作成できる Websocket 通信を実装するためのサービスです。
これだけでは分かりにくいので、そもそも WebSocket とは何か?というところから説明します。
WebSocket とは、従来の Web 上での通信に使用されてきた通信方法である HTTP 通信とは異なり、双方向通信を可能にした通信方法です。
以下図をご覧ください。

上が HTTP 通信によるクライアントとサーバの通信のやり取りです。
通信を行う際には常にクライアントからデータを送信しなければならず、サーバからクライアントに自発的にデータを送信するということができませんでした。
下は WebSocket 通信によるクライアントとサーバのやり取りです。
通信を行う際にサーバからクライアントに自発的にデータを送信することができるようになっています。
これによりあるクライアントがデータをサーバに送信したのを契機にサーバが他クライアントに同じデータを自発的に送信することが可能になりました。
こうした技術を用いて最近のチャットアプリは作成されています。
この WebSocket 通信を実装できるのが、AWS の WebSocket API です。
WebSocket API を使用する際に重要になるのが、ルートとコネクション ID です。
以下リアルタイムチャットを作成してみるの章で実際に実装しますが、WebSocket API ではルートによって行う処理を変えています。
ルートには種類があり、

  • connect ルート
  • disconnect ルート
  • default ルート
  • カスタムルート
    の四つがあります。
    connect ルートは WebSocket での接続が確立された時、disconnect ルートは WebSocket での接続が切断された時、default ルートはどのルートにも対応しない時、カスタムルートは自分で設定したルート先の処理を実行する時に利用されます。
    ルートと実際に処理が実装された Lambda 等を紐づけることで処理を実行することができます。
    また、コネクション ID とは WebSocket 通信が確立された時に発行される通信に紐づく ID で、この ID を利用することで指定のユーザーに対してメッセージを送信する等といったことができるようになっています。

リアルタイムチャットのアーキテクチャ図

以下は今回作成するリアルタイムチャットのアーキテクチャ図です。

以下アーキテクチャの説明になりますが、少し長くなるのでリアルタイムチャットの作成方法だけ知りたい方はリアルタイムチャットを作成してみるの章をご覧ください 😌

アーキテクチャの説明

まず、チャット画面は Next アプリケーションで作成し、Amplify でホスティングしています。
ユーザーはこのホスティングされた画面を自分の PC で見ることができます。
チャットでの通信時に使用されるメッセージやコネクション ID は DynamoDB で保存し、APIGateway + Lambda で
4 つの API を作成、その API がユーザーの PC と DynamoDB のやり取りを行なってくれます。
各 API はそれぞれ、

  • 初期メッセージを全て取得する getAllMessages 関数
  • WebSocket API でのコネクション確立時に実行される connect 関数
  • WebSocket API でのコネクション切断時に実行される disconnect 関数
  • ユーザーからのメッセージ送信を契機に実行される sendMessage 関数
    が Lambda で実装されています。
    sendMessage 関数は他クライアントへのサーバーからのメッセージ送信も同時に行うことができます。
    こうすることで、あるユーザーがメッセージ送信をしたのを契機とした他ユーザーへのリアルタイムのデータの反映を可能にしています。

リアルタイムチャットを作成してみる

さっそくリアルタイムチャットを作成してみます 💨

  1. チャットのデータを保管する DynamoDB を構築する

まず、チャットでやり取りされるメッセージ等を保存するために DynamoDB のテーブルを構築します。
DynamoDB でテーブルの構築を押下してください。

テーブルの作成で以下項目を入力してテーブルの作成ボタンを押下してください。

テーブル作成後、テーブル一覧の名前で message_rooms を押下 → テーブルアイテムの探索を押下 → 項目を作成を押下してください。



項目を作成画面で以下項目を入力して項目を作成ボタンを押下してください。

これで DynamoDB の初期テーブルの構築は完了です。

  1. API Gateway+Lambda で メッセージのやり取りが行える API を作成する

まず、初期メッセージを取得する getAllMessages 関数 を Lambda で作成します。

関数の作成ボタンを押下してください。

以下項目を入力して関数の作成ボタンを押下してください。

*1

コードソースに以下を入力して Deploy を押下してください。

*2

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

export const handler = async (event) => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const getCommand = new GetCommand({
    TableName: "message_rooms",
    Key: {
      room_id: 1,
    },
  });

  const docResponse = await docClient.send(getCommand);

  const response = {
    statusCode: 200,
    body: JSON.stringify({
      messages: docResponse.Item.messages,
    }),
  };
  return response;
};

設定タブ > アクセス権限 > ロール名を押下し getAllMessages-role...の許可ポリシーの AWSLambdaBasicExecutionRole...を押下してください。

編集を押下してください。

ポリシーエディタの Statement 配列に以下を追記して次へ > 変更を保存を押下してください。

*3

{
  ...
  "Statement": [
    {
			"Sid": "Statement1",
			"Effect": "Allow",
			"Action": [
				"dynamodb:*"
			],
			"Resource": "*"
		}
  ]
  ...
}

次に connect 関数、sendMessage 関数 disConnect 関数を作成します。
上記文章の中で米印がついた数字の箇所を各々下の対応する数字の入力項目と読み替えてください。

  • connect 関数

*1

関数名を connect に変える

*2

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  PutCommand,
  DynamoDBDocumentClient,
  GetCommand,
} from "@aws-sdk/lib-dynamodb";

export const handler = async (event) => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const getCommand = new GetCommand({
    TableName: "message_rooms",
    Key: {
      room_id: 1,
    },
  });

  const response = await docClient.send(getCommand);
  const connectionId = event.requestContext.connectionId;

  let connectionIds = response.Item.connection_ids;

  connectionIds.push(connectionId);

  const putCommand = new PutCommand({
    TableName: "message_rooms",
    Item: {
      ...response.Item,
      connection_ids: connectionIds,
    },
  });

  await docClient.send(putCommand);

  return {
    statusCode: 200,
  };
};

*3

{
  "Statement": [
    ...
    {
			"Sid": "Statement1",
			"Effect": "Allow",
			"Action": [
				"dynamodb:*"
			],
			"Resource": "*"
		},
		{
			"Sid": "Statement2",
			"Effect": "Allow",
			"Action": [
				"execute-api:*"
			],
			"Resource": "*"
		}
    ...
  ]
}
  • sendMessage 関数

*1

関数名を sendMessage に変える

*2

import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  PutCommand,
  DynamoDBDocumentClient,
  GetCommand,
} from "@aws-sdk/lib-dynamodb";

export const handler = async (event) => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const getCommand = new GetCommand({
    TableName: "message_rooms",
    Key: {
      room_id: 1,
    },
  });

  const response = await docClient.send(getCommand);
  const body = JSON.parse(event.body);

  const connectionIds = response.Item.connection_ids;
  let messages = response.Item.messages;

  messages.push({
    send_user: body.data.send_user,
    message: body.data.message,
  });

  const putCommand = new PutCommand({
    TableName: "message_rooms",
    Item: {
      ...response.Item,
      messages,
    },
  });

  await docClient.send(putCommand);

  const apigClient = new ApiGatewayManagementApiClient({
    endpoint:
      "https://" +
      event.requestContext.domainName +
      "/" +
      event.requestContext.stage,
  });

  const sendMessages = connectionIds.map(async (connectionId) => {
    const apigCommand = new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: JSON.stringify({ messages }),
    });

    await apigClient.send(apigCommand);
  });

  await Promise.all(sendMessages);

  return {
    statusCode: 200,
  };
};

*3

{
  "Statement": [
    ...
    {
			"Sid": "Statement1",
			"Effect": "Allow",
			"Action": [
				"dynamodb:*"
			],
			"Resource": [
				"*"
			]
		},
		{
			"Sid": "Statement2",
			"Effect": "Allow",
			"Action": [
				"execute-api:*"
			],
			"Resource": [
				"*"
			]
		}
    ...
  ]
}
  • disconnect 関数

*1

関数名を disconnect に変える

*2

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  PutCommand,
  DynamoDBDocumentClient,
  GetCommand,
} from "@aws-sdk/lib-dynamodb";

export const handler = async (event) => {
  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const getCommand = new GetCommand({
    TableName: "message_rooms",
    Key: {
      room_id: 1,
    },
  });

  const response = await docClient.send(getCommand);
  const connectionId = event.requestContext.connectionId;

  let connectionIds = response.Item.connection_ids;

  const index = connectionIds.indexOf(connectionId);
  connectionIds.splice(index, 1);

  const putCommand = new PutCommand({
    TableName: "message_rooms",
    Item: {
      ...response.Item,
      connection_ids: connectionIds,
    },
  });

  await docClient.send(putCommand);

  return {
    statusCode: 200,
  };
};

*3

{
  "Statement": [
    ...
    {
			"Sid": "Statement1",
			"Effect": "Allow",
			"Action": [
				"dynamodb:*"
			],
			"Resource": [
				"*"
			]
		}
    ...
  ]
}

次に、API Gateway で初期メッセージを取得する REST API を作成します。

API Gateway で API を作成ボタンを押下してください。

REST API の構築を押下してください。

作成で以下項目を入力して API の作成ボタンを押下してください。

アクション > メソッドの作成 > GET で GET メソッドを作成し、以下項目を入力して保存ボタンを押下してください。

アクション > API のデプロイで以下項目を入力してデプロイボタンを押下してください。

URL の呼び出しで公開される REST API の URL が表示されるので、メモしておいてください。

次に、connect、sendMessage、disconnect に対応する WebSocket API を作成します。

API Gateway で API を作成ボタンを押下してください。

WebSocket API の構築を押下してください。

API の詳細を指定するで以下項目を入力して次へボタンを押下してください。

ルートを追加で以下項目を入力して次へボタンを押下してください。

統合をアタッチするで以下項目を入力して次へボタンを押下してください。
指定する Lambda 関数はそれぞれ末尾が connect、disconnect、sendMessage で終わる関数にしてください。

次のステージを追加で次へ、確認して作成で作成してデプロイを押下してください。

WebSocket API がデプロイされたらステージ > production で WebSocket URL が表示されるのでメモしておいてください。

  1. Next+Amplify+CodeCommit でチャット画面を作成して web 上に公開する

次に、チャット画面 を作成して web 上に公開します。
CodeCommit で以下記事のCodeCommitを使ってみるの章の手順CodeCommitからHTTPSのクローンのクローン URL をクリップボードにコピーするところまでを行ってください。
コピーしたクローン URL はメモしておき、まだクローンはしないでください。
途中でリポジトリを作成しますが、設定するリポジトリ名はchat-frontにしてください。

https://www.hacknotes.jp/blog/codecommit-guide/#codecommitを使ってみる

以下コマンド実行して Next アプリケーションを作成してください。
途中の質問には以下の様に答えてください。

$ npx create-next-app@13
...
Need to install the following packages:
  create-next-app
Ok to proceed? (y) y
 What is your project named? chat-front
 Would you like to use TypeScript with this project? Yes
 Would you like to use ESLint with this project? Yes
 Would you like to use Tailwind CSS with this project? No
 Would you like to use `src/` directory with this project? Yes
 Would you like to use experimental `app/` directory with this project? No
 What import alias would you like configured? @/*
...

chat-front 内部で以下コマンドを実行し、チャットを実装する UI ライブラリchat-ui-kit-reactをインストールしてください。

$ npm install @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles

chat-front の src 配下のディレクトリ構造を以下の様に修正してください。

src
└── pages
    ├── _app.tsx
    ├── _document.tsx
    └── index.tsx

内部の各ファイルを下の様に修正してください。

_app.tsx

import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

_document.tsx

import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="ja">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
  MainContainer,
  ChatContainer,
  MessageList,
  Message,
  MessageInput,
} from "@chatscope/chat-ui-kit-react";
import Head from "next/head";
import { useEffect, useState } from "react";

const LOCAL_STORED_USER = "猫";

type Messages = {
  message: string;
  send_user: string;
}[];

type Response = {
  statusCode: number;
  body: string;
};

export default function Home() {
  const [messages, setMessages] = useState<Messages>([]);
  const [socket, setSocket] = useState<WebSocket>();

  useEffect(() => {
    const getAllMessages = async () => {
      const res = await fetch("REST APIのURL");
      const resJson: Response = await res.json();

      setMessages(JSON.parse(resJson.body).messages);
    };

    getAllMessages();

    const socket = new WebSocket("WebSocket APIのURL");

    setSocket(socket);

    socket.onmessage = (event) => {
      const newMessages = JSON.parse(event.data).messages;
      if (newMessages) setMessages(newMessages);
    };

    return () => {
      socket.close();
    };
  }, []);

  const handleSend = (innerHtml: string, textContent: string) => {
    socket?.send(
      JSON.stringify({
        action: "sendMessage",
        data: {
          send_user: LOCAL_STORED_USER,
          message: textContent,
        },
      })
    );
  };

  return (
    <>
      <Head>
        <title>チャットアプリケーション</title>
        <meta name="description" content="チャットアプリケーション" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head>
      <main>
        <div style={{ margin: "0 auto", width: "400px", height: "500px" }}>
          <MainContainer>
            <ChatContainer>
              <MessageList>
                {messages.map((message, i) => (
                  <div key={i}>
                    <Message
                      model={{
                        message: message.message,
                        sender: message.send_user,
                        direction:
                          message.send_user == LOCAL_STORED_USER
                            ? "outgoing"
                            : "incoming",
                        position: "normal",
                      }}
                    />
                    <Message.Footer
                      style={
                        message.send_user == LOCAL_STORED_USER
                          ? { textAlign: "right", display: "block" }
                          : undefined
                      }
                      sender={message.send_user}
                    >
                      {message.send_user}
                    </Message.Footer>
                  </div>
                ))}
              </MessageList>
              <MessageInput onSend={handleSend} />
            </ChatContainer>
          </MainContainer>
        </div>
      </main>
    </>
  );
}

修正が終わったら以下コマンドで CodeCommit 上のリポジトリにコードを push してください。
push 時にユーザー名とパスワードを求められた場合、上記記事を参考に入力してください。

$ git init
$ git remote add origin [chat-frontのクローンURL]
$ git add .
$ git commit -m "チャットアプリ作成"
$ git push origin main

次に Amplify で上記リポジトリをホスティングします。
Amplify 画面で Amplify ホスティングの使用を開始するを押下してください。

Amplify ホスティングの開始方法で下の様に入力して続行を押下してください。

リポジトリブランチの追加で下の様に入力して次へを押下してください。

ビルドの設定で次へを押下してください。

確認で保存してデプロイを押下してください。

下記 URL でチャット画面が Web 上に公開されています。
押下してアクセスしてください。

終わりに

ハンズオンお疲れ様でした。
頑張って楽しいハンズオンを作ろうとしたのですが、大変長くなってしまいました。
次回はもう少しさっくり読める記事も書いてみたいです。
他にもハンズオン記事をいくつか書いているので、興味のある方は是非読んでいただけると幸いです。
https://zenn.dev/alichan/articles/941cfbd68908fc
https://zenn.dev/alichan/articles/03dc627e490f4d
https://zenn.dev/alichan/articles/c40b793253f5db
https://zenn.dev/alichan/articles/c067fdee3f5870
ここまで読んでいただき本当にありがとうございます 🙇‍♀️

参照

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

Discussion