🌵

API Gateway で Websocket 開発(実装編)

2024/03/03に公開

概要

こちらの記事 で、API Gatgeway が提供する Websocket API の特徴を学びました。

本記事では、サンプルアプリケーション構築を通じて、Websocket API 構築を体験します。

先人の方々が、素晴らしい SAM テンプレートが用意されていたので、これをなぞる形で構築を体験します。

流れ

1. 初期設定

sam init コマンドにて、hello-world テンプレートを使用してプロジェクトを新規作成します。

sam init

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 初期化

以上で初期設定は完了です。

以降では、作成した API Gateway を開発していきます。

2. Websocket 接続確立 (@connect ルート開発)

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 validatetemplate.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 ファイルを作成し、以下のコードを記述します。

app.js
exports.handler = async event => {
  return { statusCode: 200, body: 'Connected.' };
};

3. Websocket 接続破棄 (@disconnect ルート開発)

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 validatetemplate.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 ファイルを作成し、以下のコードを記述します。

app.js
exports.handler = async event => {
  return { statusCode: 200, body: 'Disconnected.' };
};

4. メッセージのやりとり (カスタムルート開発)

サーバー側からクライアントへメッセージを送信するには、 connectionId が必要です。

まずは connectionId を取得する方法を確認します。

その後、カスタムルートを作成し、 connecitonId を使用した、クライアントへメッセージを送信する処理を実装します。

4.1. connectionId の取得

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 セクションに、以下を追記します。

template.yaml
+  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 属性も追記します。

template.yaml
     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 ファイルを作成し、以下のコードを記述します。

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を用意してくれています。

より詳しい情報は、以下公式ドキュメントをご確認ください。

sam validatetemplate.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
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.' };
};
onconnect/app.js
exports.handler = async event => {
  return { statusCode: 200, body: 'Connected.' };
};
ondisconnect/app.js
exports.handler = async event => {
  return { statusCode: 200, body: 'Disconnected.' };
};
template.yaml
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. 参考文献

Discussion