2
3

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が難しすぎたのでまとめて実装してみた話

Posted at

自分用のメモとはなりますが、わかりやすくまとめました。
WebRTCの実装、仕組みが分からず困っている方々の助けになれば幸いです!

注意: 私が初心者ということもあったり、話がややこしくなることを防ぐため、
 厳密な定義とは違うように書かれている可能性があります。
 厳密な定義を欲している方は、詳しく書かれている記事をお読みください。
これはとりあえずWebRTCを使えるようになることを目的とします。

WebRTCとは?

検索欄に出しゃばってきたCopilotの回答は、以下のようでした。
image.png

Webブラウザ間でリアルタイムの音声、映像、データ通信を可能にするオープンソースな技術

これをすごく簡単にまとめると、

Webブラウザだけでリアルタイム通信できるよ

ということになります。

WebRTCの特徴

WebRTCは以下のような特徴を持ち合わせています。

  • P2P通信である
  • 音声や映像、データ通信をリアルタイムでできる

要するにZoomもどきが作れるということです。

P2Pとは何か?

P2P(Peer to Peer)とは、簡単に言うとリーダーみたいに存在しているサーバー
介さずに通信する技術ということです。図で表すとわかりやすいと思います。
image.png
これが中央集権型といって、サーバーという奴が主な処理を行ってくれます。
文系の方なら絶対王政と言えばわかりやすいと思います。サーバーが王で赤が平民ですね。
なお私は歴史に全く詳しくないので多分間違ってます。

そしてこれがP2Pです。
image.png
見てください、お互い対等に、サーバーを経由せず通信してるではありませんか。
これがP2Pの世界です。凄いですね。

P2Pの課題

NATって誰?ナッツの親戚?

しかしそう簡単に行きません。もしもこんな簡単に互いの通信が出来たら
苦労しませんよ。普通に欲しいです、そんなネットワーク。

ほとんどの環境で、PCはNATという奴の中にいます。
IPアドレスを聞いたことがありますか、これ、実は二種類あって、

  • プライベートIPアドレス
  • グローバルIPアドレス

という二つのIPがあります。
まずはこちらの図をご覧ください。
image.png
赤色はそれぞれプライベートIPアドレスを持っています。これは

同じ仲間の間でしか通用しない

IPアドレスです。次にこの黄色を見てみましょう。これはNATです。
これはグローバルIPアドレスを持っていて、世界中のインターネット上で
色んなことができるようになっています。

そして赤色のコンピューターは外の世界とダイレクトにつながることはできません。
グローバルIPを持っていないからです。そこでNATを通すと外とつながることができます。

一方、他のところにもグローバルIPはあるわけで、それも内部にコンピューターを
持っているはずです。
image.png
しかしP2Pを行うのなら、こうつながなければなりません。
image.png
しかし前述したように、赤色(今回は緑色も含む)のコンピューターは外の世界と
つながれない、と書きました。

そうすると、もちろんお互いに通信するなんて絶対に無理なんですよ。
どうしてもNATを経由する必要があります。(これをNAT越えといいます)
image.png
しかしNATがどうのこうのしろと言ってもただただグローバルIPとプライベートIPを
結びつけるだけなのでうまく行きません。そこで先人たちがすごい技術、頭が
オーバーフローしすぎてbadallocを起こしそうなくらいのモノを作り上げました。
私はここで一回挫折しそうになりました。 では説明。

いざ通信、その前に

SDPとは?

まずはSDPといって、お互いのことを知り合うための自己紹介をするためのデータです。
厳密にいうと頭がパンクするのでいうのをやめておきます。

ここでは、自分が映像、音声の処理方式(コーデックとかいうらしい)などを交換
するらしいです。つまり
「俺らどうやって映像とか受け渡しするの?」
というのを決めるやつです。これでお互いのことを知れました。
自己紹介は人間でもP2Pでも大事です。
(正確にはSDPはこれらの情報を受け渡しする一連の流れのことですが、ここではそういう解釈で行きます。)

ICE Candidateとは?

いや私に言われてもわかんないですよ。こんなもの。
...まあ、NAT越えを実現するための書類です。
トラックで輸送するときも、道路が全く分からなければどうしようもありません。
それと同じで、どういう経路で接続するかというのを決めます。
これを説明するときに専門用語とかは不要だと信じたいです。

