コロナの影響で、リモートでの勉強会が増えてきましたが、プレゼン中に反応が少なくて寂しいなぁと感じることがあります。
でも意外にチャットは盛り上がってることが多いので、画面共有しているブラウザ上にニコニコ動画風にメッセージをアニメーション表示する機能を作ってみることにしました。

やりたいこと

  • プレゼンターが会議URLを発行する
  • 参加者は会議URLからコメントを入力する
  • プレゼンターのWebブラウザ上に、参加者のコメントがニコニコ動画風にアニメーション表示される

アーキテクチャ

検討の結果、以下の技術を採用することにしました。

  • ブラウザ上でコメント常時受付(フロント、プレゼンター側) => WebSocket(socket.io)
  • 任意のページ上にコメント表示(フロント、プレゼンター側) => ブックマークレット(良いものができたらChrome拡張化して公開)
  • コメント常時受付(サーバ側) => WebSocket(socket.io) + Node.js on GAE
  • コメント入力(フロント、参加者側) => Express + Node.js on GAE

では、ここから実装のポイントとなる部分を紹介していきます。
githubリポジトリ を公開しているので最新のソースコードはそちらを参照ください。

WebSocketでコメントを送受信

WebSocketとは

  • RFC6455 で定義され、クライアントとサーバー間で送信と受信を同時に行うことができる通信チャネルを提供するプロトコル
  • 通信の仕組み
    • 開始ハンドシェイク: HTTP通信でリクエストして、「101 Switching Protocols」が返却されると、WebSocketプロトコルに切り替わって、コネクションが確立される
    • 双方向通信: 確立したコネクションの上で、双方向からいつでもデータフレームを送信することができる
    • 終了ハンドシェイク: コネクションを閉じる

クライアントサイド実装(参加者側)

クライアントサイドでは、「参加者がWebSocketサーバにコメントを送信する」ということをやります。詳細はこちら

// 一部抜粋
<script src="https://niconicoment.df.r.appspot.com/socket.io/socket.io.js"></script>
<script>
(() => {
    class NiconicomentParticipant {
        constructor() {
            // コネクション作成
            this.socket = io();
            this.message = undefined;
            this.registerEvents();
            this.render();
        }

        registerEvents() {
            this.sendButtonEl.addEventListener('click',(e) => {
                const comment = new Comment(this.commentEl.value, this.colorEl.value, this.sizeEl.value);
                // イベント発行
                this.socket.emit('send-comment-to-server', {meetingId: this.meetingIdEl.value, comment: comment}, (res) => {
                    this.message = res.message;
                    this.render();
                });
            });
        }
    }
    new NiconicomentParticipant();
})();
  • socket.io のサーバ機能が/socket.io/socket.io.jsというパスでクライアントJSをserveしているので、それを読み込む
    • CDNだとサーバ側とのバージョン違いが起こりえる
  • io()でコネクションを作成する
  • socket.emit()でイベントを発行している。引数でcallback関数を渡し、処理結果を受け取れる。

サーバサイド実装

ライブラリインストール

まずは socket.ioexpress をインストールします。

npm install express socket.io --save

実装

サーバサイドでは、「参加者からのコメントを受信してプレゼンターに送信する」ということをやります。詳細はこちら

以下の部分で、express + socket.io を立ち上げて、8080ポートでリッスンしています。

const express = require('express');
const app = express()
const http = require('http').Server(app);
const io = require('socket.io')(http, { cors: { origin: '*' } });

app.use(express.static('public'));

const port = process.env.PORT || 8080;
http.listen(port, () => {
    console.log('listening on http://localhost:' + port);
});
  • httpモジュールでHTTPサーバを立ち上げ、expressでroutingなどを行う
  • 今回はクロスオリジンで呼び出されるためCORS設定を行なっている(Access-Control-Allow-Origin: *を返す設定)
  • express.static('public') でpublicフォルダ配下の静的ファイルをserveする
  • 後述するGAEが8080ポートでリッスンするWebサーバを外部公開してくれる

次に、以下の部分で、socket.ioのイベント処理を定義しています。

