Amazon Chime SDK + Lambda + API GatewayでサーバーレスWeb会議アプリを作ってみた

2020.10.16

こんにちは、CX事業本部の若槻です。

前回の記事ではAmazon Chime SDKを使ってローカル上でWeb会議アプリを作成しました。

今回は、Amazon Chime SDK、LambdaおよびAPI Gatewayを使って、Web会議アプリをAWS上にサーバーレス構成で作成してみました。

アウトプット

次のような複数人でビデオ会議ができるサーバーレスアプリを作ります。 image

ソースコードはGitHubに公開してあります。

やってみた

環境

% sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
% node -v
v12.14.0
% npm -v
6.13.4
% sam --version
SAM CLI, version 1.6.2

アプリディレクトリ作成

% mkdir serverless-meeting-app
% cd serverless-meeting-app

npm install

% npm init -y
% npm install aws-sdk serverless-aws-static-file-handler uuid

serverless-aws-static-file-handlerはServerless Frameworkが提供する、WebアプリのフロントエンドをAWS Lambdaでホストできるようにするプラグインです。

通常であればサーバーレス構成のWebアプリの静的ファイルのホストはS3バケットなどのストレージを使用しますが、今回はフロントエンドを最低限の構成にしており静的ファイルの容量が小さいので、serverless-aws-static-file-handlerを活用して構成を単純化しています。

バックエンドの作成

handlers.js作成

WebアプリのバックエンドとなるLambdaハンドラーのコードです。

% touch handlers.js

handlers.js

const AWS = require("aws-sdk");
const StaticFileHandler = require('serverless-aws-static-file-handler')

const chime = new AWS.Chime();
const { v4: uuidv4 } = require("uuid");

chime.endpoint = new AWS.Endpoint("https://service.chime.aws.amazon.com");

const json = (statusCode, contentType, body) => {
    return {
        statusCode,
        headers: { "content-type": contentType },
        body: JSON.stringify(body),
    };
};

exports.index = async (event, context) => {
    const clientFilesPath = __dirname + "/html/";
    const fileHandler = new StaticFileHandler(clientFilesPath)
    return await fileHandler.get(event,context);
}

exports.join = async (event) => {
    const query = event.queryStringParameters;
    let meetingId = null;
    let meeting = null;
    if (!query.meetingId) {
        meetingId = uuidv4();
        meeting = await chime
            .createMeeting({
                ClientRequestToken: meetingId,
                MediaRegion: "eu-west-1",
                ExternalMeetingId: meetingId,
            })
            .promise();
    } else {
        meetingId = query.meetingId;
        meeting = await chime
            .getMeeting({
                MeetingId: meetingId,
            })
            .promise();
    }

    const attendee = await chime
        .createAttendee({
            MeetingId: meeting.Meeting.MeetingId,
            ExternalUserId: `${uuidv4().substring(0, 8)}#${query.clientId}`,
        })
        .promise();

    return json(200, "application/json", {
        Info: {
            Meeting: meeting,
            Attendee: attendee,
        },
    });
};

indexでは、serverless-aws-static-file-handlerにより静的ファイルをホストするLambdaハンドラーを定義しています。

exports.index = async (event, context) => {
    const clientFilesPath = __dirname + "/html/";
    const fileHandler = new StaticFileHandler(clientFilesPath)
    return await fileHandler.get(event,context);
}

joinでは、ユーザーが会議に参加する際に必要となる会議IDおよび参加者IDを払い出すLambdaハンドラーを定義しています。

exports.join = async (event) => {
    const query = event.queryStringParameters;
    let meetingId = null;
    let meeting = null;
    if (!query.meetingId) {
        meetingId = uuidv4();
        meeting = await chime
            .createMeeting({
                ClientRequestToken: meetingId,
                MediaRegion: "eu-west-1",
                ExternalMeetingId: meetingId,
            })
            .promise();
    } else {
        meetingId = query.meetingId;
        meeting = await chime
            .getMeeting({
                MeetingId: meetingId,
            })
            .promise();
    }

    const attendee = await chime
        .createAttendee({
            MeetingId: meeting.Meeting.MeetingId,
            ExternalUserId: `${uuidv4().substring(0, 8)}#${query.clientId}`,
        })
        .promise();

    return json(200, "application/json", {
        Info: {
            Meeting: meeting,
            Attendee: attendee,
        },
    });
};