STUNとは?

STUNはスタンと言いますが、別に気絶とかそういう意味合いではありません。
これは単純で、自分がどのIPにいるかを返してくれるサーバーです。
image.png
これでお互いのIPを交換します。

TURNとは?

しかし、先人がどれだけ知恵を振り絞っても、うまく行かないときというのがありました。
そもそも互いのコンピューターだけで通信するのがそもそも無茶なんですよね。
そこで仕方なく、中継地点を挟んでいざこざをうまくやり通します。
image.png

注意点として、STUNとTURNは同時に言われることが多いです(STUN/TURN的な)
ですが、多分ほとんど関連性がありません。ご注意ください、それで私は
混乱してしまいました。混乱はとことん省いていきましょう。

ここまでのおさらい(Quiz 1)

飛ばしてもいいです。

  1. サーバーを通さずに互いに対等な関係で通信する方法のことを何と言いますか?
  2. グローバルIPアドレスとは、仲間の間だけで通用するIPである、正しいですか?
  3. プライベートIPとグローバルIPを結びつける奴をアルファベット3字でいうと何ですか?
  4. プライベートIPしか持たないPCは直接世界中のネットワークにアクセスできますか?
  5. お互いの自己紹介をする書類を何と言いますか?「S??」の?に当てはまる文字を書いてください
  6. 通信経路を確定する書類を何と言いますか?「I?? Candidate」の?に当てはまる文字を書いてください
  7. STUNサーバーの役割を簡単に書いてください
  8. TURNサーバーは必ず必要である、正しいですか?

正解

  1. P2P
  2. 正しくない
  3. NAT
  4. できない
  5. SDP
  6. ICE Candidate
  7. 自分がいるルーターのグローバルIPを教えてくれる
  8. 正しくない

補足: TURNサーバーは必ず必要でありません。あくまでもどうしようもないときに
 使うのみで、TURNサーバーを使うことは理想的でありません。

P2Pの闇

こんなにたくさん用意しておいてなんですが、これは一言で論破できてしまいます。

じゃあその情報はどうやって受け渡しするの???ねぇ??

無理です。ということで残念ながら完全にサーバーが不要というわけではなく、
受け渡しするのに第三者のサーバーを経由して、お互いに情報を渡さなければいけません。
このサーバーをシンナリングサーバーといいます。

なお、面白いことに、この受け渡し自体はどう渡すかは定義されていないので、
郵便で渡すこともできます。たった一度の通信に郵便で送るようなことはさすがに
する人はいないでしょう。SDPとICEを渡す郵便サービス、なんか逆に面白そう。

これでP2Pの一連の流れはおしまい、だが...

とりあえず仕組みとしては最低限はこれくらいで大丈夫だと思います。
自分も書いていて頭がパンクしそうになりました。
ただ実装やりたくない。やりたくないけど...やるしかないです。

実装

image.png
今までの内容をまとめるとこんな感じで、それぞれ赤と水で実装すべきところがあります。
STUNサーバーはグローバルIP上にある必要があり、面倒なので外部のものを使います。
なお、TURNは必須でないので今回は実装しません。

準備

では、まずは環境を準備しておきましょう。今回はNode.jsでやります。

$ npm init
$ touch index.js
$ npm i express
$ touch index.html

それぞれ、Node.jsのプロジェクトの設定、index.jsの作成、Expressのインストールを
やっています。この辺りはWebRTCのあたりで重要ではないので省きたいですが、
私が理解できないのでちゃんと書いておきます。

※Expressとは、Node.jsでWebサーバーが作れる最高のフレームワーク。

それぞれ、こんな風に書きます。

index.js
const express = require("express");
const app = express();

app.get("/", (req, res) => {
    res.sendFile(__dirname + "/index.html");
});
app.listen(3000, () => {
    console.log("HTTP Server is listening on PORT 3000");
});
index.html
<!DOCTYPE html>
<html>
    <head>
        <title>WebRTCの実験</title>
        <meta charset="utf-8">
        <style>

        </style>
    </head>
    <body>
        <h1>WebRTCの実験</h1>

        <script>
            // ここにスクリプトをこれから書いていきます。
        </script>
    </body>
