63
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2019-06-28

前提

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

WebRTCについては「WebRTC入門2016」が非常に参考になりました!
Firebaseについては公式ドキュメントを読んでください!

概要

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

シーケンス図

1.png

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

ステート図

2.png

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

実装

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

本体

本体
<!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のルール

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

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

#実行
ブラウザを2つ(ブラウザA、ブラウザB)起動して、実行確認してみます。

ブラウザの画面

接続待ち

image.png

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

マッチング中

image.png

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

接続中

image.png

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

ブラウザAから送信してみる

ブラウザA側

image.png

ブラウザB側

image.png

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

ブラウザB側

image.png

ブラウザA側

image.png

接続された後のFirebase Realtime Database

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

image.png

感想とか

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

いちおう

image.png

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

  1. Firebase JavaScript SDKの初期設定が省略できるマス

63
51
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?