It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【WebRTC学習】② ローカルPCだけでP2Pの接続を実装する

概要

inon29.hateblo.jp

前回は、WebRTCの導入してブラウザからPCのカメラとマイクを起動するサンプルを実装してみました。

今回は、1台のローカルPCでP2Pの通信をつなげるサンプルを実装してみます。

この記事の目標

  • 1つの画面上で、P2Pのコネクションを繋げて片方のpeerから送信した動画と音声をもう片方のpeerで受け取り、htmlの <video> タグに表示する

WebRTCにおけるシグナリング

WebRTCで双方の端末をつなげるためには、RTCPeerConnection APIを使用します。

RTCPeerConnection APIは、簡単に記載すると双方の端末の接続の準備や管理を行うためのAPIです。

RTCPeerConnectionでは、双方の接続にSDP(Session Description Protocol)を使用します。これはお互いにやり取りするメディアのタイプやプロトコルの情報を扱うテキストベースのプロトコルです。

このSDPを使って、お互いに通信をするための準備を行います。

このSDPの記述をお互いに送り合い通信を開始するために、シグナリングサーバ を用意する必要があります。

WebRTCにおいてはこのシグナリングサーバの実装は意図的に提供していないため、独自で用意する必要があります。(今回のサンプルでは、ローカルPC1台で擬似的に接続を行うため、シグナリングサーバは使いません。)

次の図は、シグナリングサーバを使った、シグナリングのシーケンスです。

f:id:inon29:20200203210346p:plain

  • 1.まず接続するPeerが、createOffer() を使ってSDPを作成します。
  • 2.作成したSDPは setLocalDescription() で自分のPeerにセットしておきます。
  • 3.これをシグナリングサーバを経由して、Peer2へ渡します。
  • 4.offer SDPを受け取ったPeer2は、setRemoteDescription() で自分のPeerに送信元の情報をセットします
  • 5.送信先のPeerは、送信元へ createAnswer() を使ってSDPを作成します。
    • offer SDP同様Peerに setLocalDescription()でセットする
  • 6.これをシグナリングサーバを経由して、Peer1へ渡します。
  • 7.offer SDPを受け取ったPeer2は、setRemoteDescription() で自分のPeerに送信元の情報をセットします

ここまでで、お互いのSDPの交換が完了します。

しかし、これだけではP2Pの通信は開始しません。P2Pの接続を開始するためには、お互いのネットワーク上にパケットを送信できる必要があります。これにはネットワーク上のファイアウォールやNATを超える必要があり色々と大変です。

この辺を上手く超えるために ICE (Interactive Connectivity Establishment) という仕組みがあります。

これは、簡単に説明すると相手のピアと接続できる可能性がある接続経路(IPアドレスとポート)情報を自動で収集してくれる機能です。

ここで、取得した接続先候補が、上記のシーケンスにある ICE Candidate です。

この ICE Candidate をSDPと同じくシグナリングサーバ経由で相手Peerに送り、addIceCandidate() でお互いに追加することで、P2Pによる通信が開始されます。

※ ICE Candidateを受け取るためのコールバック icecandidate は、setRemoteDescription()で相手のSDPを受け取ると収集が開始されるようです。

実行環境

用意するコード

前回同様、index.htmlとindex.js(ts)を使って動かしてみます。

index.html

<!doctype html>
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>WebRTC Study</title>
</head>

<body>
    <div>
        <!-- 接続元の動画と音声を表示するためのVideoタグ -->
        <video id="local-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <div>
        <!-- 接続先の動画と音声を表示するためのVideoタグ -->
        <video id="remote-video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <!-- 接続を開始するためのボタン -->
    <button id="btn-connect">connect</button>

    <script type='text/javascript' src='./index.js'></script>
</body>

</html>

今回は一つの画面で、接続元と接続先の2つの動画を再生するため、videoタグを2つ用意します。

index.ts

// NOTE: 音声と動画を両方送る
const MEDIA_CONFIG = { video: true, audio: true }

/**
 * UserMediaからストリームを取得する(Video&Audio)
 */
async function setupUserMedia(): Promise<MediaStream | undefined> {
  try {
    const stream = await navigator.mediaDevices.getUserMedia(MEDIA_CONFIG)
    return stream
  } catch (err) {
    console.log('An error occured! ' + err)
  }
}

/**
 * videoタグにStreamを流し表示する
 * @param stream MediaStream
 */
function showVideo(stream: MediaStream) {
  const video = document.getElementById('local-video') as HTMLVideoElement
  video.srcObject = stream
}

/**
 * 受信側がTrackを受け取ったときのコールバック
 * @param e RTCTrackEvent
 */
