WebRTCのシグナリングにFirebase Realtime Databaseを使ってP2P通信をしてみた

# 前提
WebRTCでランダムマッチングみたいなことをやりたくて、
シグナリングにFirebase Realtime Database（以降：Realtime Database）を使用すればお手軽に実装できるんじゃね？
と思って実装してみたハナシ

WebRTCについては「[WebRTC入門2016](https://html5experts.jp/series/webrtc2016/)」が非常に参考になりました！
Firebaseについては[公式ドキュメント](https://firebase.google.com/docs?hl=ja)を読んでください！

# 概要

実装見ながら書きました！

## シーケンス図

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/eabe0e29-65b0-9078-234f-68ae818f2e64.png)

オファーとアンサーのSDPのやり取り（シグナリング）はRealtime Databaseで行います。

## ステート図

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/3a36798f-00ef-3006-d00b-6dceadd9627d.png)

マッチング中にオファーとアンサーどちらか先に接続成功した方を使って、以降のP2P通信を行います。
自分のオファーを自分でアンサーしないよう気を付けます。

# 実装

Firebase Hostingにデプロイして動かす前提のhtmlファイルです。
Firebase Hostingにデプロイすると、ほんの少し楽できます！[^1]

## 本体

```html:本体
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8" />
</head>

<body>
    <div id="app">
        <label>During matching:</label><span>{{ duringMatching }}</span><br>
        <label>Is connected:</label><span>{{ isConnected }}</span><br>
        <button @click="matching" id="matchingButton"
            v-bind:disabled="duringMatching || isConnected">Matching</button><br>
        <button @click="disconnect" id="disconnectButton">Disconnect</button><br>
        <hr>
        <textarea v-model="sendData" id="sendText" cols="100" rows="1"></textarea>
        <button @click="send(sendData)" id="sendButton" v-bind:disabled="!isConnected">Send</button><br>
        <div style="white-space:pre-wrap; word-wrap:break-word;">{{receiveData}}</div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10"></script>
    <script src="/__/firebase/6.2.1/firebase-app.js"></script>
    <script src="/__/firebase/6.2.1/firebase-database.js"></script>
    <script src="/__/firebase/init.js"></script>
    <script>
        // RTC設定
        const rtcConfiguration = {
            iceServers: [
                {
                    urls: [
                        "stun:stun.l.google.com:19302",
                        "stun:stun1.l.google.com:19302",
                        "stun:stun2.l.google.com:19302",
                        "stun:stun3.l.google.com:19302",
                        "stun:stun4.l.google.com:19302"]
                }
            ]
        }

        // データチャネルの名前
        const dataChannelLabel = "TESTTESTTESTTEST"; // 適当に付けました！

        /**
         * Vannila ICE化を待つ
         * @return {Promise} Promise
         */
        const waitVannilaIce = peer => {
            return new Promise(resolve => {
                peer.onicecandidate = e => {
                    if (!e.candidate) {
                        resolve();
                    }
                }
            })
        }

        /**
         * 待機時間だけ待つ
         * @param {number} ms 待機時間(ms)
         * @return {Promise} Promise
   　    */
        const sleep = ms => {
            return new Promise(resolve => setTimeout(resolve, ms));
        }

        /** Offer用RTCPeerConnection */
        var offerPeer;
        /** Answer用RTCPeerConnection */
        var answerPeer;
        /** Offer用DataChannel */
        var offerDataChannel;
        /** Answer用DataChannel */
        var answerDataChannel;

        const p2pRef = firebase.database().ref().child("p2p");

        const app = new Vue({
            el: "#app",
            data: {
                key: "",
                sendData: "",
                receiveData: "",
                isConnected: false,
                isChanged: false,
                duringMatching: false,
                createOfferTimerHandler: 0
            },
            methods: {
                /**
                 * 受信
                 * @param {string} data 受信データ
                 */
                receive: data => {
                    app._data.receiveData = "receive: " + data + "\n" + app._data.receiveData;
                },
                /**
                 * クリア
                 */
                clear: () => {
                    app._data.duringMatching = false;

                    p2pRef.off("child_added");
                    p2pRef.off("child_changed");

                    clearTimeout(app._data.createOfferTimerHandler);
                },
                /**
                 * 切断
                 */
                disconnect: () => {
                    app._data.isConnected = false;

                    if (answerPeer) {
                        answerPeer.close();
                    }

                    if (offerPeer) {
                        offerPeer.close();
                    }

                    app.clear();
                },
                /**
                 * マッチング
                 */
                matching: () => {
                    app.disconnect();
                    app._data.duringMatching = true;

                    p2pRef.on("child_added", async snapshot => {
                        if (snapshot.val().date <= ((new Date()).getTime() - 60000)) {
                            p2pRef.child(snapshot.key).set(null);
                        } else {
                            if (!app._data.isConnected && snapshot.key != app._data.key && snapshot.val().status == "offer") {
                                await sleep(300);
                                answerPeer = new RTCPeerConnection(rtcConfiguration)

                                answerPeer.ondatachannel = e => {
                                    let datachannel = e.channel
                                    datachannel.onmessage = ev => {
                                        app.receive(ev.data);
                                    }
                                }

                                answerPeer.onconnectionstatechange = event => {
                                    switch (answerPeer.connectionState) {
                                        case "connected":
                                            app._data.isConnected = true;
                                            app.clear();
                                            break;
                                        case "disconnected":
                                            app._data.isConnected = false;
                                            app.clear();
                                            break;
                                    }
                                }

                                answerDataChannel = answerPeer.createDataChannel(dataChannelLabel);

                                let offer = new RTCSessionDescription({
                                    type: "offer",
                                    sdp: snapshot.val().offer,
                                });

                                await answerPeer.setRemoteDescription(offer);
                                let answer = await answerPeer.createAnswer();
                                await answerPeer.setLocalDescription(answer)
                                await waitVannilaIce(answerPeer)

                                if (!app._data.isConnected) {
                                    p2pRef.child(snapshot.key).update({
                                        answer: answerPeer.localDescription.sdp,
                                        status: "answer",
                                        date: (new Date()).getTime()
                                    });
                                }
                            }
                        }
                    });

                    p2pRef.on("child_changed", snapshot => {
                        if (!app._data.isConnected && snapshot.key == app._data.key && snapshot.val().status == "answer" && !app._data.isChanged) {
                            app._data.isChanged = true;

                            let answer = new RTCSessionDescription({
                                type: "answer",
                                sdp: snapshot.val().answer,
                            });

                            offerPeer.setRemoteDescription(answer);
                        }
                    });

                    app.createOffer();
                },
                /**
                 * 送信
                 * @param {string} data 送信データ
                 */
                send: data => {
                    app._data.receiveData = "send: " + data + "\n" + app._data.receiveData;
                    if (offerDataChannel && offerDataChannel.readyState == "open") {
                        offerDataChannel.send(data);
                    } else if (answerDataChannel && answerDataChannel.readyState == "open") {
                        answerDataChannel.send(data);
                    }
                },
                /**
                 * Offer作成
                 */
                createOffer: async () => {
                    clearTimeout(app._data.createOfferTimerHandler);
                    app._data.isChanged = false;
                    offerPeer = new RTCPeerConnection(rtcConfiguration);

                    offerPeer.ondatachannel = e => {
                        let datachannel = e.channel
                        datachannel.onmessage = ev => {
                            app.receive(ev.data);
                        }
                    }

                    offerPeer.onconnectionstatechange = event => {
                        switch (offerPeer.connectionState) {
                            case "connected":
                                app._data.isConnected = true;
                                app.clear();
                                break;
                            case "disconnected":
                                app._data.isConnected = false;
                                app.clear();
                                break;
                            case "failed":
                                if (!app._data.isConnected) {
                                    app.createOffer();
                                }
                                break;
                        }
                    }

                    offerDataChannel = offerPeer.createDataChannel(dataChannelLabel);

                    let offer = await offerPeer.createOffer();
                    await offerPeer.setLocalDescription(offer);
                    await waitVannilaIce(offerPeer);

                    if (!app._data.isConnected) {
                        app._data.createOfferTimerHandler = setTimeout(app.createOffer, 30000);

                        let ref = p2pRef.push();
                        app._data.key = ref.key;

                        ref.set({
                            offer: offerPeer.localDescription.sdp,
                            answer: "",
                            status: "offer",
                            date: (new Date()).getTime()
                        });
                    }
                }
            }
        })
    </script>
</body>

</html>
```

