FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

Amazon API GatewayのWebSocket API + Lambdaでプランニングポーカーを作る

TL;DR

  • プランニングポーカーを自作した
  • 技術選定
    • Amazon API Gateway WebSocket API
    • Lambda

はじめに

こんにちは、ピクトリンク事業部開発部開発2課、兼お嬢様部のkitajimaです。
私たちのチームは、プロダクトバックログアイテムを見積もる際にプランニングポーカーを活用しています。
メンバーが出すカードによってポイントを決める方法は、平均値、最頻値など様々ですが、私たちのチームでは次のルールでストーリーポイントを決定しています。

  • 出た数値が1種類のみの場合→その数値を採用
  • 出た数値が2種類で、カードが連続する数値の場合→大きい方の数値を採用
  • 出た数値が3種類で、カードが連続する数値の場合→中央の数値を採用
  • それ以外の場合→議論の後、再見積もり

プランニングポーカーのポイント決定方法

このルールに適応する既存のWebアプリケーションがなかったので、自分で作ってみることにしました。

仕様

以下のような仕様とします。

  • 同じルームにいるユーザ同士で投票が可能
    • 手札は0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100, skipを用意
  • ユーザの入退室がリアルタイムで分かる
  • ルーム内のユーザが投票を完了したかリアルタイムで確認できる
  • 全ユーザの投票完了時に、複数の方法で算出された結果が表示される
    • 平均値
    • 最頻値
    • 上述の特定のルールに基づく値
  • ルーム内の投票のリセットができる

構成

クライアントとサーバ間での双方向通信を可能にするWebSocketを使えば、リアルタイムでユーザの入退室や投票状況を知ることができそうです。

sequenceDiagram
    participant ユーザA
    participant ユーザB
    participant サーバ
    ユーザA->>サーバ:投票を送信
    サーバ->>ユーザB:ルームの投票情報を更新

サーバサイド

API GatewayのWebSocket API + Lambdaという構成を採用しました。永続化にはDynamoDBを使用します。

インフラ構成

WebSocket APIの特徴

WebSocket APIを使用すると、クライアントから受信したメッセージの内容に応じて異なる処理を行うことができます。事前に定義されている以下の3つのルートには、それぞれ異なるバックエンド処理を割り当てることができます。

  • $connect(接続が開始されたとき)
  • $disconnect(切断したとき)
  • $default(一致するrouteが無いとき)

加えて、特定のメッセージを受けたときに呼び出したいバックエンド処理を設定できるcustom routeも定義できます。

今回だと、以下のようなメッセージに応じて処理を分けたいです。

  • joinRoom(ユーザが特定の部屋に入室する)
  • submitCard(ユーザが投票カードを提出する)
  • resetRoom(ユーザが部屋をリセットする)

3つの事前定義されたroute、3つのcustom route、計6つのrouteと統合するためのLambda関数を用意することにしました。

構築

AWS CDKで構築しました。 まず、プロジェクト初期化します。今回はTypeScriptで定義していきます。

npm install -g aws-cdk # 未インストールの場合
cdk init --language typescript

次に、lib/ディレクトリ内でstackを定義していきます。

stack定義

コードはこちらです。

// planning-poker-server-stack.ts
export class PlanningPokerServerStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);

        const functions = this.createLambdaFunctions([
            'onConnect',
            'joinRoom',
            'resetRoom',
            'submitCard',
            'onDisconnect',
            'default',
        ]);

        const api = this.createWebSocketApi(functions);

        new WebSocketStage(this, 'planningPokerServerStage', {
            stageName: 'v1',
            webSocketApi: api,
            autoDeploy: true,
        });

        const table = this.createDynamoDBTable();

        this.grantTablePermissions(table, functions);
    }

    private createLambdaFunction = (name: string): NodejsFunction => new NodejsFunction(this, name, {
        entry: path.join(__dirname, `../src/functions/${name}/index.ts`),
        functionName: name,
    });

    private createLambdaFunctions(names: string[]): Record<string, NodejsFunction> {
        return names.reduce((acc, name) => {
            acc[name] = this.createLambdaFunction(name);
            return acc;
        }, {} as Record<string, NodejsFunction>);
    }

    private createWebSocketApi(functions: Record<string, NodejsFunction>): WebSocketApi {
        const api = new WebSocketApi(this, 'api', {
            apiName: 'planningPokerServer',
        });

        const routes = [
            {route: '$connect', func: 'onConnect'},
            {route: '$disconnect', func: 'onDisconnect'},
            {route: 'joinRoom', func: 'joinRoom'},
            {route: 'resetRoom', func: 'resetRoom'},
            {route: 'submitCard', func: 'submitCard'},
            {route: '$default', func: 'default'},
        ];

        routes.forEach(({route, func}) => {
            api.addRoute(route, {
                integration: new WebSocketLambdaIntegration(`${func}Integration`, functions[func])
            });
            api.grantManageConnections(functions[func]);
        });

        return api;
    }

    private createDynamoDBTable = (): Table => {
        const table = new Table(this, 'planningPokerTable', {
            tableName: 'PlanningPoker',
            billingMode: BillingMode.PAY_PER_REQUEST,
            partitionKey: {name: 'roomId', type: AttributeType.STRING},
            sortKey: {name: 'clientId', type: AttributeType.STRING},
        });

        table.addGlobalSecondaryIndex({
            indexName: 'ClientIdIndex',
            partitionKey: {name: 'clientId', type: AttributeType.STRING},
        });

        return table;
    };

    private grantTablePermissions(table: Table, functions: Record<string, NodejsFunction>): void {
        Object.values(functions).forEach((func) => table.grantFullAccess(func));
    }
}

