It’s now or never

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

【WebRTC学習】③ シグナリングサーバを使ってP2Pを接続する

概要

inon29.hateblo.jp

前回は、WebRTCのAPIをつかったP2P接続の導入として、1台のPC上(1画面上)で擬似的にRTCPeerConnectionによる接続を試してみました。

今回は、同じローカルネットワークで、別々の端末でP2Pの接続ができるような実装を試してみます。 (STUNサーバを建てないため、NATトラバーサルは行いません)

Peer同士の接続の開始には、WebSocketによるシグナリングサーバを建てることで2台の接続を確立します。

シグナリングの流れ

f:id:inon29:20200203210346p:plain

RTCPeerConnection APIを使用したシグナリングの流れは、上記になります。

前回のサンプルでは、シグナリングサーバを実装していなかったため、1画面での擬似的な接続を試しました。(本来シグナリングサーバ経由で渡すSDPやICE Candidateを直接渡していた)

今回は、ローカルでシグナリングサーバ(WebSocket)を立て、このシーケンス通りに通信してみます。

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

WebRTCにおいてシグナリングサーバの実装は規定されていないため、実装方法は自由に選択できますが、今回はWebSocketを使います。

  • signaling.ts
import WebSocket from 'ws'

const wss = new WebSocket.Server({ port: 5001 })

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: %s', message)
    wss.clients.forEach(function(client) {
      // NOTE: 自分以外の接続にブロードキャスト
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message)
      }
    })
  })
})

Websocketの実装には、ws というシンプルなWebSocket実装のモジュールを使用しています。

今回のシグナリングサーバでは、ルームの管理などは行わず、ある端末から送られてきたメッセージをそのまま別の端末へ流すだけのシンプルな実装になっています。

クライアントの実装

今回の実装では、自動接続などは行わず、offerSDPを送るPeerを明示的に指定してP2Pの接続を開始します。簡単な流れは以下の通りです。

  • ① Peer1の接続を開始する(ブラウザで画面を開く)
  • ② Peer2の接続を開始する(ブラウザで画面を開く)
  • ③ Peer1からofferSDPを送信してP2P接続を開始する
  • ④ Peer2がofferSDPを受け取ったらanswerSDPをPeer1へ送信する
  • ⑤ ICE Candidateを交換する

HTML

<!doctype html>
<html>

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

<body>
    <div>
        <!-- NOTE: 自分の端末のストリームを表示する -->
        <video id="local-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <div>
        <!-- NOTE: 相手端末から送られてきたストリームを表示する -->
        <video id="remote-video" autoplay muted style="width: 160px; height: 120px; border: 1px solid black;"></video>
    </div>

    <button id="btn-setup">① setup</button>
    <button id="btn-send-offer">② send offer</button>
    <button id="btn-hang-up">③ hangup</button>

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

</html>