</html>

シンナリングサーバーの実装

シンナリングサーバー、実はWebRTC側でどういうものを作るのかというのが
厳密に定義されていないので、ほぼ自分で実装します。(というか全部作ります)
頑張りましょう。

シンナリングサーバーは、WebSocketというもので作成していきます。
WebSocketとは、双方向通信ができるプロコトルです。
まあ深く考えずにいうと、シンナリングサーバーにすげぇ向いているプロコトルです。

では、以下のコマンドを実行してください。

terminal
$ npm i ws

これでWebSocketのいざこざを実装できます。
WebSocketを利用する為に、以下を追記してください。

index.js
const express = require("express");
+ const WebSocketServer = require("ws").Server;
const app = express();
+ const ws = new WebSocketServer({ port: 8081 });

さらに、WebSocketの通信を受け入れる為に、以下を追加します。

index.js
ws.on("connection", (wsc) => {
    // ここで、wscは接続されたクライアントを示す。
    wsc.on("message", (msg) => {
        // Recieved
        console.log("Recieved: " + msg);
        // 送信されたものを受け取り、msgとして受け取る
        ws.clients.forEach((client) => { // クライアントごとに
            if (client != wsc && client.readyState === 1) { // 受信できる状態で、自分自身じゃないなら
                client.send(msg);
            }
        })
    });
});

これは自分が送信したら、自分以外のところに全部そのメッセージを送信する、っていうプログラムですね。
そして、WebSocketに接続するプログラムを書きます。

index.html
        <script>
            // ここにスクリプトをこれから書いていきます。
+           const signalingConnection = new WebSocket("ws://localhost:8081");
        </script>

おい、急にわけわからないことを言い出すな。
まあこれは何かというとさっきのWebSocketサーバーに接続してるということです。
ちょっと訳が分からないので説明しておきます。
image.png
この画像のように、まず自分のPCでHttpsサーバー、WebSocketサーバーを立てます。
これは自分のPCでのみ通用するものです。そしてまずブラウザが自分で立てたhttps鯖に
アクセスして、HTMLを強奪します。その中に含まれるscriptが、WebSocket鯖にアクセスし
接続状態となるというような感じです。
そうすると、サーバーにすべてのクライアントがつながります。

Offer/Answerについて

そもそもとして、SDPを送るとき、Offer/Answerという形で接続をします。
コンピューターAとBが接続するとき、AがSDPを送り(Offer)、Bが応答する(Answer)
という構図です。

RTC接続の管理

各コンピューターにIDを持たせます。今回はUUIDというWebSocketで
使われるIDを使います。すごい便利です。そのためにシンナリングサーバーを改変します。

index.js
const express = require("express");
const WebSocketServer = require("ws").Server;
+ const { v4: uuidv4 } = require("uuid");
const app = express();
const ws = new WebSocketServer({ port: 8081 });

app.get("/", (req, res) => {
    res.sendFile(__dirname + "/index.html");
});
app.listen(3000, () => {
    console.log("HTTP Server is listening on PORT 3000");
});

ws.on("connection", (wsc) => {
    // ここで、wscは接続されたクライアントを示す。
+   const userID = uuidv4(); // IDを生成
+   wsc.userID = userID; // 接続されたクライアント(wsオブジェクト)にぶち込む

+   // UUIDを初期化という名目で送信
+   let users = [];
+   ws.clients.forEach(client => {
+       users.push(client.userID);
+   });
+   wsc.send(JSON.stringify({ type: "init", id: userID, connected: users }));
    
    wsc.on("message", (msg) => {
        // Recieved
        console.log("Recieved: " + msg);
        // 送信されたものを受け取り、msgとして受け取る
        ws.clients.forEach((client) => { // クライアントごとに
            if (client != wsc && client.readyState === WebSocket.OPEN) { // 受信できる状態で、自分自身じゃないなら
                client.send(msg);
            }
        });
    });
});

ここからはどんどんHTML側の実装に入っていきます。
まずはそれぞれのRTCPeerConnectionを管理するものを作ります。
RTCPeerConnectionはローカルマシンリモートマシンをつなぐモノです。
以下のように修正します。

