【ServerlessFramework】API GatewayのWebSocket通信を利用してリアルタイムに変化するTodoアプリを作成した

# はじめに

ServerlessFrameworkを用いて、ApiGatewayのWebsocket通信を利用したリアルタイムアウトTodoアプリを実装した時のメモ。

# 環境

* Svelte
    * 3.54.0
* ServerlessFramework
    * 3.27.0

# 完成画面

![realTimeTodo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/a50b694f-0e0b-0b0f-aa85-52efb182f739.gif)

# 環境について

## 構成図

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/0b89a0ff-a55e-6745-9e53-627450fb463e.png" width="500px" />

## WebSocket説明

### 1. connect / disconnect時にはDynamoDBに用意したテーブルにユーザーの追加/削除を行う

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/a8cc299e-d3c3-b1f3-a6c0-8db99a562417.png" width="500px" />

### 2. scanData時にはDyamoDBからデータを取得する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/79a17a56-4e50-f443-76bd-a553af2ba6af.png" width="500px" />

### 3. addData / deleteData時にはDynamoDBに用意したデータテーブルに追加/削除を行う

1. lambdaでDataの追加/削除を行う
1. 現在接続中のユーザー情報をDynamoDBから取得し、情報を送信する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/7d621c72-c19a-ac2e-5861-cef8f1adc2c0.png" width="500px" />

# serverless.yml + α (抜粋)

## S3 + CloudFront

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/e7e9178c-fd42-1150-96ba-44c634068a7a.png" width="300px" />

<details><summary>serverless.yaml</summary><div>

```yaml:serverless.yml
service: # 自由に
frameworkVersion: '3'

plugins:
  - serverless-s3-sync

provider:
  name: aws
  stage: dev
  region: # 自由に
  profile: # 自由に

custom:
  stage: ${opt:stage, self:provider.stage}
  s3Sync:
    - bucketName: # s3 syncを行うバケット名
      localDir: # sync ディレクトリ

resources:
  Resources:
    # S3
    AssetsBucket:
      Type: AWS::S3::Bucket
      DeletionPolicy: Delete
      Properties:
        BucketName: # 自由に(custom.s3sync.bucketNameに合わせる)
        PublicAccessBlockConfiguration:
          BlockPublicAcls: true
          BlockPublicPolicy: true
          IgnorePublicAcls: true
          RestrictPublicBuckets: true
        CorsConfiguration:
          CorsRules:
            - AllowedMethods:
                - GET
                - HEAD
              AllowedOrigins:
                - '*'
    # s3バケットポリシー
    # 後述するCloudFrontOriginAccessIdentityを許可する
    AssetsBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref AssetsBucket
        PolicyDocument:
          Statement:
            - Action: s3:GetObject
              Effect: Allow
              Resource: !Sub arn:aws:s3:::${AssetsBucket}/*
              Principal:
                AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}
    # CloudFront
    AssetsDistribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Origins:
            - Id: S3Origin
              DomainName: !GetAtt AssetsBucket.DomainName
              S3OriginConfig:
                OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
          Enabled: true
          DefaultRootObject: index.html
          Comment: !Sub ${AWS::StackName} distribution
          DefaultCacheBehavior:
            TargetOriginId: S3Origin
            ForwardedValues:
              QueryString: false
            ViewerProtocolPolicy: redirect-to-https
            AllowedMethods:
              - GET
              - HEAD
              - OPTIONS
              - PUT
              - POST
              - PATCH
              - DELETE
    CloudFrontOriginAccessIdentity:
      Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
      Properties:
        CloudFrontOriginAccessIdentityConfig:
          Comment: !Ref AWS::StackName
```

</div></details>

## Api Gateway + lambda + DynamoDB

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985550/9a5eb35d-6db4-4657-a2fc-c90e71e2226b.png" width="300px" />

<details><summary>serverless.yaml</summary><div>

```yaml:serverless.yml
service: # 自由に
frameworkVersion: '3'

provider:
  name: aws
  stage: dev
  runtime: # 好きなものを
  region: # 自由に
  profile: # 自由に
  websocketsApiRouteSelectionExpression: $request.body.action
  iam:
    role:
      statements:
        - Effect: "Allow"
          Action:
            - "dynamodb:*"
          Resource:
            - arn:aws:dynamodb:ap-northeast-1:*:table/○○ # ○○にはDynamoDBのテーブル名を

custom:
  stage: ${opt:stage, self:provider.stage}

functions:
  # 例としてconnectHandlerとscanDataHandler
  connectHandler:
    handler: handler.connectHandler
    timeout: 30
    events:
      - websocket: $connect
  scanDataHandler:
    handler: handler.scanDataHandler
    timeout: 30
    events:
      - websocket: scanData

resources:
  Resources:
    # DynamoDB
    Dynamodb:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        TableName: # 自由に
        AttributeDefinitions:
          - AttributeName: id   # パーティションキー
            AttributeType: S
          - AttributeName: type # ソートキー
            AttributeType: S
        KeySchema:
          - AttributeName: id   # パーティションキー
            KeyType: HASH
          - AttributeName: type # ソートキー
            KeyType: RANGE
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
```

</div></details>

<details><summary>handler.rb</summary><div>

```ruby:handler.rb
require 'json'
require 'aws-sdk'

def dynamodb_client
  Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
end

def connectHandler(event:, context:)
  begin
    table_item = {
      table_name: ○○, # 作成したテーブル名
      item: {
        id: event['requestContext']['connectionId'], # パーティションキー
        type: 'user'                                 # ハッシュキー
      }
    }

    dynamodb_client.put_item(table_item)

    { "statusCode": 200 }
  rescue StandardError => e
    puts e

    { "statusCode": 500 }
  end
end

def scanDataHandler(event:, context:)
  scanData = dynamodb_client.scan(
    table_name: ○○ # 作成したテーブル名
  )
  todos = scanData.items.select{ |data| data["type"] === 'todo' }

  api_gw = Aws::ApiGatewayManagementApi::Client.new(
    endpoint: 'https://' + event['requestContext']['domainName'] + '/' + event['requestContext']['stage']
  )

  api_gw(event).post_to_connection(
    connection_id: event['requestContext']['connectionId'],
    data: { todos: todos }.to_json
  )
end
```

</div></details>

# フロント側(Svelte)

```vue:+page.svelte
<script lang="ts">
  import { onMount } from 'svelte';

  const connection = new WebSocket(import.meta.env.VITE_WEBSOCKET_URL)

  const scanTodo = () => {
    const message = {
      action: 'scanTodo'
    }

    connection.send(JSON.stringify(message))
  }

  interface Todo {
    id: string;
    type: string;
    content: any;
  }

  let todos: Todo[];

  // メッセージを受け取ったときの処理
  connection.onmessage = event => {
    const json_todo: Todo[] = JSON.parse(event.data)
    todos = json_todo
  }

  onMount(() => {
    scanTodo()
  })
</script>
```

# 最後に

GraphQLとAppSyncを使って同じような構成も作ってみたいです〜