Serverless FrameworkでAPI GatewayのWebsocket使ってみる（TypeScript）

# 前書き
こちら、例のごとくめちゃくちゃ長い記事になります。なんせ、今回初めてAWSやserverless frameworkというものに触れた上に、あまりネットに情報が落ちていないwebsocketを使ってたり、みんながPythonを使う中、時代に逆らってNode.js(しかもこれまた情報が少ないTypeScript)で作ったり、serverless-offlineというものを使ってローカルでの実行をしたり、まあ大体必要なことをすべてやったので長くなるのは必然...。
ちなみに自分がこの構成で行こうと思ったのは以下のようなプロセスからです。

1. なんかサービス立ち上げたいけどサーバの管理やりたくないからAWSのサーバレスかな...
1. 今回のサービスはチャットとかタスク管理機能でリアルタイム通信を実装したいからWebSocketかな
1. API GatewayとLambdaというのを使えばWebSocketで通信するAPIを作成できるっぽい
1. ちょっと触ってみたけどどうやってソース管理とかデプロイを管理すればいいんや？ → Serverless Frameworkとやらを使えば全部ソースで管理できるからGitが使えそう
1. 開発中はローカルで実行しないと金額がえぐい... → serverless-offlineというやつがいるみたい

みたいな流れでServerless Frameworkにしてみました（AWS SAMというのもありますが、情報量的に今回はServerless Frameworkかなと思いました）。

AWSに関する技術は年々進化していて、ネットに落ちている情報どおりにやると全然うまくいかないので、とりあえず2023年5月現在の最新のやり方で書いていこうかと思います。あと、ほかの記事ではDynamoDBに接続情報を入れてみたりとか、全然Websocketの接続自体の関心事とは違う部分でめちゃくちゃコードがややこしくなったりしてるのをよく見かけるので、この記事では以下のことを最低限達成できるところまでを目標に書きます。

* Serverless Frameworkでプロジェクト・ソース管理を楽に行えるようにする
* ランタイムはNode.js(18.x)、TypeScriptでソースをかけるようにする
* Webscoketでコマンドラインから接続・テストメッセージ送受信・切断が行える
* ローカル環境での実行ができる

とりあえず最低限以上のことができるところまでやります。ほんとに初めてAWSに触れるので間違っているところがあればご指摘いただければと思います。
さて、前置きが長くなりましたがやっていきましょう。

ちなみに今回作ったプロジェクトのリポジトリも置いておきますね。
https://github.com/Uta-member/aws-serverless-websocket-ts

# AWSの設定
今回、AWSの設定に関してはあまり詳しく書きません（自分もいまいちわかっていない部分が多いのと、記事がめちゃくちゃ長くなるので...）。
とりあえず最低限必要なのは以下だと思います。