index.html
    const peerConnections = new Map();
    const signalingConnection = new WebSocket("ws://localhost:8081");
    let myID = null;

    // シンナリングサーバーからのメッセージを
    signalingConnection.onmessage = (async (event) => {
        const message = JSON.parse(event.data);
        switch (message.type) {
            case "init":
                myID = message.id;
                console.log("My ID is " + myID);
                message.connected.forEach(async (remoteId) => {
                    if (remoteId != myID) {
                        const peerConnection = new RTCPeerConnection({
                            iceServers: [{urls: "stun:stun.l.google.com:19302"}]
                        });
                        peerConnections.set(remoteId, peerConnection);
                    }
                });
                break;
        }
    });

Offer/Answerを送り、受信する

次はOfferを送ってみましょう。init時に、Offerを送るようにします。

index.html
signalingConnection.onmessage = (async (event) => {
    const message = JSON.parse(event.data);
    switch (message.type) {
        case "init":
            myID = message.id;
            console.log("My ID is " + myID);
            message.connected.forEach(async (remoteId) => {
                if (remoteId != myID) {
                    const peerConnection = new RTCPeerConnection({
                        iceServers: [{urls: "stun:stun.l.google.com:19302"}]
                    });
                    peerConnections.set(remoteId, peerConnection);

                            
                    // Offer作成
+                   const offer = await peerConnection.createOffer();
+                   await peerConnection.setLocalDescription(offer);
+                   signalingConnection.send(JSON.stringify({
+                       type: "offer",
+                       from: myID,
+                       to: remoteId,
+                       sdp: offer.sdp
+                   }));
                }
            });
            break;
    }
});

それぞれについて説明すると、
RTCPeerConnectionはcreateOfferをすることで、自分自身のSDPを作成できます。
それをsetLocalDescription(offer)することで、自分自身のSDPを設定できます。
それをofferという名目で、自分のIDから相手のIDに送ることを自明的に設定して
送信します。

では受信側も作っていきましょう。
Offerを受け取ると、Answerを返すはずです。そこも含めて実装します。
先ほどのSwitch文にcase offerへ加える形で実装します

index.html
case "offer":
    // なんかOfferが知らんところから来たら新たに追加
    if (!peerConnections.has(message.from)) {
        const peerConnection = new RTCPeerConnection({
            iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
        });
        peerConnections.set(message.from, peerConnection)
    }
    // Remote側のSDPを設定する
    const pc = peerConnections.get(message.from);
    await pc.setRemoteDescription(new RTCSessionDescription({
        type: "offer",
        sdp: message.sdp
    }));
    
    // Answerを作成する
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);

    signalingConnection.send(JSON.stringify({
        type: "answer",
        from: myID,
        to: message.from,
        sdp: answer.sdp
    }));

    break;

それぞれについて説明すると、まず知らないところからOfferが来るときに、新たに
RTCPeerConnectionを追加をします。

次に、Offerはリモート側からくるので、相手側の方のSDPを設定します。
これはsetRemoteDescriptionでできます。またRTCSessionDescription
ちゃんとしたSDPを形式化し、うまい具合に直してくれると思います。
多分。(詳しいことはよく知りません、すみません。)

OfferにはAnswerで返さなくてはなりません。ということで
Answerを作ります。createAnswer()でAnswerが作れ、それを
自分自身のSDPに設定します(Offerで新しく新入りが来たので、自分自身も
設定する必要があります。)

そしてそれをシンナリングサーバーに送るんですね。

では受信もします。

index.html
case "answer":
    if (peerConnections.has(message.from)) {
        const pc = peerConnections.get(message.from);
        await pc.setRemoteDescription(new RTCSessionDescription({
            type: "answer",
            sdp: message.sdp
        }));
        console.log("Answer recieved from " + message.from);
    }
    break;

説明をします。まあまずは、さすがにないと思いますが、万が一
相手が急に切断しちゃったりということも考えて、
あることを確認してから、処理を行うようにハンドリングします。

Answerはリモート側から帰ってくるので、RemoteDescriptionをします。

そしてデバッグ用にこれを置いておくようにします。
とりあえずこれでひと段落着きましたが...

ICEを実装