Realtime Databaseの操作のためにFirebase JavaScript SDKを読み込ませる以外に、Vue.js読ませて手抜きUIにしました！

## Realtime Databaseのルール

```json:Databaseのルール
{
  "rules": {
    "p2p": {
      ".read": true,
      ".write": true
    }
  }
}
```

ガバガバのままでも良かったんですが、多少変更しておきます（ガバガバには変わりない）

#実行
ブラウザを2つ（ブラウザA、ブラウザB）起動して、実行確認してみます。
## ブラウザの画面
### 接続待ち

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/356511c2-ab6e-a592-7731-1664e0d13f47.png)

「Matching」ボタンをクリックすると、マッチングが開始されます。

### マッチング中

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/84cdb1b5-7613-ad16-d10d-fd44095ddc4c.png)

「Disconnect」をクリックすると切断します。

### 接続中

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/e3324009-509a-451b-84d7-2f38be186dc3.png)

この状態でテキストボックスへ好きな値を設定し、「Send」をクリックすると相手に送信されます。

### ブラウザAから送信してみる
#### ブラウザA側
> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/73e3635f-fe56-13fa-326e-15d30c3e3d3a.png)

#### ブラウザB側

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/7e997ad6-0723-2662-046b-4ce1ff3ec338.png)

### ブラウザBから送信してみる
#### ブラウザB側

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/91aaec96-8cf2-84ed-636e-f0510fce5d07.png)

#### ブラウザA側

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/c13c8830-8c4d-7313-623b-743a0516e994.png)

# 接続された後のFirebase Realtime Database

ゴミデータなので、次マッチングする誰かが消してくれるハズ…

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/38253d43-81db-8bb0-f967-5c02172521b9.png)

# 感想とか
そもそもの設計がガバガバなんで同時接続数が増えると接続失敗する確率あがります！（ブラウザ8つでテストしてみんな接続できたので良しとしました。）
あとiOS版Safari、Android版Chromeでも動作確認できました。
調子乗ってネット対戦型のミニゲームも作りましたが、それは別の（以下略

# いちおう

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348583/2b147aab-206c-c987-6c0e-73f58b0f4b85.png)

ミニゲームはこんな画面です。

[^1]: Firebase JavaScript SDKの初期設定が省略できるマス