API Gateway で Websocket 開発(実装編)
概要
こちらの記事 で、API Gatgeway が提供する Websocket API の特徴を学びました。
本記事では、サンプルアプリケーション構築を通じて、Websocket API 構築を体験します。
先人の方々が、素晴らしい SAM テンプレートが用意されていたので、これをなぞる形で構築を体験します。
流れ
- 1. 初期設定
-
2. Websocket 接続確立 (
@connect
ルート開発) -
3. Websocket 接続破棄 (
@disconnect
ルート開発) - 4. メッセージのやりとり (カスタムルート開発)
- 5. 最終的な成果物
- 6. 改善点
- 7. 参考文献
1. 初期設定
sam init
コマンドにて、hello-world
テンプレートを使用してプロジェクトを新規作成します。
template.yaml
を開き、Outputs
セクションを削除します。
-Outputs:
- # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
- # Find out more about other implicit resources you can reference within SAM
- # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
- HelloWorldApi:
- Description: "API Gateway endpoint URL for Prod stage for Hello World function"
- Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
- HelloWorldFunction:
- Description: "Hello World Lambda Function ARN"
- Value: !GetAtt HelloWorldFunction.Arn
- HelloWorldFunctionIamRole:
- Description: "Implicit IAM Role created for Hello World function"
- Value: !GetAtt HelloWorldFunctionRole.Arn
同じく template.yaml
を開き、Resources
セクションを編集します。
Resources:
SimpleChatWebSocket:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: SimpleChatWebSocket
ProtocolType: WEBSOCKET
RouteSelectionExpression: "$request.body.action"
Resources:
- HelloWorldFunction:
- Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
+ SimpleChatWebSocket:
+ Type: AWS::ApiGatewayV2::Api
Properties:
- CodeUri: hello-world/
- Handler: app.lambdaHandler
- Runtime: nodejs18.x
- Architectures:
- - x86_64
- Events:
- HelloWorld:
- Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
- Properties:
- Path: /hello
- Method: get
- Metadata: # Manage esbuild properties
- BuildMethod: esbuild
- BuildProperties:
- Minify: true
- Target: "es2020"
- Sourcemap: true
- EntryPoints:
- - app.js
+ Name: SimpleChatWebSocket
+ ProtocolType: WEBSOCKET
+ RouteSelectionExpression: "$request.body.action"
編集後、 sam validate
コマンドを実行し、 template.yaml
に問題が無いことを確認します。
$ sam validate
/home/tomitake/aws/apigw-ws/template.yaml is a valid SAM Template
sam build
, sam deploy
コマンドを順に実行し、アプリケーションをデプロイします。
API Gateway のマネジメントコンソールを開き、 API Gateway Websocket API が作成されていることを確認します。
以上で初期設定は完了です。
以降では、作成した API Gateway を開発していきます。
@connect
ルート開発)
2. Websocket 接続確立 (Websocket 接続確立時に実行する、 onconnect
関数を作成します。
2.1. Cloudformation の編集
template.yaml
を編集し、Resources
セクションに、以下のコードを追記します。
+ ConnectRoute:
+ Type: AWS::ApiGatewayV2::Route
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ RouteKey: $connect
+ AuthorizationType: NONE
+ OperationName: ConnectRoute
+ Target: !Join
+ - "/"
+ - - "integrations"
+ - !Ref ConnectInteg
+ ConnectInteg:
+ Type: AWS::ApiGatewayV2::Integration
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ Description: Connect Integration
+ IntegrationType: AWS_PROXY
+ IntegrationUri:
+ Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations
+ Deployment:
+ Type: AWS::ApiGatewayV2::Deployment
+ DependsOn:
+ - ConnectRoute
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ Stage:
+ Type: AWS::ApiGatewayV2::Stage
+ Properties:
+ StageName: Prod
+ Description: Prod Stage
+ DeploymentId: !Ref Deployment
+ ApiId: !Ref SimpleChatWebSocket
+ OnConnectFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ CodeUri: onconnect/
+ Handler: app.handler
+ MemorySize: 256
+ Runtime: nodejs18.x
+ OnConnectPermission:
+ Type: AWS::Lambda::Permission
+ DependsOn:
+ - SimpleChatWebSocket
+ Properties:
+ Action: lambda:InvokeFunction
+ FunctionName: !Ref OnConnectFunction
+ Principal: apigateway.amazonaws.com
上記追記したコードでは、以下を定義しています。
セクション名 | 概説 |
---|---|
ConnectRoute |
Websocket API の @connect ルートを定義(今回は Lambda統合を選択) |
ConnectInteg |
@connect ルートと統合する(≒紐づける) Lambda 関数を指定。後述する OnConnectFunction を指定している |
Deployment |
API Gateway をデプロイ |
Stage |
API Gateway に、 Prod という名前のステージを追加 |
OnConnectFunction |
OnConnectFunction という名前の Lambda関数を定義 |
OnConnectPermission |
OnConnectFunction Lambda関数に、API Gateway から実行されることを許可(言い換えると、API Gatewayに、 OnConnectFunction Lambda関数を実行する権限を付与) |
sam validate
で template.yaml
に問題が無いことを確認した後、 sam build && sam deploy
を実行し、アプリケーションをデプロイします。
wscat
コマンドなどで動作を確認します。
問題が無ければ、以下のように "Connected." というメッセージが表示されます。
$ wscat -c wss://${api-id}.execute-api.${region}.amazonaws.com/Prod/
Connected (press CTRL+C to quit)
2.2. コード
template yaml
と同じ階層に、 onconnect
という名前のフォルダを作成します。
$ mkdir onconnect
$ ls
README.md events hello-world onconnect samconfig.toml template.yaml
onconnect
フォルダの中に、 app.js
ファイルを作成し、以下のコードを記述します。
exports.handler = async event => {
return { statusCode: 200, body: 'Connected.' };
};
@disconnect
ルート開発)
3. Websocket 接続破棄 (Websocket 接続破棄時、 "Disconnected." というメッセージを返す、 ondisconnect
関数を作成します。
3.1. Cloudformation の編集
template yaml
を編集し、Resources
セクションに、以下のコードを追記します。
+ DisconnectRoute:
+ Type: AWS::ApiGatewayV2::Route
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ RouteKey: $disconnect
+ AuthorizationType: NONE
+ OperationName: DisconnectRoute
+ Target: !Join
+ - "/"
+ - - "integrations"
+ - !Ref DisconnectInteg
+ DisconnectInteg:
+ Type: AWS::ApiGatewayV2::Integration
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ Description: Disconnect Integration
+ IntegrationType: AWS_PROXY
+ IntegrationUri:
+ Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations
+ OnDisconnectFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ CodeUri: ondisconnect/
+ Handler: app.handler
+ MemorySize: 256
+ Runtime: nodejs18.x
+ OnDisconnectPermission:
+ Type: AWS::Lambda::Permission
+ DependsOn:
+ - SimpleChatWebSocket
+ Properties:
+ Action: lambda:InvokeFunction
+ FunctionName: !Ref OnDisconnectFunction
やっていることは、 onconnect
の時と同じです。
また、 Deployment
セクションに、以下を追記します。
Type: AWS::ApiGatewayV2::Deployment
DependsOn:
- ConnectRoute
+ - DisconnectRoute
Properties:
ApiId: !Ref SimpleChatWebSocket
Stage:
sam validate
で template.yaml
に問題が無いことを確認した後、 sam build && sam deploy
を実行し、アプリケーションをデプロイします。
こちらは、簡単に動作確認する方法がわかりませんでした。
3.2. コード
template.yaml
と同じ階層に、 ondisconnect
という名前のフォルダを作成します。
$ mkdir onconnect
$ ls
README.md events hello-world onconnect ondisconnect samconfig.toml template.yaml
ondisconnect
フォルダの中に、 app.js
ファイルを作成し、以下のコードを記述します。
exports.handler = async event => {
return { statusCode: 200, body: 'Disconnected.' };
};
4. メッセージのやりとり (カスタムルート開発)
サーバー側からクライアントへメッセージを送信するには、 connectionId
が必要です。
まずは connectionId
を取得する方法を確認します。
その後、カスタムルートを作成し、 connecitonId
を使用した、クライアントへメッセージを送信する処理を実装します。
connectionId
の取得
4.1. Websocket 接続確立後、未定義のルート宛てにメッセージを送信すると、エラーメッセージと共に connectionId
を取得できます。
$ wscat -c wss://{api-id}.execute-api.ap-northeast-1.amazonaws.com/Prod/
Connected (press CTRL+C to quit)
> {} // 未定義のルート
< {"message": "Forbidden", "connectionId":"xxxxx", "requestId":"yyyyy"}
>
クライアントからサーバーへメッセージを送信する際、上記手順で取得した connectionId
を一緒に送信します。
{
"action":"カスタムルートに対応したaction名",
"connetionId":"取得したconnectionId",
"message": "送信したいメッセージ"
}
これにより、サーバー側では connectionId
を参照して、特定のクライアントへメッセージを送信できます。
4.2. カスタムルートを作成し、クライアントへメッセージを送信する
4.2.1. 実装イメージ
hello
というカスタムルートを作成します。
hello
ルートでは、以下のメッセージを受け取ることを想定します。
{
"action":"カスタムルートに対応したaction名",
"connetionId":"取得したconnectionId",
"userName": "任意のユーザ名"
}
そして、以下のメッセージをクライアントに返します。
{
"message":"hello, ${ユーザ名}!"
}
4.2.2. Cloudformation の編集
template.yaml
を開き、 Resources
セクションに、以下を追記します。
+ HelloRoute:
+ Type: AWS::ApiGatewayV2::Route
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ RouteKey: hello
+ AuthorizationType: NONE
+ OperationName: HelloRoute
+ Target: !Join
+ - "/"
+ - - "integrations"
+ - !Ref HelloInteg
+ HelloInteg:
+ Type: AWS::ApiGatewayV2::Integration
+ Properties:
+ ApiId: !Ref SimpleChatWebSocket
+ Description: Hello Integration
+ IntegrationType: AWS_PROXY
+ IntegrationUri:
+ Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloFunction.Arn}/invocations
+ HelloFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ CodeUri: hello/
+ Handler: app.handler
+ MemorySize: 256
+ Runtime: nodejs18.x
+ Policies:
+ - Statement:
+ - Effect: Allow
+ Action:
+ - "execute-api:ManageConnections"
+ Resource:
+ - !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SimpleChatWebSocket}/*"
+ HelloPermission:
+ Type: AWS::Lambda::Permission
+ DependsOn:
+ - SimpleChatWebSocket
+ Properties:
+ Action: lambda:InvokeFunction
+ FunctionName: !Ref HelloFunction
+ Principal: apigateway.amazonaws.com
カスタムルート作成において、カスタムルートに対応する action
プロパティの値は、 AWS::ApiGatewayV2::Route
リソースの RouteKey
に設定します。
HelloRoute
では、 hello
と設定しています。
...
RouteKey: hello
...
これにより、以下のようなメッセージがクライアントから送信されると、HelloRoute
に紐づけた HelloLambdaFunction
を実行できます。
{"action":"hello",...}
また、 Deployment
リソースの DependsOn
属性も追記します。
DependsOn:
- ConnectRoute
- DisconnectRoute
+ - HelloRoute
4.2.3. コード
HelloLambdaFunction
リソースの本体を作成します。
template.yaml
と同じ階層に、 hello
という名前のフォルダを作成します。
$ mkdir hello
$ ls
README.md events hello hello-world onconnect ondisconnect samconfig.toml template.yaml
hello
フォルダの中に app.js
ファイルを作成し、以下のコードを記述します。
const {
ApiGatewayManagementApiClient,
PostToConnectionCommand
} = require("@aws-sdk/client-apigatewaymanagementapi");
exports.handler = async event => {
const {connectionId,userName} = event;
const config = {
region: 'ap-northeast-1',
endpoint:"https://{api-id}.execute-api.ap-northeast-1.amazonaws.com/Prod"
};
const client = new ApiGatewayManagementApiClient(config);
const input = { // PostToConnectionRequest
Data: `hello,${userName}!`,
ConnectionId: connectionId,
};
const command = new PostToConnectionCommand(input);
const response = await client.send(command);
return { statusCode: 200, body: 'sent.' };
};
@connections
コマンドによる POST リクエストは、自分で組み立てる方法に加えて、 AWS SDK がAPIを用意してくれています。
より詳しい情報は、以下公式ドキュメントをご確認ください。
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-connections.html
- https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/apigatewaymanagementapi/command/PostToConnectionCommand/
sam validate
で template.yaml
に問題が無いことを確認した後、 sam build && sam deploy
を実行し、アプリケーションをデプロイします。
wscat
で動作確認します。
$ wscat -c wss://{api-id}.execute-api.{region}.amazonaws.com/Prod/
Connected (press CTRL+C to quit)
> {}
< {"message": "Forbidden", "connectionId":"abcdefg", "requestId":"UAcRxGPjNjMF2xg="} // connectionId取得
> {"action":"hello","connectionId":"abcdefg","userName":"taro"} //取得したconnectionIdをセット
< hello,taro!
あとは、 HelloRoute
と同じ要領でカスタムルートを量産できます。
5. 最終的な成果物
.
├── README.md
├── events
│ └── event.json
├── hello
│ └── app.js
├── onconnect
│ └── app.js
├── ondisconnect
│ └── app.js
├── samconfig.toml
└── template.yaml
const {
ApiGatewayManagementApiClient,
PostToConnectionCommand
} = require("@aws-sdk/client-apigatewaymanagementapi");
exports.handler = async event => {
const {connectionId,userName} = event;
const config = {
region: 'ap-northeast-1',
endpoint:"https://{api-id}.execute-api.ap-northeast-1.amazonaws.com/Prod"
}
const client = new ApiGatewayManagementApiClient(config);
const input = { // PostToConnectionRequest
Data: `hello,${userName}!`,
ConnectionId: connectionId,
};
const command = new PostToConnectionCommand(input);
const response = await client.send(command);
return { statusCode: 200, body: 'sent.' };
};
exports.handler = async event => {
return { statusCode: 200, body: 'Connected.' };
};
exports.handler = async event => {
return { statusCode: 200, body: 'Disconnected.' };
};
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
apigw-ws
Sample SAM Template for apigw-ws
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 3
Resources:
SimpleChatWebSocket:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: SimpleChatWebSocket
ProtocolType: WEBSOCKET
RouteSelectionExpression: "$request.body.action"
ConnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref SimpleChatWebSocket
RouteKey: $connect
AuthorizationType: NONE
OperationName: ConnectRoute
Target: !Join
- "/"
- - "integrations"
- !Ref ConnectInteg
ConnectInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref SimpleChatWebSocket
Description: Connect Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations
DisconnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref SimpleChatWebSocket
RouteKey: $disconnect
AuthorizationType: NONE
OperationName: DisconnectRoute
Target: !Join
- "/"
- - "integrations"
- !Ref DisconnectInteg
DisconnectInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref SimpleChatWebSocket
Description: Disconnect Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations
Deployment:
Type: AWS::ApiGatewayV2::Deployment
DependsOn:
- ConnectRoute
- DisconnectRoute
- HelloRoute
Properties:
ApiId: !Ref SimpleChatWebSocket
Stage:
Type: AWS::ApiGatewayV2::Stage
Properties:
StageName: Prod
Description: Prod Stage
DeploymentId: !Ref Deployment
ApiId: !Ref SimpleChatWebSocket
OnConnectFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: onconnect/
Handler: app.handler
MemorySize: 256
Runtime: nodejs18.x
OnConnectPermission:
Type: AWS::Lambda::Permission
DependsOn:
- SimpleChatWebSocket
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref OnConnectFunction
Principal: apigateway.amazonaws.com
OnDisconnectFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ondisconnect/
Handler: app.handler
MemorySize: 256
Runtime: nodejs18.x
OnDisconnectPermission:
Type: AWS::Lambda::Permission
DependsOn:
- SimpleChatWebSocket
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref OnDisconnectFunction
Principal: apigateway.amazonaws.com
HelloRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref SimpleChatWebSocket
RouteKey: hello
AuthorizationType: NONE
OperationName: HelloRoute
Target: !Join
- "/"
- - "integrations"
- !Ref HelloInteg
HelloInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref SimpleChatWebSocket
Description: Hello Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloFunction.Arn}/invocations
HelloFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: hello/
Handler: app.handler
MemorySize: 256
Runtime: nodejs18.x
Policies:
- Statement:
- Effect: Allow
Action:
- "execute-api:ManageConnections"
Resource:
- !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SimpleChatWebSocket}/*"
HelloPermission:
Type: AWS::Lambda::Permission
DependsOn:
- SimpleChatWebSocket
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref HelloFunction
Principal: apigateway.amazonaws.com
6. 改善点
connectionId
管理周りの実装は、大きな改善点かと思います。
今回は、クライアント側から connectionId
を取得しました。
公式ブログを見ると、サーバー側で connectionId
を取得し、DB管理する方法を採用しています。
ただし、 「connectionId
をDBから取り出して @connection
コマンドを実行する」という処理が、 DB上に存在する有効な connectionId
全部に送信するという、ブロードキャストのような実装となっています。
公式ブログのようにチャットアプリケーションを想定するなら、ブロードキャストで問題ありません。
しかし、「特定のユーザーにだけメッセージを送信したい」となると、改善が必要です。
「特定のユーザーにだけメッセージを送信」することを想定した方法について色々試行錯誤しましたが、最後まで良い方法は思いつきませんでした。
7. 参考文献
- https://github.com/aws-samples/simple-websockets-chat-app
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api.html
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-connections.html
- https://aws.amazon.com/jp/blogs/news/announcing-websocket-apis-in-amazon-api-gateway/
- https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/apigatewaymanagementapi/command/PostToConnectionCommand/
- https://repost.aws/ja/knowledge-center/410-gone-api-gateway
Discussion