NAT越え、真のP2Pにたどり着くためにはICEが必要です。ということでくじけずに
実装したいですがもう私はここでくじけました。
しかし寝ているとRTCの神にささやかれたので全力で実装します。
何だよこのくだり。

かなり追加の実装をすることになりますが、その前に準備をします。
関数でいろいろまとめてしまいましょう。共通部分があり、今後の実装で
都合がよくなってくるので...。
まずは、新しいRTCPeerConnectionを作る関数を、
setupPeerConnectionにまとめましょう。

index.html
const peerConnections = new Map();
const signalingConnection = new WebSocket("ws://localhost:8081");
let myID = null;

+ function setupPeerConnection(remoteId) {
+   const peerConnection = new RTCPeerConnection({
+       iceServers: [{urls: "stun:stun.l.google.com:19302"}]
+   });
+   peerConnections.set(remoteId, peerConnection);
+ }

// シンナリングサーバーからのメッセージを
signalingConnection.onmessage = (async (event) => {
    const message = JSON.parse(event.data);
    switch (message.type) {
        case "init":
            myID = message.id;
            console.log("My ID is " + myID);
            message.connected.forEach(async (remoteId) => {
            if (remoteId != myID) {
+               setupPeerConnection(remoteId);
-               const peerConnection = new RTCPeerConnection({
-                   iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
-               });
-               peerConnections.set(message.from, peerConnection)
/////
                    // Offer作成
                    const offer = await peerConnection.createOffer();
                    await peerConnection.setLocalDescription(offer);
                    signalingConnection.send(JSON.stringify({
                        type: "offer",
                        from: myID,
                        to: remoteId,
                        sdp: offer.sdp
                    }));
                }
            });
            break;
        case "offer":
            // なんかOfferが知らんところから来たら新たに追加
            if (!peerConnections.has(message.from)) {
+           setupPeerConnection(message.from);
-               const peerConnection = new RTCPeerConnection({
-                   iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
-               });
-               peerConnections.set(message.from, peerConnection)
            }
            // Remote側のSDPを設定する
            const pc = peerConnections.get(message.from);
            await pc.setRemoteDescription(new RTCSessionDescription({
                type: "offer",
                sdp: message.sdp
            }));
            
            // Answerを作成する
            const answer = await pc.createAnswer();
            await pc.setLocalDescription(answer);

            signalingConnection.send(JSON.stringify({
                type: "answer",
                from: myID,
                to: message.from,
                sdp: answer.sdp
            }));

            break;
        case "answer":
            if (peerConnections.has(message.from)) {
                const pc = peerConnections.get(message.from);
                await pc.setRemoteDescription(new RTCSessionDescription({
                    type: "answer",
                    sdp: message.sdp
                }));
                console.log("Answer recieved from " + message.from);
            }
            break;
    }
});

これで整理が完了しました。

RTCPeerConnectionは内部でネットワーク経路を探します。
onicecandidateイベントでICEを生成します。これをシンナリングサーバーに流し、
受けっとたらaddIceCandidate()に追加します。
ではこれらを踏まえて、setupPeerConnectionにイベントの処理を追加します。

index.html
function setupPeerConnection(remoteId) {
    const peerConnection = new RTCPeerConnection({
        iceServers: [{urls: "stun:stun.l.google.com:19302"}]
    });
    peerConnections.set(remoteId, peerConnection);

+   peerConnection.onicecandidate = (event) => {
+       if (event.candidate) {
+           signalingConnection.send(JSON.stringify({
+               type: "ice",
+               from: myID,
+               to: remoteId,
+               candidate: event.candidate
+           }));
+       }
+   };
+   return peerConnection;
}

そして、switch文にも新たに追加します。

index.html
case "ice":
    if (peerConnections.has(message.from)) {
        const pc = peerConnections.get(message.from);
        await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
        console.log("ICE Candidate recieved");
    }
    break;

最終的なコード

すこし修正を加え、以下のようなコードになりました。