* AWSアカウントを作成する
* AWS CLIをPCにインストールする(https://aws.amazon.com/jp/cli/)
* AWS CLIでアクセスキーとシークレットアクセスキーを設定する。

[Udemyの記事](https://udemy.benesse.co.jp/development/system/aws-cli.html)とかもあるので、それを読めばわかるかとは思います。

# Serverless Frameworkのプロジェクト作成
自分はnpmよりyarn派なのでyarnで書きます。yarnをインストールしてない方は以下のコマンドでインストールしてください。
```bash
npm install -g yarn
```

ではまず以下のコマンドでServerless Frameworkのインストールを行います。

```bash
yarn add -D serverless
```

Serverless Frameworkがインストールできたら、次はプロジェクトを作成していきます。公式がTypeScriptのテンプレートを出してくれていて、結構使いやすいのでそれを使っていきます。
プロジェクトフォルダを作りたいフォルダの配下で以下のコマンドを実行してください。今回はプロジェクト名を`aws-websocket-ts`としています。
```bash
serverless create --template aws-nodejs-typescript --path ./aws-websocket-ts
```

プロジェクト作成後はnode_modulesをインストールする必要があるため、以下のコマンドを実行してください。`aws-websocket-ts`の部分はプロジェクトのフォルダ名です。

```
cd ./aws-websocket-ts
yarn
```
また、オフラインでAPIGatewayやLambdaをシミュレートするため、serverless-offlineというモジュールを使うのでインストールします。
```bash
yarn add -D serverless-offline
```

# serverless.tsを編集する
プロジェクトフォルダ内に`serverless.ts`というファイルがありますが、若干変えます。変えたいのはランタイムのNode.jsのバージョンです。デフォルトでは14.xになっていますが、古すぎるので現在最新のNode.js18.xにします。また、websocketやserverless-offlineを使うために以下のように追記します。
```ts:serverless.ts
import type { AWS } from "@serverless/typescript";

import hello from "@functions/hello";

const serverlessConfiguration: AWS = {
  service: "aws-websocket-ts",
  frameworkVersion: "3",
  // pluginsにserverless-offlineを追加
  plugins: ["serverless-esbuild", "serverless-offline"],
  provider: {
    name: "aws",
    // 18にする
    runtime: "nodejs18.x",
    // websocket関連の機能を使うには以下を追記
    websocketsApiName: "websocket-test",
    websocketsApiRouteSelectionExpression: "$request.body.action",
    websocketsDescription: "test api websocket",

    apiGateway: {
      minimumCompressionSize: 1024,
      shouldStartNameWithService: true,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
      NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
    },
  },
  // import the function via paths
  functions: { hello },
  package: { individually: true },
  custom: {
    esbuild: {
      bundle: true,
      minify: false,
      sourcemap: true,
      exclude: ["aws-sdk"],
      // 18にする
      target: "node18",
      define: { "require.resolve": undefined },
      platform: "node",
      concurrency: 10,
    },
  },
};

module.exports = serverlessConfiguration;

```

# websocketの関数を作成する
まず、AWS SDKが必要なのでインストールします。なお、AWS SDK v3というのを使いますが、普通にaws-sdkをインストールするとv2になってしまうので、`@aws-sdk/hogehoge`でインストールする必要があります。今回は`client-apigatewaymanagementapi`というのを使います。
```bash
yarn add @aws-sdk/client-apigatewaymanagementapi
```

API Gatewayでwebsocketを使う際、以下の4種類のルートが使用できます。
* $connect
    * 接続時の処理。
* $disconnect
    * 切断時の処理
* カスタムルート(sendMessageとかtestFuncとかなんでもいい)
    * $connectで接続した後の実際のやり取りをするためのルート
* $default
    * 上記のどれにも当てはまらなかった時の処理

以上4つを定義していきます。今回はソースが長くなるとわかりにくくなると思ったので、カスタムルートに関してはほかの記事とかでもよく見かけるsendmessageっていう名前のルートを作成します。別にsendmessageという名前に何か意味があるわけではないのでなんでもいいですが。

## 共通処理
まず共通で使う処理を書いていきます。こういうのがあると記事がややこしくなるのは百も承知ですが、どうしてもめんどくさい処理が2つあるので共通化させてください...。
共通処理を入れておくフォルダとして、`src`配下に`common`というフォルダを作成しましょう。

### endpointを取得する関数
まずはwebsocketのエンドポイントを取得する関数です。ローカル実行しないのであれば固定なのであんまり関係ありませんが、今回はローカルで試したいので、httpの利用やポートなどの設定のために必要です。
`src/common`内に`endpoint.ts`というファイルを作成し、以下のように記述してください。

```ts:endpoint.ts
import { APIGatewayEventWebsocketRequestContextV2 } from "aws-lambda";

/**
 * websocketのエンドポイントを返す関数
 * @param requestContext eventのrequestContext
 * @returns エンドポイント
 */
export const getEndpoint = (
  requestContext: APIGatewayEventWebsocketRequestContextV2
) => {
  // ローカルでやるときとデプロイ後でhttpとhttpsを切り替える
  const { domainName, stage } = requestContext;
  /**
   * serverless-offlineでローカル実行するときのエンドポイント
   */
  const localEndpoint = "http://localhost:3001";
  /**
   * デプロイ後のエンドポイント。
   * 先頭にhttps://をつけないとエラーになります。
   * ドキュメントどおりに書くとうまくいかないのでご注意...
   */
  const cloudEndpoint = `https://${domainName}/${stage}`;

  // serverless-offlineで実行してるときは、stageがlocalになってるので
  // localのエンドポイントを使う
  const endpoint = stage === "local" ? localEndpoint : cloudEndpoint;

  return endpoint;
};

```
ちなみにここが一番のハマりポイントでした。ほかの記事を見てもらうとわかるのですが、endpointには`domainName + "/" + stage`としか書いておらず、実際やってみるとエラーになります。ちなみに、serverless-offlineで実行する際はrequestContextのstageが`local`になるので、とりあえずそのまま`http://localhost:3001`を入れてますが、ポートを変えたいときとかにここも修正する必要があるので、本来は環境変数でやるべきですが、今回は割愛します。（環境変数もまた記事をややこしくするので...）。

### データ変換関数
これまたネットの情報通りやるとうまくいかなくなるので先手を打っておきましょう。ネットの記事を見ると、クライアントにデータを送る際にJSON.stringifyしたものを送っていますが、それではダメです。aws-sdk v3では`Uint8Array`という型で送る必要があります。毎度その処理を書くのは面倒なので、先に関数化しましょう。
`src/common`配下に`dataEncoder.ts`というファイルを作成し、以下のように記述します。
```ts:dataEncoder.ts
import { TextEncoder } from "util";

export const encodeObjectToUint8Array = (data: { [key: string]: any }) => {
  const uint8ArrayData = new TextEncoder().encode(JSON.stringify(data));

  return uint8ArrayData;
};
```
まあこれは普通に標準の機能で出来るので苦労しませんでしたが、データの型が聞いたことない型だったので若干ハマりかけました。

これで共通処理は書けたので、実際の関数を作成していきます。

## $connect
まず接続処理を書いていきます。とはいっても、そもそも接続に関してはAPI Gatewayがやってくれるので、こちらはルートを開いてstatusをreturnするだけの関数を書けばいいです。もしconnectIdをDBで管理してメッセージグループを作ったりとかやりたければ、ここでDBにConnectionIdとかを格納しましょう。引数に入ってくるevent.requestContextとかにいろいろデータが入ってます。
まず`src/functions`配下に`onConnect`というフォルダを作成し、その中に`handler.ts`と`index.ts`を作成します。
`handler.ts`はlambda関数の部分になります。以下のように記述してください。
```ts:handler.ts
import { APIGatewayProxyWebsocketHandlerV2 } from "aws-lambda";

/**
 * websocket接続時の処理
 * @param _event
 * @param _context
 * @param _callback
 * @returns
 */
const onConnect: APIGatewayProxyWebsocketHandlerV2 = async (
  _event,
  _context,
  _callback
) => {
  try {
    console.log("connected!!");
    return {
      statusCode: 200,
      body: "connected",
    };
  } catch (err) {
    console.log(err);
  }
};

export const main = onConnect;
```

`index.ts`はhandlerをAPIGatewayのルートにつなげるところを定義します。以下のように記述します。
```ts:index.ts
import { handlerPath } from "@libs/handler-resolver";

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      websocket: {
        route: "$connect",
      },
    },
  ],
};
```

## $disconnect
切断時の処理を書きます。これも内部で何かしたりはしませんが、接続の管理をDBで行っている場合はここでDBからデータを消したりとかするといいんじゃないかと思います。
`src/functions`配下に`onDisconnect`というフォルダを作成し、その中に`handler.ts`と`index.ts`を作成します。
`handler.ts`はlambda関数の部分になります。以下のように記述してください。

```ts:handler.ts
import { APIGatewayProxyWebsocketHandlerV2 } from "aws-lambda";

/**
 * websocket切断時の処理
 * @param _event
 * @param _context
 * @param _callback
 * @returns
 */
const onDisconnect: APIGatewayProxyWebsocketHandlerV2 = async (
  _event,
  _context,
  _callback
) => {
  try {
    console.log("disconnected!!");
    return {
      statusCode: 200,
      body: "disconnected",
    };
  } catch (err) {
    console.log(err);
  }
};

export const main = onDisconnect;
```

`index.ts`には以下のように記述します。routeには`$disconnect`を渡してます。
```ts:index.ts
import { handlerPath } from "@libs/handler-resolver";

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      websocket: {
        route: "$disconnect",
      },
    },
  ],
};
```

## sendMessage
次はカスタムルートです。何度でも言いますが、別にsendMessageという名前に意味はないので、好きな名前で構いません。
例のごとく、`src/functions`配下に`sendMessage`というフォルダを作成し、その中に`handler.ts`と`index.ts`を作成します。
`handler.ts`はlambda関数の部分になります。以下のように記述してください。

```ts:handler.ts
import { APIGatewayProxyWebsocketHandlerV2 } from "aws-lambda";
import { ApiGatewayManagementApi } from "@aws-sdk/client-apigatewaymanagementapi";
import { encodeObjectToUint8Array } from "src/common/dataEncoder";
import { getEndpoint } from "src/common/endpoint";

/**
 * テストでメッセージを送信してただmessage success!!とbodyを返すだけの関数
 * @param event
 * @param _context
 * @param _callback
 * @returns
 */
const sendMessage: APIGatewayProxyWebsocketHandlerV2 = async (
  event,
  _context,
  _callback
) => {
  try {
    const apiManage = new ApiGatewayManagementApi({
      apiVersion: "2018-11-29",
      endpoint: getEndpoint(event.requestContext),
    });

    const data = encodeObjectToUint8Array({ message: "message success!!", body: event.body });

    // 送る処理
    await apiManage.postToConnection({
      ConnectionId: event.requestContext.connectionId,
      Data: data,
    });

    return {
      statusCode: 200,
      body: "sendMessage",
    };
  } catch (err) {
    console.log(err);
  }
};

export const main = sendMessage;
```
indexには以下のように記述します。
```ts:index.ts
import { handlerPath } from "@libs/handler-resolver";

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      websocket: {
        route: "sendmessage",
      },
    },
  ],
};
```

## $default
次は、どのルートにも当てはまらないリクエストを処理する関数です。同じように書いていきましょう。
`src/functions`配下に`defaultFunction`というフォルダを作成し、その中に`handler.ts`と`index.ts`を作成します。
`handler.ts`はlambda関数の部分になります。以下のように記述してください。
```ts:handler.ts
import { APIGatewayProxyWebsocketHandlerV2 } from "aws-lambda";
import { ApiGatewayManagementApi } from "@aws-sdk/client-apigatewaymanagementapi";
import { encodeObjectToUint8Array } from "src/common/dataEncoder";
import { getEndpoint } from "src/common/endpoint";

/**
 * 定義されていないルートでリクエストされたときの処理
 * @param event
 * @param _context
 * @param _callback
 * @returns
 */
const defaultFunction: APIGatewayProxyWebsocketHandlerV2 = async (
  event,
  _context,
  _callback
) => {
  try {
    const apiManage = new ApiGatewayManagementApi({
      apiVersion: "2018-11-29",
      endpoint: getEndpoint(event.requestContext),
    });

    const data = encodeObjectToUint8Array({ message: "not defined action..." });

    // 送る処理
    await apiManage.postToConnection({
      ConnectionId: event.requestContext.connectionId,
      Data: data,
    });

    return {
      statusCode: 200,
      body: "defaultRoute",
    };
  } catch (err) {
    console.log(err);
  }
};

export const main = defaultFunction;
```

index.tsは以下のように記述してください。
```ts:index.ts
import { handlerPath } from "@libs/handler-resolver";

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  events: [
    {
      websocket: {
        route: "$default",
      },
    },
  ],
};
```

## serverless.tsに関数を追加する
関数ができたので、それらを`serverless.ts`で呼び出す必要があります。以下のように追記しましょう。
```ts:serverless.ts
import type { AWS } from "@serverless/typescript";

import hello from "@functions/hello";
import defaultFunction from "@functions/defaultFunction";
import onConnect from "@functions/onConnect";
import onDisconnect from "@functions/onDisconnect";
import sendMessage from "@functions/sendMessage";

const serverlessConfiguration: AWS = {
  service: "aws-websocket-ts",
  frameworkVersion: "3",
  plugins: ["serverless-esbuild"],
  provider: {
    name: "aws",
    runtime: "nodejs18.x",
    websocketsApiName: "websocket-test",
    websocketsApiRouteSelectionExpression: "$request.body.action",
    websocketsDescription: "test api websocket",

    apiGateway: {
      minimumCompressionSize: 1024,
      shouldStartNameWithService: true,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
      NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
    },
  },
  // import the function via paths
  // functionsに関数を追加する
  functions: { hello, onConnect, onDisconnect, defaultFunction, sendMessage },
  package: { individually: true },
  custom: {
    esbuild: {
      bundle: true,
      minify: false,
      sourcemap: true,
      exclude: ["aws-sdk"],
      target: "node18",
      define: { "require.resolve": undefined },
      platform: "node",
      concurrency: 10,
    },
  },
};

module.exports = serverlessConfiguration;
```

これでAPI側の設定は完了です。いよいよ接続テストです。

# 接続テスト
接続テストをするにあたって、いちいちクライアントのコードを書くのは面倒なので、Websocketのテストを行うモジュールを入れましょう。`wscat`というのが便利なのでインストールします。
```
yarn add -D wscat
```

インストールできたらさっそくテストしていきます。APIをローカルで立ち上げましょう。その際、`serverless offline`というコマンドを使いますが、結構長くて毎回打ち込むのが面倒なので、package.jsonのscriptsに登録しましょう。ついでにデプロイのコマンドも入れておきましょう。
```package.json
{
  "name": "aws-websocket-ts",
  "version": "1.0.0",
  "description": "Serverless aws-nodejs-typescript template",
  "main": "serverless.ts",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "serverless offline",
    "deploy": "serverless deploy"
  },
  ...
}
```

package.jsonに追記して保存したら以下のコマンドをプロジェクトフォルダ直下で実行します。
```
yarn start
```

無事立ち上がると、以下のような表示になると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524944/54e39f58-1e58-d323-5216-afcf202cc0f1.png)

