RTCPeerConnectionを使ってビデオチャットができるまでの流れをまとめてみた

KV画像

コロナウィルスの影響でリモートで仕事する機会が増えた今日この頃。
皆様もビデオチャットを活用する機会が多くなったのではないでしょうか?
そのビデオチャットを実現する方法の一つに、P2P接続を行うRTCPeerConnectionという技術があります。今回はブラウザ間P2P接続の手順を追うことで、どのようにして通信を確立しているのか可視化できるサンプルアプリ を作成してみました。

  1. 事前知識:RTCPeerConnectionとは?
    1. SDPとは?
    2. ICE Candidatesとは?
  2. ブラウザ間でRTCPeerConnectionを確立してみる
    1. ステップ1: <Sender>でSDPとICE Candidatesを作成する
    2. ステップ2: <Receiver>でRTCPeerConnectionを初期化する
    3. ステップ3: <Sender>のSDPをOfferとして<Receiver>に渡す
    4. ステップ4: <Receiver>が<Sender>のOfferを元にSDPとICE Candidatesを作成する
    5. ステップ5: <Receiver>のSDPをAnswerとして<Sender>に渡す
    6. ステップ6: <Sender>のICE Candidatesを<Receiver>に渡す
    7. ステップ7: <Receiver>のICE Candidatesを<Sender>に渡す
  3. まとめ

事前知識:RTCPeerConnectionとは?

サンプルアプリでは、主にRTCPeerConnectionの通信確立の流れを追っています。

RTCPeerConnection (Real-Time-Communication Peer Connection) は、ブラウザ上でPeer to Peer(P2P)通信を可能にする為のAPIです。これはWebRTCにも使われているAPIで、サーバを経由せず、ブラウザ間で音声や動画、テキストデータを送受信できるようになります。


通信を確立させるために必要なデータは2つあります。

・SDP(Session Description Protocol)

・ICE Candidates(Interactive Connectivity Establishment Candidates)

SDPとは?

SDPとは、リアルタイム通信の進め方を記述するための規格です。
この規格内には、

・自分のIPアドレス
・SCTPのポート番号を送受信できる最大のサイズ数

などが含まれています。
RTCPeerConnectionでは、このSDPに沿って記述されたプロトコルをクライアント間で交換し、どのような情報を流すか確定させます。

ICE Candidatesとは?

ICE Candidatesを日本語に直訳すると「対話接続確立の候補一覧」となり、
転じてP2P通信できる通信経路の候補一覧となります。

ここでは深く触れませんが、それぞれ違うNATで囲まれたクライアント間でP2P通信を確立する為にはNAT越えが必要になります。そこで登場するのがSTUNサーバやTURNサーバ、ICEサーバと呼ばれるもので、これらのサーバを使用することで、NAT越えした通信経路、つまりICE Candidatesを算出できます。

このICE Candidatesをクライアント間で交換をして、RTCPeerConnectionに登録することで、お互いの通信候補から通信経路が決定して、通信が始まります。

ブラウザ間でRTCPeerConnectionを確立してみる

それでは実際にサンプルアプリを動かしながら、SDP、ICE Candidatesが渡される様子を手動で確認し、通信を確立させてみましょう。

まず、ブラウザを2つ立ち上げて SenderReceiver を表示してください。

以下の画面が表示されていれば準備完了です。

Sender側ではカメラの許可を承認してください。

Sender&amp;Receiver_before

ステップ1: <Sender>でSDPとICE Candidatesを作成する

<Sender>のブラウザが起動された時、<Receiver>に渡すためのSDPとICE Candidatesを用意する必要があります。

// Sender.vue