index.html
<!DOCTYPE html>
<html>
    <head>
        <title>WebRTCの実験</title>
        <meta charset="utf-8">
        <style>

        </style>
    </head>
    <body>
        <h1>WebRTCの実験</h1>

        <script>
            const peerConnections = new Map();
            const signalingConnection = new WebSocket("ws://localhost:8081");
            let myID = null;

            function setupPeerConnection(remoteId) {
                const peerConnection = new RTCPeerConnection({
                    iceServers: [{urls: "stun:stun.l.google.com:19302"}]
                });
                peerConnections.set(remoteId, peerConnection);

                peerConnection.onicecandidate = (event) => {
                    if (event.candidate) {
                        signalingConnection.send(JSON.stringify({
                            type: "ice",
                            from: myID,
                            to: remoteId,
                            candidate: event.candidate
                        }));
                    }
                };
                return peerConnection;
            }

            // シンナリングサーバーからのメッセージを
            signalingConnection.onmessage = (async (event) => {
                const message = JSON.parse(event.data);
                switch (message.type) {
                    case "init":
                        myID = message.id;
                        console.log("My ID is " + myID);
                        message.connected.forEach(async (remoteId) => {
                            if (remoteId != myID) {
                                const peerConnection = setupPeerConnection(remoteId);
                                // Offer作成
                                const offer = await peerConnection.createOffer();
                                await peerConnection.setLocalDescription(offer);
                                signalingConnection.send(JSON.stringify({
                                    type: "offer",
                                    from: myID,
                                    to: remoteId,
                                    sdp: offer.sdp
                                }));
                            }
                        });
                        break;
                    case "offer":
                        // なんかOfferが知らんところから来たら新たに追加
                        if (!peerConnections.has(message.from)) {
                            setupPeerConnection(message.from);
                        }
                        // Remote側のSDPを設定する
                        const pc = peerConnections.get(message.from);
                        await pc.setRemoteDescription(new RTCSessionDescription({
                            type: "offer",
                            sdp: message.sdp
                        }));
                        
                        // Answerを作成する
                        const answer = await pc.createAnswer();
                        await pc.setLocalDescription(answer);

                        signalingConnection.send(JSON.stringify({
                            type: "answer",
                            from: myID,
                            to: message.from,
                            sdp: answer.sdp
                        }));

                        break;
                    case "answer":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            await pc.setRemoteDescription(new RTCSessionDescription({
                                type: "answer",
                                sdp: message.sdp
                            }));
                            console.log("Answer recieved from " + message.from);
                        }
                        break;
                    case "ice":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            if (message.candidate) {
                                await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
                                console.log("ICE Candidate recieved");
                            }
                        }
                        break;
                }
            });
        </script>
    </body>
</html>

では、$ node index.jsを実行して、localhost:3000にアクセスしましょう。
DevToolで特にエラーが出てなければ、とりあえずいいでしょう。

チャットを作る

本来WebRTCは画面配信とかできますが、正直私の体力がなくなってきたので、
DataChannelで我慢してください、お願いします...。すみません。

DataChannelを使うことによって、テキストデータ(厳密にはその他も一応できる)を
送受信できます。今回は超簡易的なチャットを作りましょう。
ん..?まって...?

なんかエラー

VM1092:1  Uncaught (in promise) SyntaxError: Unexpected token 'o', "[object Blob]" is not valid JSON
    at JSON.parse (<anonymous>)
    at signalingConnection.onmessage ((インデックス):60:38)
Uncaught (in promise) InvalidStateError: Failed to execute 'setLocalDescription' on 'RTCPeerConnection': Failed to set local answer sdp: Called in wrong state: stable

どうやら順番が違ったらしい。
こちらが修正後のコードです、すみません。