これで`ws://localhost:3001`にアクセスすればwebsocketの通信ができる状態なので、wscatで接続していきます。

コマンドプロンプトなどを開いて以下のコマンドを実行します。
```bash
wscat -c ws://localhost:3001
```

うまく接続できれば、以下のようにAPI側のコマンドラインに`connected!!`と表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524944/9b9f390d-1b73-d9cb-4cf6-5b09315e3b25.png)

次はsendmessageしてみましょう。
コマンドプロンプトのほうで以下のように入力します。`action`にルートを入れてあげることで、定義したカスタムルートにアクセスできます。
```
{"action":"sendmessage","data":"hello!"}
```
成功すれば以下のような表示になると思います。ちゃんと送ったデータが返ってきてます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524944/3a0fbc72-10cc-9793-cfc3-8af24e4c90e6.png)

では、以下のようにリクエストしてみましょう。
```
{"action":"send","data":"hello!"}
```
こちらは定義していないルートなので、$defaultの処理が呼ばれていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524944/1f064d1b-c531-60a2-6e41-1dba9535016b.png)

wscatの通信はCTRL + Cで終了できます。実際にフロントを作る際は、ちゃんとWebsocketの通信を閉じる処理を書く必要があると思いますが、wscatはCTRL+Cで終了できます。