// 一部抜粋
io.on('connection', (socket) => {
    socket.on('send-comment-to-server', (msg, callback) => {
        io.to(msg.meetingId).emit('send-comment-to-presenter', msg.comment);
        callback(new Response(true, 'Send comment succeeded.'));
    });
});
  • io.on('connection')でクライアントからのハンドシェイクリクエストを受け付ける。socket.idはクライアント毎に別IDになる
  • socket.on('send-comment-to-server')でそのsocketにおけるクライアントからのコメント送信イベントを受け取る。最後の引数はcallbackでこれを実行することで呼び出し元に結果を返却できる
  • io.emit('send-comment-to-presenter')でメッセージ送信イベントを発行していが、to(msg.meetingId)で宛先を絞っているため、プレゼンターにだけイベントが送られる

クライアントサイド実装(プレゼンター側)

プレゼンターは任意のWebページ上で会議作成やコメント受付をするようにしたいので、JavaScriptだけで画面構築およびWebSocket通信を行うようにしました。詳細はこちら

まず、動的にJavaScriptで socket.io.js を読み込んでおきます。

// 一部抜粋
const script = document.createElement('script');
script.src = 'https://niconicoment.df.r.appspot.com/socket.io/socket.io.js';
script.addEventListener('load', () => {
    // 本体処理
    new NiconicomentPresenter();
});
document.body.appendChild(script);
  • scriptエレメントを作って、bodyに追加することでスクリプトを動的に読み込む
  • script読み込みをloadイベントで待ってから、本体の処理を行う

socket.ioに関する処理の書きっぷりは、これまで紹介したものとほぼ同等です。

// 一部抜粋
class NiconicomentPresenter {
    constructor() {
        this.registerEvents();
    }
    registerEvents() {
        this.openButtonEl.addEventListener('click', (e) => {
            // コネクション作成
            this.socket = io('https://niconicoment.df.r.appspot.com'); // 明示的にURL指定
            // イベント監視
            this.socket.on('send-comment-to-presenter', (msg) => {
                this.slideComment(new Comment(msg.comment, msg.color, msg.size));
                this.renderToolbox();
            });
        });
    }
}
  • コネクション作成時に明示的にURLを指定する。そうしないとホストページのドメインにアクセスしようとしてしまう
  • socket.on()でイベントを監視している

最後に、presenter.js の読み込みは以下のブックマークレットで行います(コンソールやアドレスバー実行でももちろんOK)。

javascript: const script = document.createElement('script'); script.src = 'https://niconicoment.df.r.appspot.com/presenter.js'; document.body.appendChild(script);

ニコニコ動画風にコメントをアニメーション

最初は何かJavaScriptのライブラリでも入れようかと思ったのですが、今回の範囲であれば、cssの animation で十分だったので、そちらを利用することにしました。

ニコニコ動画風にコメントを右から左にスライドさせる設定は、とてもシンプルです(実際にはJavaScriptで動的に値を設定してます)。

<style>
.niconicoment-slide-comment {
    position: absolute;
    animation: slide 10s linear 0s 1 normal forwards;
}
@keyframes slide {
    0% { right: 0; }
    100% { right: 100%; display: none; }
}
</style>

animation一括指定プロパティ

左から順に以下の意味になるそうです。

  • animation-name: アニメーションの名前(@keyframesで指定する名称と一致させる)
  • animation-duration: 1回のアニメーション周期の完了時間
  • animation-timing-function: アニメーションの変化率(ease, linear, ease-out, ease-in-outなど)
  • animation-delay: アニメーションの開始遅延時間
  • animation-iteration-count: 繰り返し回数
  • animation-direction: アニメーション再生の向き(normal, reverse, alternate, alternate-reverseなど)
  • animation-fill-mode: アニメーションの実行後にどうスタイルを適用するか(none, forwards, backforwards, both)

@keyframes

  • 一連のアニメーションの各ステップを制御するためのもの
  • ここで指定するアニメーション名はanimationプロパティで指定した値と一致させる必要あり
  • 今回は、right: 0 => right: 100%で右から左に移動させている
  • 最後にdisplay: noneを指定した上で、animation-fill-mode: forwardsとしているので、アニメーション後に要素は非表示になる

