【AWSで構築するサーバレスWebSocket①】 AWS APIGatewayからWebSocket APIを試してみる


2021/07/22
蛸壺の技術ブログ|AWS APIGatewayからWebSocket APIを試してみる

以前、別ブログでやっていたRestAPIを手動で構築する方法を紹介していました。

今回はRest APIではなく別の選択肢としてもっとも簡単なWebSocket APIの作成にチャレンジしていきます。


手動でWebSocket APIを構築する

合同会社タコスキングダム|蛸壺の技術ブログ

今回は上の図のようなもっともシンプルなWebSocket APIを利用したチャットアプリのようなものをAPI Gatewayで構築してみましょう。

WebSocket APIルート

特定のリクエストで使用するルートを決定する際に利用する、ルートセレクションエクスプレッション(ルート選択式)を指定する必要があります。

このルート選択式とは、クライアントから送られるリクエストに対して、
ルートとして定義されるrouteKey値を評価することで、送り先のAPI(Lambda)のハンドラを決定するルーティングの手法です。

同じルートで接続したクライアント間に対して双方向通信が確立するようにできます。

API Gatewayが内部で定義しているrouteKeyには、自分で定義して使うルート以外に、以下の特別な3つルートがあります。

            
            $default :
    RSXがAPIルート内の他のrouteKeyに一致しない場合に使用される。
    例としてはエラー処理などを実装するときに利用する

$connect :
    クライアントが最初に接続するときの処理に使うルート

$disconnect :
    クライアントが切断する処理に使うルート。
    実装は任意で、切断処理をカスタマイズする場合に利用
        
また、自分で定義しているルートはリクエストボディのコンテクストから指定します。

デフォルトではユーザー定義のrouteKeyは
$request.body.actionというフィールドパスに設定されるようですが、このフィールドも自由に変更可能です。

Websocket APIの処理の大まかな流れとしては、

            
            1. WebSocket API接続時にクライアントを情報をユーザーとして登録
2. 接続後に提供されるコールバックURLを使用して特定のユーザーにメッセージを送信
3. 接続確立後にユーザーは相互にメッセージを送受信する
4. ユーザーが切断後は登録情報を削除する
        
というフローになると思います。


ルートハンドラの実装

まずは先にLambdaのダッシュボードからWebSocket APIの各ルートの使うハンドラ関数から仕込んでいきます。

いかのように4つのLambda関数を新規作成します。

            
            + hello-websocket-onconnect :
    $connectルートに対応。
    接続時の初期化処理
+ hello-websocket-ondisconnect :
    $disconnectルートに対応。
    切断時の後処理
+ hello-websocket-onerror :
    $defaultルートに対応。
    接続中のエラーハンドリング
+ hello-websocket-onmessage :
    カスタムルートに対応。
    接続中のクライアント間メッセージを転送
        
Lambdaの新規作成の手順の説明は簡略化しますが、以下の図のようにシンプルなHello Worldテンプレートから作成した関数のindex.jsを編集してDeployするだけです。

合同会社タコスキングダム|蛸壺の技術ブログ

では作成した4つのLambdaのindex.jsの中身を後述していきます。

WebSocket APIのアクセス権限

ハンドラ関数の実行ロールでアクセス権限が不足しているとWebSocket APIが実行できない場合があります。

合同会社タコスキングダム|蛸壺の技術ブログ

Lambdaを実行する
AWSLambdaBasicExecutionRoleは必要ですが、AmazonAPIGatewayInvokeFullAccessを追加することで、aws-sdk越しにAPI Gatewayを叩けるようにしないと、execute-api:ManageConnectionsの権限エラーが付くことになってしまいます。

$connectイベント用のハンドラ

$connectと後述する$disconnectのルートは接続しているクライアントを適切に追跡するためのイベントです。

例えばonConnect関数は、requestContextでユーザーから送られてくる登録情報を元に、データベースにレコードする処理を行うことができます。

$connect用のLambdaを設定するかしないかは選択できるため、このハンドラ関数の実装も任意です。

今回はただのステータスコードを返すだけにしておきます。

            
            exports.handler = async (event, context, callback) => {

    //何か接続開始処理を行う

    try {
        return {
            statusCode: 200,
            body: 'Connected! Hello WebSocket App!',
        };
    } catch(e) {
        return {
            statusCode: 500,
            body: `Failed to connect: ${JSON.stringify(e)}`,
        };
    }
};
        

$disconnectイベント用のハンドラ

通常このイベントでは、切断されたクライアントの情報を元に、DynamoDBなどでレコードされた値を削除するような処理を割り当てます。