// RTCPeerConnectionのインスタンスを作成する。
async connectPeers() {
      // RTCPeerConnectionのコンフィグを作成。
      // 今回はビデオ通信を行うため、
      // offerToReceiveVideo/Audioの設定を明記する
      // 同NAT内のブラウザ同士で接続するなら、iceServersの記述は不要
      const config = {
        offerToReceiveAudio: 1,
        offerToReceiveVideo: 0,
        iceServers: [{
          urls: "stun:stun.l.google.com:19302"
        }]
      }
      
      // configを元にRTCPeerConnectionを初期化
      this.connection = new RTCPeerConnection(config)
      
      // データチャンネルの生成とイベントハンドラの登録
      // それぞれ、表示用の変数に追加、代入する
      this.channel = this.connection.createDataChannel("channel")
      this.channel.onmessage = e => {this.receivedMessages.push(e.data)}
      this.channel.onopen = e => { this.channelOpen = true }
      this.channel.onclose = e => { this.channelOpen = false }
      
      // ICE Candidatesが生成された時発火するイベントハンドラ
      // setLocalDescription(sdp)が呼ばれるとICE Candidatesの生成が裏で行われて発火する
      // Receiver側にICE Candidatesの情報を渡す必要があるので
      // this.candidates 変数にpushしてテキストボックスに表示する
      this.connection.onicecandidate = e => {
        if (e.candidate) {
          this.candidates.push(e.candidate)
        }
      }

      // ユーザがビデオを許可している時のみ、MediaStreamTrackを登録する
      // Sender側の画面でもビデオを使っている事が見えるようにする必要があるので
      // this.localStream 変数にメディアストリームを代入
      try {
        this.localStream = await navigator.mediaDevices.getUserMedia({audio: false, video: true})
        this.localStream.getTracks().forEach(track => this.connection.addTrack(track, this.localStream))
        this.useMedia = true
      } catch {
        this.localStream = undefined
      }

      // config、データチャンネル、メディアストリーム情報を元にしたSDPを作成し、自身のSDPとして登録。
      // 裏でICE Candidatesが作成されるので、自身の onicecandidate が発火される
      this.connection.createOffer().then(offerSDP =>  {
        this.connection.setLocalDescription(offerSDP)        
        // 作成したSDPはReceiver側に渡す必要があるので this.offer 変数に代入
        this.offer = offerSDP
      })
      console.log('接続準備完了')
    },

<Sender>でブラウザ側の表示に合わせて`connectPeers()`を発火させます。
やっていることは大まかに4つで、

1. `new RTCPeerConndection()`で初期化
2. データチャンネルの作成とイベントハンドラの登録
3. ICE Candidatesが登録された時のイベントハンドラの登録
3. メディアストリームトラックの登録
4. `createOffer()`でSDPの作成をして、自分のSDPだと登録しICE Candidatesを作成

です。

ステップ2: <Receiver>でRTCPeerConnectionを初期化する

<Receiver>のブラウザが起動された時、<Receiver>に渡す為のICE Candidatesを用意します。

//Receiver.vue

//RTCPeerConnectionのインスタンスを作成する。
connectPeers() {
      //RTCPeerConnectionのコンフィグを作成。
      //offerToReceiveVideo/Audioの設定を明記する
      //同NAT内のブラウザ同士で接続するなら、iceServersの記述は不要
      const config = {
        offerToReceiveAudio: 1,
        offerToReceiveVideo: 0,
        iceServers: [
          {
            urls: this.iceServer
          }
        ]
      };
      
      //configを元にRTCPeerConnectionを初期化
      this.connection = new RTCPeerConnection(config);
      
      //Sender側からデータチャネルを受け取った時発火するイベントハンドラ
      this.connection.ondatachannel = this.receiveChannelCallback;
      //Sender側のメディアストリームを受け取った時発火するイベントハンドラ
      this.connection.ontrack = this.handleconnectionTrack;
      
      //ICE Candidatesが生成された時発火するイベントハンドラ
      //setLocalDescription(sdp)が呼ばれるとICE Candidatesの生成が裏で行われて発火する
      //Sender側にICE Candidatesの情報を渡す必要があるので
      //this.candidates変数にpushしてテキストボックスに表示する
      this.connection.onicecandidate = e => {
        if (e.candidate) {
          this.candidates.push(e.candidate);
        }
      };

      console.log("onconnect");
    }
//Receiver.vue
receiveChannelCallback(e) {
  //データチャンネルの生成とイベントハンドラの登録
  this.channel = e.channel;
  this.channel.onmessage = this.handleMessage;
  this.channel.onopen = this.handlechannelStatusChange;
  this.channel.onclose = this.handlechannelStatusChange;
},
handleconnectionTrack(e) {
  //Sender側から来たメディアストリームをthis.mediaStreamに代入
  this.mediaStream = e.streams[0];
}

