Help us understand the problem. What is going on with this article?

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

前提

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の初期設定が省略できるマス 

Yamazin
楽しく生きていきたい、 フリーランスのプログラマー
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away