フロントエンドの作成

フロントエンド用のディレクトリを作成します。

% mkdir -p html/assets/js

app.js作成

app.jsではでWebアプリ画面上のアクションを定義します。

% touch html/assets/js/app.js

html/assets/js/app.js

var startButton = document.getElementById("start-button");

var urlParams = new URLSearchParams(window.location.search);

function generateString() {
    return (
        Math.random().toString(36).substring(2, 15) +
        Math.random().toString(36).substring(2, 15)
    );
}

var isMeetingHost = false;
var meetingId = urlParams.get("meetingId");
var clientId = generateString();

const logger = new ChimeSDK.ConsoleLogger(
    "ChimeMeetingLogs",
    ChimeSDK.LogLevel.INFO
);
const deviceController = new ChimeSDK.DefaultDeviceController(logger);

let requestPath = `join?clientId=${clientId}`;
if (!meetingId) {
    isMeetingHost = true;
} else {
    requestPath += `&meetingId=${meetingId}`;
}

if (!isMeetingHost) {
    startButton.innerText = "Join!";
} else {
    startButton.innerText = "Start!";
}

startButton.style.display = "block";

async function start() {
    if (typeof meetingSession !== 'undefined' && meetingSession) {
        return
    }
    try {
        var response = await fetch(requestPath, {
            method: "POST",
            headers: new Headers(),
        });

        const data = await response.json();
        meetingId = data.Info.Meeting.Meeting.MeetingId;
        if (isMeetingHost) {
            document.getElementById("meeting-link").innerText = window.location.href + "?meetingId=" + meetingId;
        }
        const configuration = new ChimeSDK.MeetingSessionConfiguration(
            data.Info.Meeting.Meeting,
            data.Info.Attendee.Attendee
        );
        window.meetingSession = new ChimeSDK.DefaultMeetingSession(
            configuration,
            logger,
            deviceController
        );

        const audioInputs = await meetingSession.audioVideo.listAudioInputDevices();
        const videoInputs = await meetingSession.audioVideo.listVideoInputDevices();

        await meetingSession.audioVideo.chooseAudioInputDevice(
            audioInputs[0].deviceId
        );
        await meetingSession.audioVideo.chooseVideoInputDevice(
            videoInputs[0].deviceId
        );

        const observer = {
            videoTileDidUpdate: (tileState) => {
                console.log("VIDEO TILE DID UPDATE");
                console.log(tileState);
                if (!tileState.boundAttendeeId) {
                    return;
                }
                updateTiles(meetingSession);
            },
        };

        meetingSession.audioVideo.addObserver(observer);

        meetingSession.audioVideo.startLocalVideoTile();

        const audioOutputElement = document.getElementById("meeting-audio");
        meetingSession.audioVideo.bindAudioElement(audioOutputElement);
        meetingSession.audioVideo.start();
    } catch (err) {
        console.log(err)
    }
}

function updateTiles(meetingSession) {
    const tiles = meetingSession.audioVideo.getAllVideoTiles();
    console.log("tiles", tiles);
    tiles.forEach(tile => {
        let tileId = tile.tileState.tileId
        var videoElement = document.getElementById("video-" + tileId);

        if (!videoElement) {
            videoElement = document.createElement("video");
            videoElement.id = "video-" + tileId;
            document.getElementById("video-list").append(videoElement);
            meetingSession.audioVideo.bindVideoElement(
                tileId,
                videoElement
            );
        }
    })
}

window.addEventListener("DOMContentLoaded", () => {
    startButton.addEventListener("click", start);
});

amazon-chime-sdk.min.js作成

