20201112のNode.jsに関する記事は1件です。

WebRTCを使って複数人がビデオチャットできるデモを作った。

切っ掛け

WebRTCとは、ウェブ・ブラウザーを使ってP2P通信をする規格ですが、WebRTCを使ってデモを作ろうと思ったのは、ブラウザーで楽しめるマルチプレイヤー・オンラインゲームを作成する為、P2P接続について学習したのが切っ掛けでした。しかし、実際に複数人が参加できるビデオチャットのサンプルを探した所、マンツーマンのサンプルはある物も、複数人でのサンプルが、あまり無かったので何かそれっぽい物を作りました。

本記事で自分が学んだ事とWebRTCの実装方法などについて触れて行きたいと思います。

サンプル・コード

https://github.com/h-nasu/webrtc-multi-example

クローン後、、、

npm install
node index.js

localhost:8080にアクセスすればサンプルが見れます。
ページ内に、
Room URL: http://localhost:8080/#f50fb77d54324
と言う形で、勝手にハッシュタグにランダムな文字列があるURL出ます。
これを別のブラウザにコピペすれば同じ部屋での接続が可能になります。
準備がてきたら「start」の後、「call」ボタンを押す形です。

ブラウザでP2P接続の流れ

接続方法がHTTPリクエストやソケット接続と違い、接続用のセッション情報を作成して、それを送信したりなど、ブラウザ同士でP2Pを行うまで一連の流れを理解するのに少々苦労しました。
これを解りやすい図で表すと、、、
Screen Shot 2020-10-28 at 14.25.38.png
↑このような形になりますが、ここにあります「STUN」や「TURN」サーバーについては、下記のサイトにあるリストから選び、それをペアーオブジェクト作成時に設定します。
https://gist.github.com/zziuni/3741933

let peerConn = new RTCPeerConnection({
  'iceServers': [{
    'urls': 'stun:stun.l.google.com:19302'
  }]
})

ペアーオブジェクトを作成した後は、ICE Candidate(P2P接続情報)を「STUN」から受信した後の処理を用意しておきます。

peerConn.onicecandidate = function(event) {
  console.log('icecandidate event:', event)
  if (event.candidate) {
    sendCandidate({
      type: 'candidate',
      label: event.candidate.sdpMLineIndex,
      id: event.candidate.sdpMid,
      candidate: event.candidate.candidate
    })
  } else {
    console.log('End of candidates.')
  }
}

※sendCandidateは自作の関数です。

ICE情報を受信したらそれを、sendCandidateで接続したいリモート宛に送信し、リモート側はICEをペアーオブジェクトに登録します。

// messageはリモートに送られて来た情報
peerConn.addIceCandidate(new RTCIceCandidate({
  sdpMLineIndex: message.label,
  candidate: message.candidate
}))

※あるチュートリアルでは、sdpMLineIndexは入れなくても大丈夫のような事を言われていますが、実際無いと起動しなかったりしたので一応入れておいた方が無難です。

これで接続に必要な初期設定は出来ました。後は、接続する時のOfferとAnswer情報の作成と送受信になります。

まずは、接続を依頼する側(Aさん)からOfferを作成して、生成されたDescription(詳細情報)をsendMessageOfferで受信側(Bさん)に送ります。

// ローカルから送信
peerConn.createOffer(function (desc) {
  console.log('local session created:', desc)
  peerConn.setLocalDescription(desc, function() {
    console.log('sending local desc:', peerConn.localDescription)
    sendMessageOffer(peerConn.localDescription)
  }, logError)
}, logError)

※後の図でも入れ忘れたのですが、createOfferでDescriptionを作成した後、それを必ずsetLocalDescriptionに登録してください。createOfferの段階で勝手に登録されれば良いと思うのですが、構造上、後で受信側(Bさん)のDescriptionをsetRemoteDescriptionで登録しますので、送信側(Aさん)も自分の情報を似たような形で登録するようにしていると思われます。

※sendMessageOfferは自作の関数です。

受信側は、送られて来たDescription(コード内ではmessage)をsetRemoteDescriptionでペアーオブジェクトに登録します。
今度は、createAnswerでAnswerを作成して、同じく生成されたDescriptionを元の送信者にsendAnswerで返信します。

// リモートが受信
console.log('Got offer. Sending answer to peer.')
peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, logError)

peerConn.createAnswer(function (desc) {
  console.log('local session created:', desc)
  peerConn.setLocalDescription(desc, function() {
    console.log('sending local desc:', peerConn.localDescription)
    sendAnswer(peerConn.localDescription)
  }, logError)
}, logError)

※sendAnswerは自作の関数です。

後は、送信者がAnswerのDescriptionを登録して完了になります。