Receiver側でブラウザ側の表示に合わせて`connectPeers()`を発火させます。
やっていることは大まかに3つで、

1. `new RTCPeerConndection()`で初期化
2. イベントハンドラの登録
3. ICE Candidatesが登録された時のイベントハンドラの登録

です。

ステップ3: <Sender>のSDPをOfferとして<Receiver>に渡す

<Sender>Offerのテキストエリアをコピーし、<Receiver>Paste offerにペーストすることで<Receiver>は<Sender>のオファーを受け取ります。

ステップ4: <Receiver>が<Sender>のOfferを元にSDPとICE Candidatesを作成する

//Receiver.vue
watch: {
    async offerStr(offer) {
      //オファーを受け取ったらそれをリモートのSDPとして登録
      await this.connection.setRemoteDescription(JSON.parse(offer));
      //config,データチャネル、メディアストリーム情報を元にしたSDPを作成し、自身のSDPとして登録
      //裏でICE Candidatesが作成されるので、自身のonicecandidateが発火される
      this.answer = await this.connection.createAnswer();
      this.connection.setLocalDescription(this.answer);
    }, 
  }

<Receiver>は<Sender>のオファーを受け取りリモートSDPとして登録します。
その後、`createAnswer()`でSDPの作成をおこない、自分のSDPとして登録してICE Candidatesを作成します。

ステップ5: <Receiver>のSDPをAnswerとして<Sender>に渡す

<Receiver>Answerのテキストエリアをコピーし、<Sender>Paste Answerにペーストすることで<Sender>は<Receiver>のアンサーを受け取ります。

//Sender.vue
watch: {
    answerStr(answer) {
      //アンサーを受け取ったらそれをリモートのSDPとして登録
      this.connection.setRemoteDescription(JSON.parse(answer))
    },
  }

<Sender>は<Receiver>のアンサーを受け取りリモートSDPとして登録します。

ステップ6: <Sender>のICE Candidatesを<Receiver>に渡す

<Sender>Sender Candidatesのテキストエリアをコピーし、<Receiver> Paste sender candidatesにペーストすることで<Receiver>は<Sender>のICE Candidatesを受け取ります。

//Receiver.vue
watch: {
    receiverCandidatesStr(str) {
      //ICE Candidatesを受け取る
      const candidates = JSON.parse(str);
      //それぞれのCandidateをブラウザのICEエージェントに渡す
      candidates.forEach(candidate => {
        console.log("Receiver adding candidate", candidate);
        this.connection.addIceCandidate(candidate).catch(e => {
          console.eror("Receiver addIceCandidate error", e);
        });
      });
    }
  }

<Receiver>は受け取ったそれぞれのCandidateをブラウザのICEエージェントに渡します。
ICEエージェントは受け取ったcandidateの接続性チェックなどを行い、有効な通信経路を決定します。通信経路が確立すると<Receiver>に<Sender>のカメラ映像が流れ始めます。

ステップ7: <Receiver>のICE Candidatesを<Sender>に渡す

<Receiver>Receiver Candidatesのテキストエリアをコピーし、<Sender>Paste receiver candidatesにペーストすることで<Sender>は<Receiver>のICE Candidatesを受け取ります。

//Sender.vue
watch: {
    receiverCandidatesStr(str) {
      //ICE Candidatesを受け取る
      const candidates = JSON.parse(str)
      //それぞれのCandidateをブラウザのICEエージェントに渡す。
      candidates.forEach(candidate => {
        console.log('Sender adding candidate', candidate)
        this.connection.addIceCandidate(candidate).catch((e) => {
          console.eror('Sender addIceCandidate error', e)
        })
      })
    },
  }

ステップ6で行っていることと同様です。

Sender&amp;Receiver

ビデオチャットがブラウザ間でできていれば成功です。

まとめ

今回はRTCPeerConnectionを使ってブラウザ上でP2P通信を行うための接続手順を見ていきました。皆さんもビデオチャットを作ってみようと思ったときはぜひ、RTCPeerConnectionを使ってみてください!

最後に、このブログ作成に携わっていただきましたエンジニア、人事、デザイナーのみなさん 、本当にありがとうございました。

Techブログ 新着記事一覧