function onTrackEventForPc2(e: RTCTrackEvent) {
  // NOTE: このサンプルでは受信側の videoタグのstreamと接続して送信側の映像を表示する
  const remoteVideo = document.getElementById('remote-video') as HTMLVideoElement
  if (remoteVideo && remoteVideo.srcObject !== e.streams[0]) {
    remoteVideo.srcObject = e.streams[0]
    console.log('pc2 received remote stream', e.streams[0])
  }
}

/**
 * 取得したメディアデータをPC1からPC2に接続して流す処理
 * 流したデータはリモート用のvideoタグに出力する
 */
async function connectPeer() {
  // 1. Peer1からPeer2へ流すためのStreamを取得カメラ、マイクから取得
  const stream = await setupUserMedia()
  if (stream) {
    showVideo(stream)
  }

  // 2. peer1とpeer2の初期化
  // NOTE: このサンプルではpc1を `送信側` pc2を `受信側` として扱う
  const pcConfig = { iceServers: [] }
  const pc1 = new RTCPeerConnection(pcConfig)
  const pc2 = new RTCPeerConnection(pcConfig)
  // 3. pc2は受信側なので、Streamを受け取るコールバックする
  pc2.addEventListener('track', onTrackEventForPc2)

  // 4. pc1にMediaStreamを接続する
  if (stream) {
    const videoTracks = stream.getVideoTracks()
    const audioTracks = stream.getAudioTracks()
    // NOTE: 接続デバイスを表示する
    if (videoTracks.length > 0) {
      console.log(`Using video device: ${videoTracks[0].label}`)
    }
    if (audioTracks.length > 0) {
      console.log(`Using audio device: ${audioTracks[0].label}`)
    }
    stream.getTracks().forEach(track => {
      pc1.addTrack(track, stream as MediaStream)
    })
  }

  // 5. 接続元のSDPを作成
  const offerDescription = await pc1.createOffer()
  // 6. pc1に offerSDPをセット
  // NOTE: setLocalDescriptionは接続元情報をセットする
  await pc1.setLocalDescription(offerDescription)
  console.log('pc1: setLocalDescription')

  /* NOTE: 本来はofferSDPをシグナリングサーバ経由で pc2 へ送る */

  // 7. pc2に offerSDP をセットする
  await pc2.setRemoteDescription(offerDescription)
  console.log('pc2: setRemoteDescription')

  // 8. pc2は、ICE candidateの取得を開始
  // NOTE: setRemoteDescription が完了すると ICE Candidateを取得できる
  pc2.addEventListener('icecandidate', e => {
    console.log('pc2: icecandidate')
    if (e.candidate) {
      /* NOTE: 本来はIceCandidateをシグナリングサーバ経由で pc1 へ送る */
      // pc2のIceCandidateをセット
      pc1.addIceCandidate(e.candidate)
    }
  })
  console.log(`Answer from pc2:\n${offerDescription.sdp}`)
  // 8. 受信側から送信側へanserSDPを作成する
  const answerDescription = await pc2.createAnswer()
  // 9. pc2にanser SDPをセット
  await pc2.setLocalDescription(answerDescription)
  console.log('pc2: setLocalDescription')

  /* 本来はこの間にanserSDPをシグナリングサーバ経由で PC2->PC1 へ送る処理が入る */

  // 10. 送信側のリモート情報にanser SDPをセット
  await pc1.setRemoteDescription(answerDescription)
  console.log('pc1: setRemoteDescription')
  // 3. ICE Candidateの追加
  // NOTE: このサンプルでは、シグナリングサーバを利用しないため ICE Candidateを自前で追加する
  // 追加しないとお互いの通信先が見つからないためP2Pが開始されない
  // PC1の接続情報をPC2についか(逆も追加)
  pc1.addEventListener('icecandidate', e => {
    console.log('pc1: icecandidate')
    if (e.candidate) {
      pc2.addIceCandidate(e.candidate)
    }
  })

  /* これで双方のP2Pのシグナリング処理が完了し通信が始まる */
}

/* ============= Button Event Handler ============ */
const btnConnect = document.getElementById('btn-connect')
if (btnConnect) {
  btnConnect.addEventListener(
    'click',
    async function(e) {
      connectPeer()
      e.preventDefault()
    },
    false
  )
}

動作確認

f:id:inon29:20200203220239p:plain

connect ボタンを押下後に2つの画面に動画が再生されれば正しく接続出来ています。

今回は、ローカルPCでHTTPSでの接続を確認するために、mkcertというツールを使っています。

READMEにも簡単に記載してありますが、詳しくは当該ツールのgithubを参照ください。

実装コード

ここに置いてあります。

まとめ

今回は、1台のPC(一つのローカルネットワーク)でP2Pの接続を実装してみました。SDPやICE Candidateの交換にネットワークを介していないため問題なく接続できますが、本来であればここには別のネットワークを返すための仕組み(NATトラバーサル)等が必要です。次回はその辺りを触ってみようと思います。

参考記事

今までの関連リンク

inon29.hateblo.jp