index.html
<!DOCTYPE html>
<html>
    <head>
        <title>WebRTCの実験</title>
        <meta charset="utf-8">
        <style>
        </style>
    </head>
    <body>
        <h1>WebRTCの実験</h1>
        <textarea id="message" rows="4" cols="90" placeholder="Enter message here"></textarea>
        <button id="sendMessage">送信</button>

        <script>
            const peerConnections = new Map();
            const signalingConnection = new WebSocket("ws://localhost:8081");
            let myID = null;

            // ボタン
            document.getElementById("sendMessage").addEventListener("click", () => {
                const message = document.getElementById("message").value;
                if (dataChannel && dataChannel.readyState == "open") {
                    dataChannel.send(message);
                    console.log("sent: " + message);
                }
            })

            function setupPeerConnection(remoteId) {
                const peerConnection = new RTCPeerConnection({
                    iceServers: [{urls: "stun:stun.l.google.com:19302"}]
                });
                peerConnections.set(remoteId, peerConnection);

                peerConnection.onicecandidate = (event) => {
                    if (event.candidate) {
                        signalingConnection.send(JSON.stringify({
                            type: "ice",
                            from: myID,
                            to: remoteId,
                            candidate: event.candidate
                        }));
                    }
                };
                return peerConnection;
            }

            // シンナリングサーバーからのメッセージを
            signalingConnection.onmessage = async (event) => {
                console.log("Received data type:", typeof event.data);
                let messageData;
                if (event.data instanceof Blob) {
                    messageData = await event.data.text();
                } else {
                    messageData = event.data;
                }

                const message = JSON.parse(messageData);
                switch (message.type) {
                    case "init":
                        myID = message.id;
                        console.log("My ID is " + myID);
                        message.connected.forEach(async (remoteId) => {
                            if (remoteId !== myID) {
                                const peerConnection = setupPeerConnection(remoteId);
                                // Offerを作成
                                const offer = await peerConnection.createOffer();
                                await peerConnection.setLocalDescription(offer);
                                signalingConnection.send(JSON.stringify({
                                    type: "offer",
                                    from: myID,
                                    to: remoteId,
                                    sdp: offer.sdp
                                }));
                            }
                        });
                        break;
                    case "offer":
                        if (!peerConnections.has(message.from)) {
                            setupPeerConnection(message.from);
                        }
                        
                        // Remote側のSDPを設定する
                        const pc = peerConnections.get(message.from);
                        await pc.setRemoteDescription(new RTCSessionDescription({
                            type: "offer",
                            sdp: message.sdp
                        }));
                        const answer = await pc.createAnswer();
                        await pc.setLocalDescription(answer);
                        // Answerを作成
                        signalingConnection.send(JSON.stringify({
                            type: "answer",
                            from: myID,
                            to: message.from,
                            sdp: answer.sdp
                        }));

                        
                        
                        break;
                    case "answer":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            await pc.setRemoteDescription(new RTCSessionDescription({
                                type: "answer",
                                sdp: message.sdp
                            }));
                            console.log("Answer received from " + message.from);
                        }
                        break;
                    case "ice":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            if (message.candidate) {
                                await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
                                console.log("ICE Candidate received");
                            }
                        }
                        break;
                }
            };
        </script>
    </body>
</html>

こんどこそチャットを作る

以下のようにDataChannelを開きます。DataChannelはRTCPeerConnectionごとに作るので、
管理してやりましょう。この辺はClaudeにやらせてます。

index.html
<!DOCTYPE html>
<html>
    <head>
        <title>WebRTCの実験</title>
        <meta charset="utf-8">
        <style>
        </style>
    </head>
    <body>
        <h1>WebRTCの実験</h1>
        <textarea id="message" rows="4" cols="90" placeholder="Enter message here"></textarea>
        <button id="sendMessage">送信</button>

        <script>
            const peerConnections = new Map();