// ローカルが受信
console.log('Got answer.')
peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, logError)

ビデオ・ストリームの追加

ネット上を検索すればビデオ・ストリームのやり方は、沢山あるとは思いますが、ここでは本当に簡単な説明だけにします。

まずは、ローカル・ビデオの取得はnavigator.mediaDevices.getUserMediaでビデオを取得しそれをペアーオブジェクトに登録します。

// ローカル・ビデオ
let localStream
navigator.mediaDevices.getUserMedia({
  video: true,
}).then(mediaStream => {
  localStream = mediaStream
})

// ペアーオブジェクトにローカル・ビデオを追加
peerConn.addStream(localStream)

そして、リモート・ビデオはリスナーから登録します。

// リモート・ビデオ
let remoteStream

// ペアーオブジェクトにリモート・ビデオを追加
peerConn.addEventListener('addstream', event => {
  remoteStream = event.stream
})

はい、これでビデオ・ストリームの登録は完了です。
P2P接続時に送信側(Aさん)と受信側(Bさん)共に同じ形でビデオ・ストリームをペアーオブジェクトに登録します。

実際の使用例としては、リモート・ビデオの表示などありますが、詳細については、gitのコード見て貰えればです。
下記の関数をコールバックとして登録しております。

// 受信したビデオの処理
const remoteVideos = document.getElementById('remoteVideos')

// Handles remote MediaStream success by adding it as the remoteVideo src.
function gotRemoteMediaStream(event) {
  const video = document.createElement("video")
  const autoplay = document.createAttribute("autoplay")
  video.setAttributeNode(autoplay)
  const playsinline = document.createAttribute("playsinline")
  video.setAttributeNode(playsinline)

  const mediaStream = event.stream
  video.srcObject = mediaStream
  remoteVideos.appendChild(video)
  trace('Remote peer connection received remote stream.')
}

データ・チャンネルからメッセージを送受信

チャットメッセージなどデータを送信するのには、データ・チャンネルを作成する必要があります。
ペアー接続で送信側(Offer)がチャンネルを作成し、受信側(Answer)は作成されたチャンネルを登録します。
送信側はペアーオブジェクトからcreateDataChannelでデータ・チャンネルオブジェクトを作成します。

// ローカルでデータ・チャンネルを作成
let dataChannel = peerConn.createDataChannel('message')

そして、受信側はペアー接続が完了した後、作成されたデータ・チャンネルを受信しondatachannelから登録します。

// リモートでデータ・チャンネルを受信
let dataChannel
peerConn.ondatachannel = (event) => {
  dataChannel = event.channel
}

両側でメッセージを受信した時の処理をonmessageに登録しておきます。

let msg
dataChannel.onmessage = (event) => {
  msg = JSON.parse(event.data)
}

※JSONデータの送受信になりますが、受信メッセージは文字列になる為、JSON形式に変換します。

データ・チャンネルの作成は、ペアーオブジェクト作成後でOffer作成前に作成する必要があります。