今回は、自分の端末のストリームと相手からのストリームを分けて表示するために videoタグ を2つ用意します。(#local-video、#remote-video)

お互いの接続準備をするための setup ボタン。接続を開始するための send offer ボタン。接続を切るための hangup ボタンの3つを用意しています。

f:id:inon29:20200209120411p:plain

① 接続準備

  • index.ts
// NOTE: 音声と動画を両方送る
const MEDIA_CONFIG = { video: true, audio: true }
// NOTE: 送信側のデバイスから受け取ったStreamを遅れるようにグローバルにセット
let localStream: MediaStream | null = null
let pc: RTCPeerConnection
// NOTE:ページを開いたタイミングでWebSocketは接続しておく
const socket = new WebSocket('ws://localhost:5001')
socket.addEventListener('open', function() {
  console.log('Websocket open success')
})

/**
 * 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 showLocalVideo(stream: MediaStream) {
  const video = document.getElementById('local-video') as HTMLVideoElement
  video.srcObject = stream
}

/**
 * シグナリングサーバ(WebSocket)の初期化
 */
function initializeSignalingServer() {
  // NOTE: サーバーからデータを受け取るリスナーのセット
  socket.addEventListener('message', function(e) {
    console.log('on socket message.', e.data)
    onMessage(e)
  })
}

/**
 * Peerのセットアップ
 */
async function handleSetup() {
  // 1. UserMediaにアクセスして音声と画像ストリームを取得
  const stream = await setupUserMedia()
  if (!stream) {
    console.error('Get Stream fail.')
    return
  }
  localStream = stream
  // 2. LocalのVideoタグにStreamを渡して再生
  showLocalVideo(stream)
  // 3. PeerConnectionを作成
  const pcConfig = { iceServers: [] }
  pc = new RTCPeerConnection(pcConfig)
  // 4. videoとaudioのTrackをpeerにセット
  const videoTracks = localStream.getVideoTracks()
  const audioTracks = localStream.getAudioTracks()
  if (videoTracks.length > 0) {
    console.log(`Using video device: ${videoTracks[0].label}`)
  }
  if (audioTracks.length > 0) {
    console.log(`Using audio device: ${audioTracks[0].label}`)
  }
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream as MediaStream)
  })
  // 5. Signalingサーバを初期化し、接続を受けれるようにする
  initializeSignalingServer()
  // 6. P2P接続後に受け取るTrackのコールバックをセット
  pc.addEventListener('track', onTrackEvent)
}

まずは、P2Pの準備の処理(setupボタンを押下時の処理)です。

ここでは、

  • 端末からのストリームの取得
  • RTCPeerConnectionの生成
  • シグナリングサーバの初期化
  • P2Pトラックのコールバックの初期化

などを行います。

なお、この実装では予めページを開いたタイミングでWebSocketとのコネクションを作成していますが、このへんは特に自由で大丈夫だと思います。

② offer SDPの送信

/**
 * シグナリングサーバをへ Offer Descriptionを送信する
 * @param description RTCSessionDescriptionInit
 */
function sendOfferSDP(description: RTCSessionDescriptionInit) {
  const data = {
    type: 'offer',
    sdp: description.sdp,
  }
  console.log('sendOfferSDP: ', JSON.stringify(data))
  socket.send(JSON.stringify(data))
}

/**
 * OfferSDPをシグナリングサーバへ送信し接続を開始する
 */
async function handleSendOffer() {
  // 1. OfferSDPの作成
  const offerDescription = await pc.createOffer()
  // 2. ローカルDescriptionのセット
  // NOTE: setLocalDescriptionは接続元(local)の情報をセットするAPI
  // NOTE: addTrack,setLocalDescriptionを実行してはじめてicecandidateの受信が始まる
  await pc.setLocalDescription(offerDescription)
  // 3. シグナリングサーバへOfferを送信
  sendOfferSDP(offerDescription)
  // 4. ICE Candidateを取得
  pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
      console.log('icecandidate: ', e.candidate)
      sendCandidate(e.candidate)
    }
  })
}

次に、通信を開始するPeerがofferSDPを送信する処理(send offerボタンを押下された時の処理)です。

offerSDPは、createOffer()で作成できます。作成したSDPは、setLocalDescription()で自分のconnectionにセット後、シグナリングサーバを使って相手Peerへ送信します。

 const data = {
    type: 'offer',
    sdp: description.sdp,
 }

この時、シグナリングサーバへは上記のフォーマットでjsonを送信しています。typeは、受信メッセージをハンドリングするために定義しているものなので何でも構いませんが、sdpについては受信側で RTCSessionDescription というクラスインスタンスに変換する必要があるため、sdpインスタンスのsdpプロパティ(description.sdp)を渡しています。

ICE candidateを受け取るためのリスナーもこのタイミングでセットしています。

③ Answer SDPの送信

/**
 * シグナリングサーバからの受信処理
 * @param event MessageEvent
 */