さきほどの$disconnect同様、このハンドラ関数の実装は任意です。

            
            exports.handler = async (event, context, callback) => {

    //何か切断処理を行う

    try {
        return {
            statusCode: 200,
            body: 'Disconnected! Good-bye WebSocket App!',
        };
    } catch(e) {
        return {
            statusCode: 500,
            body: `Failed to disconnect: ${JSON.stringify(e)}`,
        };
    }
};
        

$defaultイベント用のハンドラ

このイベントは$connect、$disconnect、そしてユーザーカスタムルート以外のルートが検知されたときに処理されるためのものです。例えば意図としないルートでWebSocketのエンドポイントがアクセスされた場合のエラーハンドリングなどに利用できます。

このハンドラ関数の実装は任意です。

            
            exports.handler = async (event, context, callback) => {

    //何かのルート解析処理を行う

    return {
        statusCode: 200,
        body: 'Undefined Route Detected!',
    };
};
        
今回は具体的なルートエラーハンドリングの実装は説明しませんがとりあえず仕込んでおきます。

ユーザー定義イベント用のハンドラ

WebSocket API作成でもっとも重要なものが、ユーザー定義ルートのハンドリングです。

ここでは例として
sendMessageRouteという名前でルートを作成し、このルートイベント発火するようにハンドラ関数を作成してみましょう。

このハンドラ関数は、リクエストで送られてきたJSON形式のメッセージオブジェクトから
request.body.actionに設定された値を参照し、sendMessageRouteであった場合にのみトリガーされる仕組みになっています。

WebSocket APIに接続の確立したWebCleintクライアントは、以下のJSON形式の文字列をサーバーに対し送信することで通信を行います。

            
            {
    "action": "sendMessageRoute",// ルート変数は必須
    "data": "<送信したいデータ(UTF8)>",
    "arg1": "<ユーザー定義変数1>",
    "arg2": "<ユーザー定義変数2>",
    ...
}
        
このJSONデータではrouteKeyとして設定した変数であるactionは必須です。他のフィールドは副次的なもので、各自でフィールド名もカスタマイズが可能です。

このWebSocketクライアントから送られてきた文字列をハンドラ関数のイベントのリクエストコンテクスト(requestContext)で受け取ることでLambda側の処理が可能です。

今回の処理手順としては主に以下のようになります。

            
            1. WebSocket通信するためApiGatewayManagementApiインスタンスを生成。
    コンストラクター引数ではendpointが必要となり、
    その際にrequestContextからdomainNameとstageを利用する

2. ハンドラ関数のevent引数に渡されるRequestContextからconnectionIdを取得

3. WebSocketクライアントからのメッセージがforwardが空でなく、
    指定先のクライアントのconnectionIdがあった場合には、
    メッセージをそのクライアントに転送先を変更する。
    forwardが空だった場合には、自分にメッセージを送る

4. ApiGatewayManagementApiインスタンスから、
    postToConnectionメソッドで指定のクライアントにメッセージを送信する
        
ここでWebSocket APIの機能の中核を担うのがAWS.ApiGatewayManagementApiクラスです。このクラスに用意されているメソッドがWebSocket APIの全てであると言っても良いので、より進んだ機能を実装したい方はこのドキュメントの中身を良く理解する必要があります。

では先程の処理手順1-4に沿って、クライアント間でメッセージをやり取りするだけのハンドラ関数の実装を以下に示します。

            
            const AWS = require('aws-sdk');
AWS.config.update({ region: process.env.AWS_REGION });

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

exports.handler = async (event, context, callback) => {
    const apigwManagementApi = new AWS.ApiGatewayManagementApi({
        apiVersion: "2018-11-29",
        endpoint: event.requestContext.domainName + "/" + event.requestContext.stage
    });
    const forward_id = JSON.parse(event.body).forward;
    const ConnectionId = forward_id != null ? forward_id : event.requestContext.connectionId;
    const Data = forward_id != null ? `${JSON.parse(event.body).data}` : `YOUR ID: ${ConnectionId}`;

    const postParams = { ConnectionId, Data };
    apigwManagementApi.postToConnection(postParams, (err) => {
        if (err) {
            console.log("Failed to post. Error: " + JSON.stringify(err));
        }
    });

    callback(null, {
        statusCode: 200,
        body: "Send data!"
    });
};
        
単にクライアントの接続IDを指定してメッセージを投げるだけのものですが、処理の初歩として理解するには良い題材です。

では、ここまでで各ルートに対するLambdaハンドラの下準備を終えましたので、API Gatewayの準備の手順を以下で説明していきます。


API Gatewayインスタンスの作成

API GatewayのダッシュボードからWebSocket APIを新規作成します。

合同会社タコスキングダム|蛸壺の技術ブログ

まずはAPI名を
wsMyAppとしておきます。

またルート選択式の引き出し変数を
request.body.actionに位置に指定しておきます。

合同会社タコスキングダム|蛸壺の技術ブログ