+           const dataChannels = new Map();  // 相手ごとのデータチャネルを管理 //
            const signalingConnection = new WebSocket("ws://localhost:8081");
            let myID = null;

            // ボタン
            document.getElementById("sendMessage").addEventListener("click", () => {
                const message = document.getElementById("message").value;
                // 接続している全員にメッセージを送信 // DIFF
+               dataChannels.forEach((channel, peerId) => {
+                   if (channel.readyState === "open") {
+                       channel.send(message);
+                       console.log("sent to " + peerId + ": " + message);
+                   }
+               });
            });

            function setupPeerConnection(remoteId) {
                const peerConnection = new RTCPeerConnection({
                    iceServers: [{urls: "stun:stun.l.google.com:19302"}]
                });
                peerConnections.set(remoteId, peerConnection);

                // 受信側のデータチャネル設定を追加
+               peerConnection.ondatachannel = (event) => {
+                   const channel = event.channel;
+                   channel.onopen = () => {
+                       console.log("Data Channel Opened with " + remoteId);
+                   }
+                   channel.onmessage = (event) => {
+                       console.log("Received from " + remoteId + ": " + event.data);
+                   }
+                   dataChannels.set(remoteId, channel);
+               };
+
+               // 送信側のデータチャネル設定
+               const channel = peerConnection.createDataChannel("chat");
+               channel.onopen = () => {
+                   console.log("Data Channel Opened with " + remoteId);
+               }
+               channel.onmessage = (event) => {
+                   console.log("Received from " + remoteId + ": " + event.data);
+               }
+               dataChannels.set(remoteId, channel);

                peerConnection.onicecandidate = (event) => {
                    if (event.candidate) {
                        signalingConnection.send(JSON.stringify({
                            type: "ice",
                            from: myID,
                            to: remoteId,
                            candidate: event.candidate
                        }));
                    }
                };
                return peerConnection;
            }

            // シンナリングサーバーからのメッセージを
            signalingConnection.onmessage = async (event) => {
                console.log("Received data type:", typeof event.data);
                let messageData;
                if (event.data instanceof Blob) {
                    messageData = await event.data.text();
                } else {
                    messageData = event.data;
                }

                const message = JSON.parse(messageData);
                switch (message.type) {
                    case "init":
                        myID = message.id;
                        console.log("My ID is " + myID);
                        message.connected.forEach(async (remoteId) => {
                            if (remoteId !== myID) {
                                const peerConnection = setupPeerConnection(remoteId);
                                // Offerを作成
                                const offer = await peerConnection.createOffer();
                                await peerConnection.setLocalDescription(offer);
                                signalingConnection.send(JSON.stringify({
                                    type: "offer",
                                    from: myID,
                                    to: remoteId,
                                    sdp: offer.sdp
                                }));
                            }
                        });
                        break;
                    case "offer":
                        if (!peerConnections.has(message.from)) {
                            setupPeerConnection(message.from);
                        }
                        
                        // Remote側のSDPを設定する
                        const pc = peerConnections.get(message.from);
                        await pc.setRemoteDescription(new RTCSessionDescription({
                            type: "offer",
                            sdp: message.sdp
                        }));
                        const answer = await pc.createAnswer();
                        await pc.setLocalDescription(answer);
                        // Answerを作成
                        signalingConnection.send(JSON.stringify({
                            type: "answer",
                            from: myID,
                            to: message.from,
                            sdp: answer.sdp
                        }));

                        
                        
                        break;
                    case "answer":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            await pc.setRemoteDescription(new RTCSessionDescription({
                                type: "answer",
                                sdp: message.sdp
                            }));
                            console.log("Answer received from " + message.from);
                        }
                        break;
                    case "ice":
                        if (peerConnections.has(message.from)) {
                            const pc = peerConnections.get(message.from);
                            if (message.candidate) {
                                await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
                                console.log("ICE Candidate received");
                            }
                        }
                        break;
                }
            };
        </script>
    </body>
</html>

ついに、実行。

以下を実行してください。

node index.js

そしてウィンドウを2つ開いて、デベロッパーツールを開いてください。
プロンプトに入力して送信を押します。
Videotogif (1).gif

まとめ

ここまで長すぎて長かったです。
まあまとめていくんですが、

  • SDP...自己紹介
  • ICE...経路設定
  • WebRTC...P2P、リアルタイムで情報をやり取りできるもの
  • RTCPeerConnection...自分と相手をRTCでつなぐやつ
  • Offer/Answer...SDPを交換する手順
  • シンナリングサーバー...SDP、ICEの交換に使うやつ
  • STUN...グローバルIPを返す
  • TURN...どうしようもないときに使うやつ
  • WebSocket...双方向通信プロコトルで、シンナリングサーバーに向いている
  • DataChannel...データを受け渡しするやつ(適当)

このあたりでしょうか。わからないことはChatGPTに聞けば基本返してくれます!
プログラミングの可能性は無限です!
ここまでよんでくださりありがとうございました!

雑談

TURN今回実装しませんでしたが、なぜかというと

  • 単純に面倒すぎる
  • Linuxでの実装しか乗ってなかった

ことです。しかし1番目はどうにでもなるし、2番目はWSLを使えば何とでもなるようです。
また機会があればしようと思います。

WikiにのっていたWebRTCの画像はこれでした。
WebRTC
リアルタイムって感じがしていい気がします。(?)

2
3
0

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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?