GAEにNode.jsアプリをデプロイ

こちらをみながら作業してみましたが、初めてでも30分ぐらいでできました。

事前設定

  1. gcloud SDKをインストール
  2. プロジェクト作成
    • gcloud projects create [YOUR_PROJECT_ID] --set-as-default
  3. 作成されたプロジェクトを確認
    • gcloud projects describe [YOUR_PROJECT_ID]
  4. GAEアプリケーションを初期化
    • gcloud app create --project=[YOUR_PROJECT_ID]
  5. 対象プロジェクトの課金を有効化

app.yamlを作成してデプロイ

GAEのアプリケーションの設定ファイルであるapp.yamlを作成します。設定内容のリファレンスはこちらです。

runtime: nodejs12
network:
  session_affinity: true
entrypoint: node serve.js
  • session_affinity: trueで 同じユーザのリクエストを同じインスタンスに割り振る
  • entrypointで8080ポートでリッスンするWebサーバを立ち上げると、独自ドメインで外部公開してくれる

app.yamlを作成したら、gcloud app deployでGAEアプリをデプロイします。

gcloud app deploy
> Services to deploy:
> 
> descriptor:      [/xxxx/niconicoment/server/app.yaml]
> source:          [/xxxx/niconicoment/server]
> target project:  [niconicoment]
> target service:  [default]
> target version:  [20201208t011212]
> target url:      [https://niconicoment.df.r.appspot.com]

今回の例だと、http://localhost:8080https://niconicoment.df.r.appspot.com で公開されました。
デプロイしたアプリの稼働状況(アクセス数、課金ステータスなど)はコンソールから確認できます。

トラブル対応

WebSocketのハンドシェイクでエラー

WebSocket connection to 'wss://niconicoment.df.r.appspot.com/socket.io/?EIO=4&transport=websocket&sid=PokDhNT0SYiyiNngAAAg' failed: Error during WebSocket handshake: Unexpected response code: 400

Google App EngineのStandard環境ではWebSocketの対応をしてないためでした。フレキシブル環境に設定を変更することでこのエラーは解消されるようです。app.yamlに以下の設定を追加すればよいみたいです。

# フレキシブル環境にする
env: flex
# ローカルメモリにチャット内容などを保持しているケースではインタンスが複数あるとうまく動かないのでインスタンス数を1に設定
manual_scaling:
  instances: 1

しかし、フレキシブル環境は無料枠が提供されていません ので、費用がかかってしまいます。今回は、エラーが発生しているものの問題なく動作しているため、この対処は行わず放置することにしました。
では、なぜWebSocketが利用できないのにうまく動くのでしょうか。答えはドキュメントに書いてありました。

The client will try to establish a WebSocket connection if possible, and will fall back on HTTP long polling if not.

どうやら、socket.ioはWebSocketに対応していない場合、HTTP polling に切り替えて実行してくれるようです。これはWebSocketに対応していないブラウザなどでも、動くようにするためのもののようですが、今回はこれに助けられました(^ ^)

CORSエラー

ブックマークレットでホストページのドメインから、WebSocketサーバにアクセスする際に以下のエラーが発生しました。

Access to XMLHttpRequest at 'https://niconicoment.df.r.appspot.com/socket.io/?EIO=4&transport=polling&t=NOtA0a6' from origin 'https://socket.io' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

これはsocket.ioのサーバサイドの設定で以下の設定を行うことで回避します。

const io = require('socket.io')(http, { cors: { origin: '*' } });

Content Security Policy エラー

ブックマークレットで動的にJavaScriptファイルを読み込んだ際に以下のエラーが発生するページがありました。

VM19:1 Refused to load the script 'https://niconicoment.df.r.appspot.com/presenter.js' because it violates the following Content Security Policy directive: "script-src github.githubassets.com". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

これは、現状まだ解決してないのですが、動的に読み込んでいる二つのJavaScriptファイルをバンドルして、その内容を直接ブックマークレットで実行するか、Chrome拡張として実行すれば解決できそうです。

(後日追記)こちらはChrome拡張として実行することで解決しました。ソースコードを公開してますので興味があれば参照ください。

やりのこしたこと

  • socket.ioのroom機能活用
  • 複数人が切り替えながら画面共有するケースで、会議URLを使い回せるようにする

ソースコード

こちらで公開しています。
https://github.com/rinoguchi/niconicoment

さいごに

会社の勉強会向けにニコニコ動画風コメント表示サービスを開発してみましたが、今回初めて触った socket.io も GAE もかなり良かったです。

  • socket.io
    • サーバとクライアントでほぼ同じ実装でいける
    • WebSocket対応していない環境でも動くことに救われた
  • GAE
    • 多少言語を選ぶものの、Webアプリを簡単にホスティングできる
    • 独自ドメインを割り当てて、デフォルトでHTTPサーバを公開してくれる

[参考] アーキテクチャ選定理由

ブラウザ上でコメント常時受付(フロント、プレゼンター側)

プレゼンターは参加者が入力したコメントを受け付けたいのですが、候補は二つあるかなと思います。

  • WebSocket で常時接続【採用】
  • HTTPでサーバに一定間隔でpolling【不採用】

今回はプレゼンテーション中に良い感じのコメントを送るのが目的なので、リアルタイム性に優れたWebSocketを採用しました。

任意のページ上にコメント表示(フロント、プレゼンター側)

プレゼンターは、Web会議システムで画面共有する任意のページ上にコメントを表示したいのですが、任意のページ上でJavaScriptを実行できる必要があり、候補は三つありそうです。

  • ブックマークレット【採用】
    • ○ 任意のサイトで実行できる
    • ○ 作り込みが楽
    • × 配布が大変で、一般の人に使ってもらうのは厳しい
  • Chrome拡張【とりあえず不採用。後回し】
    • ○ ユーザが許可すれば任意のサイトで実行できる。ユーザはプレゼンターだけなのでハードルも低い
    • △ 作り込みや申請が大変
    • ○ Chrome Storeは配信できるし、誰でも使える
  • iframe【不採用】
    • × iframeで対象サイトを読み込む際にCORSエラーになる。サイト側でAccess-Control-Allow-Originヘッダーを追加してもらうのは不可能

結論としては、まずはブックマークレットで作り込んで、良いものができたらChrome拡張化するという方針にすることにしました。

コメント常時受付(サーバ側)

WebSocketのサーバ機能が必要になりますが、今回はクライアントがブラウザで言語はJavaScriptになりますので、サーバサイドも同じくJavaScriptを採用しようと思います。
Node.jsのsocket.ioであれば、Webブラウザで実行するJavaScriptクライアントも提供されるので、これを使うのが良さそうです。

次にサーバの実行場所ですが、GCPのサービス内で検討すると以下の三つ候補がありました。

  • App Engine(GAE)【採用】
    • ○ 無料枠: 28 インスタンス時間 / 日
    • ○ デプロイ簡単だし、サービスの起動停止を考えなくていい
    • ○ 独自ドメインでHTTPサーバを外部公開してくれる
  • Compute Engine(GCE)【不採用】
    • ○ 無料枠: 1 f1-microインスタンス / 1ヶ月
    • × linux環境準備、サービスの起動停止管理など大変
  • Cloud Run【不採用】
    • × HTTPサーバの外部公開は自分でやる必要がある
    • 無料枠: 200万リクエスト / 月
    • × こちらを参考にさせていただくと、やめた方が良さそう

比較すると、GAEが明らかに今回の用途では優位性があるので、GAEを採用することにしました。

Denoを使いたい欲求もあるのですが、GAEが対応してないので今回はやめておくことにしました)

コメント入力(フロント、参加者側)

参加者がコメントを入力するWeb画面が必要なのですが、ここは静的なWebサイトをホスティングできればそれで良さそうです。
色々候補があるようなのですが、今回はGAE+Node.jsを利用することをすでに決めたので、ついでにexpressで静的HTMLをserveすることにしました。