function onMessage(event: MessageEvent) {
  const data = JSON.parse(event.data)
  switch (data.type) {
    case 'candidate':
      onCandidate(
        new RTCIceCandidate({ candidate: data.candidate, sdpMLineIndex: data.sdpMLineIndex, sdpMid: data.sdpMid })
      )
      break
    case 'offer':
      onOfferSDP(new RTCSessionDescription({ sdp: data.sdp, type: 'offer' }))
      break
    case 'answer':
      onAnswerSDP(new RTCSessionDescription({ sdp: data.sdp, type: 'answer' }))
      break
  }
}


/**
 * シグナリングサーバをへ Answer Descriptionを送信する
 * @param description RTCSessionDescriptionInit
 */
function sendAnswerSDP(description: RTCSessionDescriptionInit) {
  const data = {
    type: 'answer',
    sdp: description.sdp,
  }
  console.log('sendAnswerSDP: ', JSON.stringify(data))
  socket.send(JSON.stringify(data))
}

/**
 * Answer SDPを生成し、LocalDescriptionにセット後、シグナリングサーバへ送信する
 */
async function handleSendAwnser() {
  const answerDescription = await pc.createAnswer()
  await pc.setLocalDescription(answerDescription)
  sendAnswerSDP(answerDescription)
}

/**
 * シグナリングサーバから ICE Candidateを受信
 * @param candidate RTCIceCandidate
 */
async function onOfferSDP(offer: RTCSessionDescription) {
  console.log('onOfferSDP: ', offer)
  // 1. Remote Descriptionのセット
  pc.setRemoteDescription(offer)
  // 2. ICE Candidate の取得開始
  // NOTE offerをsetRemoteDescriptionするまでICE CandidateをAddできないためこのタイミングで取得する
  pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
      console.log('icecandidate: ', e.candidate)
      sendCandidate(e.candidate)
    }
  })
  // 3. Anser SDP を送り返す
  await handleSendAwnser()
}

Peer2側でPeer1から送られてきたofferSDPを受け取ったときの処理(onOfferSDP)です。

送られたSDPは、setRemoteDescription()でconnectionにセットし、answer SDPをPeer1へ送り返します。

この時、Peer1と同じようにICE candidateを受け取るリスナーをセットするのですが、Peer2がsetRemoteDescription()を実行する前にICE candidateを受け取って追加していまうとエラーが発生するため、setRemoteDescription()の後でリスナーをセットしています。

④ Answer SDPの受信

/**
 * シグナリングサーバからAnswer SDPを受信
 * @param answer RTCSessionDescription
 */
async function onAnswerSDP(answer: RTCSessionDescription) {
  console.log('onAnswerSDP: ', answer)
  pc.setRemoteDescription(answer)
}

Peer1は送り返されたanswer SDPを setRemoteDescription() でセットします。

⑤ ICE candidate の交換/追加

/**
 * シグナリングサーバから ICE Candidateを受信
 * @param candidate RTCIceCandidate
 */
function onCandidate(candidate: RTCIceCandidate) {
  console.log('onCandidate: ', candidate)
  pc.addIceCandidate(candidate)
}

ICE candidateは各Peerで受信したタイミングでSDPと同様にシグナリングサーバ経由で相手のPeerへ送信します。

受信したICE candidateは、 addIceCandidate() でセットします。

動作確認

このサンプルのコードはここに置いてあります。

シグナリングサーバとクライアントを起動し、次の手順を実行します

  • 1.https://localhost:8080をブラウザで開く(peer1)
  • 2.① setup ボタンを押下
  • 3.https://localhost:8080をブラウザの別ダブで開く(peer2)
  • 4.① setup ボタンを押下
  • 5.どちらかの画面で ② send offer ボタンを押下

2つのvideoタグに動画が表示されていれば、正しく通信が行われています。

まとめ

今回は、WebSocketを使ったシグナリングサーバの最低限の実装を試してみました。これで基本的な接続の実装はできたのでSTUNサーバを使えば、別のネットワークでも同じように接続できるようになります。次回はそのへんを実装してみたいと思います。

関連記事

inon29.hateblo.jp

inon29.hateblo.jp