次のページでWebSocket APIで必要なルートを選択追加します。

合同会社タコスキングダム|蛸壺の技術ブログ

先行して作成していたLambdaハンドラ関数を各ルートイベントに割り当てます。

合同会社タコスキングダム|蛸壺の技術ブログ

ステージ名は任意です。

合同会社タコスキングダム|蛸壺の技術ブログ

内容を確認したらデプロイを実行します。

合同会社タコスキングダム|蛸壺の技術ブログ

問題がなければ、API Gatewayインスタンスのデプロイが成功しています。

合同会社タコスキングダム|蛸壺の技術ブログ


2つのクライアント間での接続確認

それでは先ほど作ったWebSocket APIの動作確認をやっていきます。

wscatを使ってコンソールで確認

wscatはコマンドラインから軽量に使えるWebSocketクライアント用のプログラムで、WebSocketアプリ開発でも良く利用されているツールです。

node.js環境下で動作しますので、以下のコマンドでインストールすると即時利用可能です。

            
            $ npm install -g wscat
        
wscatコマンドからWebSocket APIを呼び出すためには、先程作ったAPI Gateway側のエンドポイントにアクセスする必要があります。

合同会社タコスキングダム|蛸壺の技術ブログ

2つのWebSocketクライアントから呼び出せるように、ここでターミナルを二つ開いておきましょう。

1つ目のターミナルで早速WebSocket API側に接続してみます。

            
            $ wscat -c wss://[APIのドメインID].[APIのリージョン名].amazonaws.com/[APIをデプロイしたステージ名]
Connected (press CTRL+C to quit)
#👇forwardフィールド無しでsendMessageRouteへシグナル送信
>  {"action":"sendMessageRoute", "data":"who am i"}
< YOUR ID: C51DherHNjMCI3Q=
        
本来は接続完了時にWebSocketクライアントのconnectionId値を格納するデータベース(dynamoDBなど)をバックエンドで保管・管理するやり方が普通です。今回はテストということで敢えて相手方の接続IDを直接入力するやり方で双方向通信をやっています。

また別のターミナルでもWebSocketクライアントを立ち上げてみます。

            
            $ wscat -c wss://[APIのドメインID].[APIのリージョン名].amazonaws.com/[APIをデプロイしたステージ名]
Connected (press CTRL+C to quit)
>  {"action":"sendMessageRoute", "data":"who am i"}
< YOUR ID: C50a8cvrtjMCI7w=
        
最初のクライアントとはまた別の接続IDが得られたことが分かります。

では、最初のクライアント(wscat1)から2つ目のクライアント(wscat2)へメッセージを送信してみます。

wscat1のターミナルでwscat2の接続IDを指定し、dataを送信してみましょう。

            
            > {"action":"sendMessageRoute", "data":"Hello wscat2!", "forward":"C50a8cvrtjMCI7w="}
        
するとwscat2のターミナルでは

            
            < Hello wscat2!
        
と表示されているならば成功です。

逆にwscat2からwscat1に以下を送信してみます。

            
            > {"action":"sendMessageRoute", "data":"Hello wscat1!", "forward":"C51DherHNjMCI3Q="}
        
今度はwscat1のターミナルに

            
            < Hello wscat1!
        
という表示があれば相互に通信が確立していることが分かります。

合同会社タコスキングダム|蛸壺の技術ブログ

ブラウザで確認

ブラウザ側でWebSocketクライアントとして利用するためにはJavascriptスクリプトからWebSocket APIクラスを実装して使う必要があります。

ここでは実装の方針だけを掻い摘むと、以下のような使い方になるでしょう。

            
            //WebSocket APIでインスタンスを生成
const ws = new WebSocket("wss://[APIのドメインID].[APIのリージョン名].amazonaws.com/[APIをデプロイしたステージ名]")

//メッセージの受信のためのコールバック
ws.onmessage = (e) => {
    const data = JSON.parse(e.data);
    // ...
}

//定義済みルートへメッセージ送信するメソッド
const message = {
    "action": "sendMessageRoute",
    "data": "送信メッセージ",
    // ...
}
ws.send(JSON.stringify(message))
        
ユーザーにブラウザからアクセスさせて使う場合、わざわざ別のクライアントの接続IDを指定させて利用させるのは不便です。

このためバックエンドでクライアント情報を管理できるデータベースをセットでWebSocketアプリケーションを構築する必要が出てきます。


まとめ

今回はAPI Gateway上でもっとも簡単なWebSocket APIを手動で構築する手順を詳しく解説していきました。

次回以降では、今回行った手動によるAPI構築法を、Serverless Frameworkで置き換えるための内容を解説していこうかと思います。

参考サイト

Amazon API GatewayでWebsocketが利用可能

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