API Gateway WebSocket APIを構築し、6つのLambdaをrouteたちに統合しています。 そして永続化のためのDynamoDBテーブルも作成しています。

加えて、CDKから参照するLambda関数のファイルもsrc/以下に用意しておきます。 最終的にプロジェクト構成は以下のようになりました。

.
├── README.md
├── bin
│   └── cdk.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── planning-poker-server-stack.ts
├── package-lock.json
├── package.json
├── src
│   ├── functions
│   │   ├── default
│   │   │   └── index.ts
│   │   ├── joinRoom
│   │   │   └── index.ts
│   │   ├── onConnect
│   │   │   └── index.ts
│   │   ├── onDisconnect
│   │   │   └── index.ts
│   │   ├── resetRoom
│   │   │   └── index.ts
│   │   └── submitCard
│   │       └── index.ts
│   ├── repository
│   │   └── PlanningPokerRepository.ts
│   └── service
│       └── NotificationService.ts
...

以下のコマンドでデプロイすると、上記の構成図のようにリソースが作成されているはずです。

npm run cdk deploy -- --profile $YOUR_PROFILE_NAME

実装の詳細

Lambdaの中身を実装していきます。こちらもTypeScriptで記述しています。 抜粋して、submitCardのコードのみ示します。

// submitCard/index.ts
export const handler: APIGatewayProxyWebsocketHandlerV2 = async (event) => {
    try {
        const body = JSON.parse(event.body ?? '{}');
        await planningPokerRepository.updateCardNumberInRoomAndUser(body.roomId, event.requestContext.connectionId, body.cardNumber);
        const {domainName, stage} = event.requestContext;
        await new NotificationService(`${domainName}/${stage}`).notifyCurrentUsers(body.roomId);
    } catch (e) {
        console.error(e);
        return {
            statusCode: 400,
            body: 'Cannot submit card.'
        }
    }
    return {
        statusCode: 200,
        body: 'succeeded to submit card.',
    };
}

submitCardでは以下の処理を行っています。

  1. イベントからカード情報とルームIDを取得
  2. planningPokerRepositoryを用いて、ルームとユーザのカード番号を更新
  3. NotificationServiceを使用して、ルーム内の全ユーザに更新を通知

データの永続化(planningPokerRepository)と、ルーム内クライアントへの通知(NotificationService)は共通の処理であるため、これらは別の関数に切り分けて再利用しています。

また、WebSocket APIに統合されたLambda関数は、イベントオブジェクトからリクエスト情報を取得できます。
API Gateway での WebSocket API 統合リクエストの設定 - Amazon API Gateway

event.requestContext.connectionIdでクライアントを識別できるため、永続化の際もこの値を利用することにしました。

フロントエンド

以下のような画面を用意しました。

プランニングポーカーのクライアント

ルーム入室、カード選択(投票)、ルームリセットといった操作に応じてWebSocketのメッセージを送信するように実装しました。

// Vueファイルのscriptのうち、WebSocketに関する一部のみ抜粋
methods: {
    joinRoom() {
      this.socket.send(JSON.stringify({
        action: 'joinRoom',
        roomId: this.roomId,
        userName: this.userName,
      }));
    },
    submitCard() {
      this.socket.send(JSON.stringify({
        action: 'submitCard',
        roomId: this.roomId,
        cardNumber: this.selectedCardNumber
      }));
    },
    resetRoom() {
      this.socket.send(JSON.stringify({
        action: 'resetRoom',
        roomId: this.roomId,
      }));
    }
},
created() {
    this.socket = new WebSocket('__websocket_url__');

    // ルーム内の情報をサーバから受け取ったらページに反映するため
    this.socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.shouldReset) {
        this.selectedCardNumber = null;
      }
      this.participants = data.users.map(value => ({name: value.name, vote: value.cardNumber}));
    };
}

使ってみて

普段の見積もりで使ってみましたが、特に不都合無く使うことができました! レイアウトのセンスが無いのはさておき・・・。

自分でも気になったのは、各操作の初回だけ遅延があることです。 以下の図のように、最初に投票を送信して、部屋の情報を受信するまでの時間は2.3秒ほどでした。それ以降の操作では0.1秒ほどしかかかっていません。

緑が投票を送信した時刻, 赤が部屋の情報を受信した時刻

これはバックエンドにLambdaを使っており、しばらく起動していない後の最初の呼び出しはコールドスタートになるためです。
今回はプライベートなアプリケーションのため許容することにしましたが、Lambdaのprovisioned concurrencyを設定し、呼び出し前に実行環境を準備しておいてもらうことで解決できます。

最後に

普段のプロダクト開発ではREST APIを提供する用途でAmazon API Gateway + Lambdaという基本的なサーバレス構成にお世話になっていましたが、今回のようにWebSocketサーバもインフラのことを考えずに構築できることを実感できました。 何かの参考になれば幸いです!