Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

API Gateway + WebSocketでさくっとお絵かきチャットを作る

この記事はモバイルファクトリー Advent Calendar 2020 17日目の記事です。


こんにちは、エンジニアのshioiyanです。

モバイルファクトリーには部活動制度があり、いくつもの部活動が存在しているのですが、自分はそのうちのゲームジャム部に所属しています。

今年2月から弊社はリモートワークになりましたが、ゲームジャム部はビデオ通話を使って活動を継続しています。

近頃、外出自粛している人が増えた中でも、ビデオ通話で話しながら楽しく遊べるサービスを作ろう!ということで部活を通じて、Web上でリアルタイムにそれぞれの画面が同期するお絵かきチャットの開発をしました。

仕様

今回作るリアルタイムお絵かきチャットの仕様はざっくり以下のようになります。

  1. ユーザは部屋を選んで入室ができる
  2. 部屋にはマウスやタップ操作で絵を描くことのできるキャンバスがある
  3. 絵を描くと同じ部屋のメンバーのキャンバスがリアルタイムで更新される
  4. 部屋を退室することができる

技術選定の背景

各クライアントのお絵かき画面をリアルタイムで同期するためにはWebSocket APIを用いることが思い浮かびました。

しかし、WebSocketサーバの構築・管理にはコストがかかるため、「動いて触れるものを素早く作りたい」という方針だったゲームジャム部で作るには少しネックでした。

調べていく中でAPI Gateway の WebSocket APIを用いればサーバレスでさくっと構築できてサーバの管理要らずで良さそう、ということがわかってきたので、公式で紹介されていたチャットの実装を参考にものは試しと作ってみました。

すると思った以上にさくっと要件を叶える実装をすることができたので、その知見を共有しようと思います。

利用した技術スタック

  • リアルタイム通信: Amazon API Gateway WebSocket API
  • バックエンド: AWS Lambda
  • 接続/部屋情報の保持: Amazon DynamoDB
  • フロントエンド: Nuxt.js (v2.14.11)

構成

まずはじめに今回実装したものの全体の構成を示しておきます。

クライアントはAPI GatewayとWebSocketで通信し、通信内容に応じて3種類のLambda関数を実行します。

DynamoDBはLambda関数を通じて接続しているクライアント情報の参照や更新を行います。

実装されたもの(canvasの同期のみ)

(左のブラウザのcanvasに描いた絵が右のブラウザにも同期されています)

実装

Serverless Framework

API Gateway WebSocket API + Lambda + DynamoDBの構成はServerless Frameworkで作成します。

コマンド1つで各種リソースの生成・更新・削除ができるのはとても便利です。

デプロイ
$ sls deploy -v --stage dev
...
Service Information
service: advent-calendar-2020
stage: dev
region: ap-northeast-1
stack: advent-calendar-2020-dev
resources: 25
api keys:
  None
endpoints:
  wss://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev
functions:
  connectHandler: advent-calendar-2020-dev-connectHandler
  disconnectHandler: advent-calendar-2020-dev-disconnectHandler
  sendMessageHandler: advent-calendar-2020-dev-sendMessageHandler
layers:
  None

Stack Outputs
ConnectHandlerLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxx:function:advent-calendar-2020-dev-connectHandler:10
DisconnectHandlerLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxx:function:advent-calendar-2020-dev-disconnectHandler:10
ServiceEndpointWebsocket: wss://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev
ServerlessDeploymentBucketName: advent-calendar-2020-dev-serverlessdeploymentbuck-xxxxx
SendMessageHandlerLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:xxxxx:function:advent-calendar-2020-dev-sendMessageHandler:10

✨  Done in 18.34s.

リソース一括削除
$ sls remove --stage dev

DynamoDB

WebSocketのconnectionIdとその接続しているクライアントが今入室している部屋情報を保持するために、connectionIdとroomIdのカラムを作成しています。

また、同じ部屋に入っているメンバーのレコードを取得するためにroomIdを使用してクエリを投げたいのですが、パーテションキー(connectionId)の指定をせずにクエリを投げることはできません。(パーテションキーを指定せずにクエリを投げるとValidationException: Query condition missed key schema elementエラーになる)

そこでroomIdにグローバルセカンダリインデックスを貼って検索できるようにしています。

SSESpecification.SSEEnabledDynamoDBに保存されたデータの暗号化を有効にするかの設定です。

有効にすると料金がかかるので、開発時には無効にしておくと良いでしょう。

API Gateway WebSocket API

API GatewayのWebSocket APIではserverless.ymlwebsocketsApiRouteSelectionExpressionで指定された値(routeKey)がクライアントから渡されると、それに応じたルートと呼ばれるリソースタイプで処理が実行されます。

今回の実装だと、$request.body.messageの値によってルートが決定され、$request.body.messagesendMessageだとsendMessageHandlerの関数が実行されることになります。

ただし、API Gatewayで最初からルートに使用できる3つの特別なrouteKey値が存在します。

  • $connect: クライアントがWebSocket APIに最初に接続するときに使用される
    • 接続開始したときに実行したい処理にルーティングできる
    • 今回の実装だとconnectHandlerが実行される
  • $disconnect: クライアントがWebSocket APIから切断するときに使用される
    • 接続を切断したときに実行したい処理にルーティングできる
    • 今回の実装だとdisconnectHandlerが実行される
  • $default: websocketsApiRouteSelectionExpressionの値が他のrouteKeyに一致しない場合に使用される
    • 今回は使用しない(sendMessage以外の$request.body.messageは考慮しない)

Lambda関数

今回実装している3つの関数の大枠はこちらのドキュメントを参考にしています。

接続時にQuery ParameterでroomIdを渡してconnectionIdと共にDynamoDBに保持します。

これによってWebSocketの接続状態をDynamoDBに保持しつつ、接続しているクライアントがどの部屋に入っているかも参照できるようになります。

グローバルセカンダリインデックスを貼ったroomIdで同じ部屋のクライアントのレコードを取得して、それらのconnectionIdに対してpostToConnectionでdataを送信します。

クライアントから送信されたdataを同じ部屋のクライアント全員に送信しています。

切断時にはDynamoDBから切断したクライアントのレコードを削除します。

クライアント

WebSocketの接続にはJavaScriptのWebSocketのwrapperライブラリのSocketteを使用しています。

Socketteを使用することで再接続処理や、WebSocketの各種EventListenerで実行される関数が簡単に指定できます。

vueコンポーネントの実装は、実際に絵を描く部分は割愛しますが以下のようになります。

ページ表示後にWebSocketの接続を行い、接続した状態でcanvas上でタップ&ドラッグ操作をするとその座標をWebSocketを介して同じ部屋のクライアントに操作した内容を送信してcanvasの同期を行います。

送信するobjにactionTypeという値を持たせていますが、これは受信したメッセージの内容を識別するためのものです。

この値を変えることで、別のデータのやりとりとそれに応じた処理の分岐も簡単に行えます。

この記事では実装していませんが、例えばキャンバスクリアや描いた絵を1つ戻す(undo)/進める(redo)といったイベントの同期をできるようにすると、よりお絵かきチャットっぽくなるでしょう。

まとめ

API Gateway + WebSocket APIでお絵かきチャットを作ることができました。

今回はcanvasの同期に利用しましたが、様々なリアルタイム通信が必要な場面で便利に使っていけそうな機能だと感じました。

明日の記事は id:pikkaman さんです!