# デプロイ
ローカルでのテストはこれで完璧ですね。最後に、実際のAWS環境にデプロイしてみましょう。プロジェクトフォルダ直下で以下のコマンドを実行してください。
```
yarn deploy
```
ちゃんとAWS CLIとかの設定ができていれば問題なくデプロイできると思います。
デプロイできたらエンドポイントのURLが表示されるので、さっきと同じようにwscatで一通り試してみましょう。wscat -cのうしろにエンドポイントのURLを入れるだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524944/722d58ef-2b00-689e-ffe0-11664db86a43.png)
上の画像でいうと、`wss://8z11y1bqrg.execute-api.us-east-1.amazonaws.com/dev`ですね。以下のようにすればアクセスできます。
```
wscat -c wss://8z11y1bqrg.execute-api.us-east-1.amazonaws.com/dev
```
AWSのマネジメントコンソールにアクセスすればAPI Gatewayにちゃんとデプロイできていることが確認できるので、そちらも確認していただければと思います。

デプロイしたものを削除したい場合は、プロジェクトフォルダ直下で以下のコマンドを実行します。
```
serverless remove
```
まあこのコマンドはそうそう使うことないと思うんでpackage.jsonのscriptsには入れませんでしたので、そのままserverlessコマンドでやっています。

# 最後に
今回、初めてAWSを扱ってみましたが、やはりインフラをほとんど気にせずアプリを作れるっていいですよね。実際使うときはこの記事のままではなく、DBにつないだりとかConnectionIdをDBで管理して複数ユーザに同時にメッセージを送ったりとか、CI/CDを導入したりとかすると思いますが、今回はとにかく繋げて通信できてオフラインとAWSで実行ができればいいというのがコンセプトなのでシンプルを目指しました。
まああと、この方法ではAWSにめちゃくちゃ依存してしまうため、本当はserverless-expressとやらを使いたかったんですが、websocketを使おうとするとめちゃくちゃ大変そうというのと、まあ今後APIGatewayとLambdaが消え去るころにはさすがにサービスも寿命を迎えているはずなので、ビジネスロジックをちゃんと分離できていれば資源も使いまわしつつ新しいプラットフォームに移行することはできるんじゃないかなと思っています。

今後もAWS関連の記事を積極的に上げていきますので是非フォロー等よろしくお願いします。