// ローカルでデータ・チャンネルを作成
let peerConn = new RTCPeerConnection(config)
let dataChannel = peerConn.createDataChannel('message')
let msg
dataChannel.onmessage = (event) => {
  msg = JSON.parse(event.data)
}
peerConn.createOffer(function (desc) {
...

受信側も同じ流れでデータ・チャンネルの受信を登録しておきます。

// リモートでデータ・チャンネルを受信
let peerConn = new RTCPeerConnection(config)
let dataChannel
peerConn.ondatachannel = (event) => {
  dataChannel = event.channel
}
let msg
dataChannel.onmessage = (event) => {
  msg = JSON.parse(event.data)
}
...
// Offer受信後、Answerを作成
peerConn.createAnswer(function (desc) {
...

これでペアー接続後はデータの送受信が可能になります。
データ送信はデータ・チャンネルオブジェクトのsend関数で送信します。

const msg = {
  name: 'Michael',
  message: 'FOOOOOW!'
}
dataChannel.send(JSON.stringify(msg))

以上がWebRTCで必要な接続とデータのやり取りになります。今までの説明からローカル側とリモート側を一つのサンプルコードに実装して、一つのプラウザで確認する事も可能です。上記例ではペアーオブジェクトをpeerConnとしておりますが、一つのプラウザで試す場合は、peerConnLocalとpeerConnRemoteなど作成しローカル側とリモート側を一つのブラウザで再現する形になります。

次は実際に接続する人が別サーバーや違うブラウザの場合に必要なシグナル・チャンネル(中継サーバー)について説明します。

P2P接続に必要なシグナル・チャンネル(中継サーバー)

2人の間でWebRTCを使ってP2P接続をするには、Offer Answer Candidateの情報を送受信するシグナル・チャンネル(中継サーバー)が必要になります。WebRTCの接続方法上、相手からのOfferを待つ必要がある為、これに適した通信方法としては随時接続しサーバーからの応答を待つウェブ・ソケットが挙げられます。サンプル・コードではNodejsのSocket.ioを使っています。
シグナル・チャンネルを使用した接続の流れとしては、、、

  1. ローカル側が部屋を作成する。
  2. リモート側が部屋へ参加する。
  3. シグナル・チャンネルが既に接続されている全てのメンバーに対して新しいリモートが参加した事を通知する。
  4. 全てのメンバーがOfferを新規参加者に送信する。
  5. 新規参加者は全てのメンバーにAnswerを送信する。
  6. 全てのメンバーがICEを新規参加者に送信する。
  7. 新規参加者は全てのメンバーにICEを送信する。

Nodejs側で部屋の登録ができるシグナル・チャンネルの用意をしておきます。
サーバー側でindex.jsにSocket.ioから部屋を作成します。

index.js
// 中継サーバーで部屋を登録
socket.on('create or join', function(room) {
    log('Received request to create or join room ' + room);

    var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
    log('Room ' + room + ' now has ' + numClients + ' client(s)');

   // 部屋に誰もいない場合は、新規に作成 
   if (numClients === 0) {
      socket.join(room);
      log('Client ID ' + socket.id + ' created room ' + room);
      socket.emit('created', room, socket.id);
    // 既に部屋に誰かいる場合は、参加させる 
    } else {
      log('Client ID ' + socket.id + ' joined room ' + room);
      socket.join(room);

      // 新規参加者に既存メンバーの情報を送信
      socket.emit('joined', room, socket.id, Object.keys(clientsInRoom.sockets));

      // 既存メンバーに新規参加者の情報を送信
      socket.to(room).emit('ready', socket.id);

    }
  });

ローカルでmain.jsを用意します。部屋IDを作成しサーバー側へ登録します。

main.js
// ローカルで部屋を作成
room = window.location.hash = randomToken()
socket.emit('create or join', room)

新規参加者が部屋に登録した後は、、、

main.js
// 新規参加者が部屋に参加した後の処理
socket.on('joined', function(room, clientId, socketIds) {
  console.log('This peer has joined room with client ID', clientId)
  socketIds.forEach((socketId) => {
    if (socket.id == socketId) return
    // ここで各メンバーに対してペアーオブジェクトを作成する。
  });
});

また、既存メンバーでは、、、

main.js
// 既存メンバーが新規参加者の情報を受信した後の処理
socket.on('ready', function(socketId) {
  console.log('Socket is ready')
  // ここで新規参加者のペアーオブジェクトを作成する。
});

これで、シグナル・チャンネルでのデータ送受信の準備ができました。
新規参加者は次の既存メンバーになる流れになります。
こちらはSocket.ioでの例になりますが、部屋の作成などはサーバー側で使うモジュールにもよります。

複数のペアー接続をさせるには、

今まで説明しましたWebRTCの接続、ビデオ・ストリーム、データ・チャンネル、とシグナル・チャンネルの流れを図にしますと下記になります。

Screen Shot 2020-11-05 at 12.28.21.png

さてこれでWebRTCでのP2P接続の基本ができた所で最後に、どのようにして複数人のデータを送受信するかなのですが、今までに使ったペアーオブジェクトを接続した人数分、作れば良い事です。
今までですと、、、

let peerConn = new RTCPeerConnection(config)
let dataChannel = peerConn.createDataChannel('message')

このように、ペアーオブジェクトは一つでしたが、ソケットから受信した相手側のソケットIDをキーにしてペアーオブジェクトを複数格納する形です。

// 全てのペアーを格納
let peerConns = {}
// 全てのデータ・チャンネルを格納
let dataChannels = {}

...
// 既存メンバーが新規参加者の情報を受信した後の処理
socket.on('ready', function(socketId) {
  console.log('Socket is ready')
  // ここで新規参加者のペアーオブジェクトを作成する。
  peerConns[socketId] = new RTCPeerConnection(config)
  dataChannels[socketId] = peerConn.createDataChannel('message')
});

後は、使いたいペアーオブジェクトをソケットIDで引き出します。

まとめ

以上でWebRTCの基本的な接続方法などをできるだけ簡単に説明したつもりですが、全体を理解するには、今まで説明した知識を元にサンプルコードを追って頂ければと思います。

WebRTCのチュートリアルはウェブを検索すればありますが、シグナル・チャンネルからの接続方法や複数ペアーの持ち方などは、具体例があまり見当たらなかったので、本記事とサンプルコードが参考になれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む