Amazon Chime SDK for JavaScriptを一つのJSファイルにバンドルしたamazon-chime-sdk.min.jsを作成して、フロントエンドでAmazon Chime SDKを使用できるようにします。

amazon-chime-sdk-jsディレクトリ内で作成したamazon-chime-sdk.min.jshtml/assets/js/に配置します。

% git clone https://github.com/aws/amazon-chime-sdk-js.git
% npm --prefix amazon-chime-sdk-js/demos/singlejs install rollup
% npm --prefix amazon-chime-sdk-js/demos/singlejs run bundle
% cp amazon-chime-sdk-js/demos/singlejs/build/amazon-chime-sdk.min.js \
      html/assets/js/amazon-chime-sdk.min.js

index.html作成

会議参加やビデオ会議を行うWebページです。

% touch html/index.html

html/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Serverless Meetings</title>
    <script src="assets/js/amazon-chime-sdk.min.js"></script>
    <script src="https://unpkg.com/uuid@latest/dist/umd/uuidv4.min.js"></script>
    <style>
      #video-list video {
        width: 600px;
        height: 400px;
      }
    </style>
  </head>
  <body>
        <h1>Welcome to Serverless Meetings!</h1>
        <audio style="display: none" id="meeting-audio"></audio>
        <button type="button" id="start-button" style="display: none;"></button>

        <p id="meeting-link"></p>
        <div id="video-list">

        </div>
        <script src="assets/js/app.js"></script>
  </body>
</html>

SAMテンプレート作成

AWSにデプロイするサーバーレスアプリのリソースを定義します。

% touch template.yml

template.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Globals:
  Function:
    Runtime: nodejs12.x
    Timeout: 30
    MemorySize: 128
Resources:
  ChimeMeetingsAccessPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: ChimeMeetingsAccess
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - 'chime:*'
            Resource: '*'
      Roles:
        - Ref: MeetingJoinLambdaRole
  MeetingIndexLambda:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: handlers.index
      Events:
        Api1:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: GET
  MeetingJoinLambda:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers.join
      Events:
        Api1:
          Type: Api
          Properties:
            Path: /join
            Method: POST
Outputs:
  ApiURL:
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/index.html"

アプリのデプロイ

SAMでアプリをAWSへデプロイします。リージョンはus-east-1などAmazon Chime SDKが利用可能なリージョンを選択してください。

% sam build
% sam deploy --guided

sam deployが成功すると次のようにコマンド実行画面にURLhttps://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/index.htmlが出力されます。WebアプリのアクセスURLとなるので控えます。

...


CloudFormation outputs from deployed stack
------------------------------------------------------------------------------------------------------------------------------------------------
Outputs                                                                                                                                        
------------------------------------------------------------------------------------------------------------------------------------------------
Key                 ApiURL                                                                                                                     
Description         -                                                                                                                          
Value               https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/index.html                                                     
------------------------------------------------------------------------------------------------------------------------------------------------

Successfully created/updated stack - sufle-meeting-app-with-chime-sdk in us-east-1

使ってみる

デプロイ時に出力したURLにアクセスします。[Start!]をクリックします。 image

すると会議IDmeetingIdが払い出されてタイルにカメラ映像が表示され会議に参加できます。またmeetingIdをクエリパラメータに含む会議URLが表示されます。 image

会議URLをほかの出席者に共有します。

ほかの出席者は共有された会議URLにアクセスして[Join!]をクリックします。 image

タイルが追加されてカメラ映像が表示され会議に参加できます。 image

終了時は、会議の終了や退出処理は今回は実装していないため、ブラウザタブを閉じて終了してください。

おわりに

Amazon Chime SDK、LambdaおよびAPI Gatewayを使って、Web会議アプリをAWS上にサーバーレス構成で作成してみました。

完全サーバーレスでWeb会議機能を持ったアプリを構築できるのは驚きですね。自社アプリへのWeb会議機能の組み込みもこれなら開発費用をとても抑えられそうです。

参考

以上