※ このブログはヌーラバー Advent Calendar 2020 12日目の記事です。明日は yuh kim さんの記事です。
こんにちは。本日32歳になりました。Cacoo課の川端(@kwbtsts)です。
Cacooは先日、図の編集画面上でビデオ会議ができる「ビデオ通話」機能をリリースしました!
本記事では、ビデオ通話を実現するために必要なWebRTCという技術について解説したいと思います。ビデオ通話を実際にWebサービスで開発・運用していこうと考えている方や、WebRTCに興味がある方へのヒントになれば幸いです。
ビデオ通話機能についての詳しくはCacooリリースブログ『テレワークで使える!Cacooで「ビデオ通話」ができるようになりました!』をご覧ください。
目次
WebRTC(Web Real-Time Communications)とは?
Cacooのビデオ通話ではWebRTC(Web Real-Time Communications)と呼ばれる技術を使って開発しています。MozillaのWebRTC APIのページでは下記のような説明が書かれています。
ウェブアプリケーションやウェブサイトにて、仲介を必要とせずにブラウザ間で直接、任意のデータの交換や、キャプチャしたオーディオ/ビデオストリームの送受信を可能にする技術です。
ものすごく簡単に説明するとブラウザどうしがサーバを介さず直接通信してビデオ通話できる技術です。
通常、Webページを見る場合はPCやスマホなどの端末(クライアント)がサーバと通信してHTTPリクエストとコンテンツ(HTML、画像など)のやり取りを行います。
WebRTCを使った通信は、端末と端末とが直接通信します。この形の通信をピア・ツー・ピア(Peer to Peer)と呼ばれることもあります。Cacooのビデオ通話はこの通信方法になるのでユーザーのPCどうしが直接繋がっているということになります。
サーバを介さずに直接通信?
通常のWebサイトを見るときのクライアント・サーバ型の通信では、サーバの場所がURLとしてわかっているのでクライアントはURLの場所へと通信を行えば良いです。それに対して、ピア・ツー・ピア型の通信では相手のPCの場所がわかりません。
シグナリング – どこにいるかわからない相手の場所を知る –
WebRTCでは相手の場所を知るためにシグナリングというものが必要になります。自分と相手の両方の場所を知っている第三者が必要になります。その第三者が仲介することで、自分の場所を相手に教えて、そして相手の場所も知ることができます。WebSocketサーバでも良いし、HTTPサーバでポーリングするでも良いですし、とにかく相手に情報を伝えることができれば何でも良いです。
Cacooのエディターでは共同編集のためのWebSocketサーバを使っています。普段はカーソル位置を送ったり、図の編集データを送ったりしています。ビデオ通話ではそれをシグナリングサーバとして使いました。
実際に相手の場所を知るために以下のような情報をやり取りします。
- SDP: 転送方式やコーデックの情報
- ICE: 自分の居場所(IPアドレスやポート番号)
SDP(Session Description Protocol)ネゴシエーション – 転送方式やコーデックの交換 –
通信を行う上で転送方式や映像や音声のコーデックなどの情報を交換する必要があります。自分のPCが使える転送方式やコーデックを相手に伝えて、それを受け取った相手は自分のPCが使えるものを返します。それによってお互いに映像や音声データの再生ができるようになります。
そのような情報を扱うためのプロトコルがSDP(Session Description Protocol)です。RFCの文書には下記のように書かれています。
マルチメディアの電話会議、VoIP (Voice-over-IP)、ストリーミングビデオ、またはその他のセッションを開始するときに、メディアの詳細、トランスポートアドレスなどのセッション記述メタデータを参加者に伝達する必要がある。(中略)SDPは、このような情報の標準的な表記方法を提供する。
ではメタデータとはどういったものでしょうか?以下にSDPのサンプルを示します。
v=0 o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5 s=SDP Seminar i=A Seminar on the session description protocol u=http://www.example.com/seminars/sdp.pdf e=j.doe@example.com (Jane Doe) c=IN IP4 224.2.17.12/127 t=2873397496 2873404696 a=recvonly m=audio 49170 RTP/AVP 0 m=video 51372 RTP/AVP 99 a=rtpmap:99 h263-1998/90000
各プロパティはこのようになっています。これを相手と交換することで、使用可能な転送方式やコーデックで送信することができるようになります。
- v= (プロトコルのバージョン)
- o= (発信元およびセッション識別子)
- s= (セッション名)
- i=* (セッション情報)
- u=* (記述のURI)
- e=* (電子メールアドレス)
- c=* (セッション情報 — すべてのメディアに含まれる場合は必要なし)
- t= (セッションがアクティブな時間)
- a=* (0行以上のメディア属性行)
- m= (メディア名と伝送アドレス)
- a=* (0行以上のセッション属性行)
こちらのサンプルでお使いのブラウザのSDPを見ることができます。
https://codepen.io/kwst/full/yLaaxRy
JSではどうやるの?
実際にJSでSDPの交換をするときのコード例を示します。ブラウザではWebRTCの通信には、RTCPeerConnection APIを使います。
送る側(Offer)
createOfferメソッドで自分のSDP(offer)を生成して、setLocalDescriptionメソッドで自分のRTCPeerConnectionに設定します。offerをWebSocketで相手に送ります。
const peerConnection = new RTCPeerConnection(); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); // offerをWebSocketで送る sendOffer(offer);
受ける側(Answer)
setRemoteDescriptionメソッドで送られてきたSDP(offer)をRTCPeerConnectionに設定して、createAnswerメソッドで返信用のSDP(answer)を生成します。answerをWebSocketで相手に送り返します。
await peerConnection.setRemoteDescription(offer); const answer = await peerConnection.createAnswer(RTC_OFFER_OPTION); // answerをWebSocketで送り返す sendAnswer(answer);
送る側(Answerを受け取る)
Answerを受け取ってsetRemoteDescriptionメソッドで送られてきたSDP(answer)をRTCPeerConnectionに設定します。これでシグナリングが完了します。
await peerConnection.setRemoteDescription(answer);
シグナリングを手動で行うためのサンプルを用意したのでブラウザを2つ開いて、手動でSDPのやりとりを確認することができます。
https://codepen.io/kwst/full/KKggrBB
ICE(Interactive Connectivity Establishment) – 通信経路の情報の交換 –
SDPの交換と同時に自分のネットワーク上の場所を相手に伝える必要があります。この情報は自分のIPアドレスやポート番号です。ICE(Interactive Connectivity Establishment)という仕組みにのっとって情報を伝えます。
ICEでは下記の順序で通信を確立させます。
- 自分の場所の候補(candidate)を収集する
- 自分の場所の候補を相手に送信する
- 相手の候補を受信する
- 通信経路を決定して開通させる
自分の情報は下記のような形で候補(candidate)をいくつか取得できます。
candidate:1 1 UDP 2130706431 10.0.1.1 8998 typ host
Candidateの種類は、host、srflx、relayの3つあります。
- host(Host candidate): 自分のPCのローカルインターフェースのIPアドレス、ポート番号になります。
- srflx(Server reflexive candidate): NATを経由している場合、一番外の外のIPアドレス、ポート番号になります。これは後述するSTUN/TURNサーバで取得できます。
- relay(Relay candidate): 中継するTURNサーバのIPアドレス、ポート番号になります。
こちらのサンプルでお使いのブラウザのICE候補(candidate)を見ることができます。
https://codepen.io/kwst/full/xxEEBvG
ピア・ツー・ピア通信を行う上でNATが壁になってきます。Wifiルーターなどを使っているとプライベートなネットワークとパブリックなネットワークの間にNATが立っています。自分自身はプライベートなネットワークの中での自分の場所(プライベートIPアドレス)しかわかりません。相手(パブリックなネットワーク)から見た自分の場所を相手に伝える必要があります。
それを解決する方法としてSTUN/TURNサーバを使います。
STUN/TURNサーバ – NAT越えのために –
STUN(Settion Traversal Utilities for NAT)サーバから自分が属しているNATの一番外側のIPアドレス、ポート番号を取得します。JSでは、RTCPeerConnectionのコンストラクタの引数に渡してやります。Googleが公開しているSUTNサーバがあったりするのでそれを試しに使うこともできます。
new RTCPeerConnection({"iceServers":[ {"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun1.l.google.com:19302"}, {"urls": "stun:stun2.l.google.com:19302"} ]});
TURN(Traversal Using Relays around NAT)サーバはSTUNを拡張したプロトコルです。PCとPCの間のNATの事情により直接通信することが難しい場合、このTURNサーバを中継してビデオが転送されます。
Cacooではcoturnというオープンソースを使って自前でTURNサーバを構築しています。
JSではどうやるの?
自分のICE Candidateの収集はonicecandidateイベントハンドラで行います。候補が検出されるたびに発火されます。
peerConnection.onicecandidate = (e) => { // WebSocketで送る sendCandidate(e.candidate); };
相手のICE Candidateをシグナリングサーバから受信したら、addIceCandidateメソッドでRTCPeerConnectionに設定します。
peerConnection.addIceCandidate(candidate);
以上で通信が確立します。SDPとICE両方のやり取りを図で示します。通信の接続状況はoniceconnectionstatechangeやonconnectionstatechangeといったイベントハンドラで取得することができます。
ここまでのシグナリングを手動で行うためのサンプルを用意したのでブラウザを2つ開いて、手動でSDPとICEのやりとりを確認することができます。
https://codepen.io/kwst/full/ZEppgvE
シグナリングにより通信が確立できたらビデオを送信する
通信が確立すればいよいよビデオを送ります。Webカメラの映像のMediaStreamはMediaDevices.getUserMediaメソッドで取得できます。
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
映像を送るにはMediaStreamに乗っているMediaStreamTrackをaddTrackメソッドで追加してやります。
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); for (const track of stream.getTracks()) { peerConnection.addTrack(track, stream); }
相手から送られてきた映像を取得するにはRTCPeerConnection.ontrackイベントハンドラを使用します。イベントオブジェクトの中にMediaStreamがあるのでvideo要素のsrcObjectにセットしてあげるとビデオが表示されます。このときvideo要素のplay()を呼ぶ、もしくはautoplay属性をセットして再生してあげないと映像が表示されないです。
peerConnection.ontrack = (e) => { const [stream] = e.streams; videoElement.srcObject = stream; };
さきほどの手動シグナリングのサンプルにビデオ表示を追加しました。2つのブラウザで試してみてください。
https://codepen.io/kwst/full/qBaqRvE
Data Channels – ビデオ以外のカスタムデータの送信 –
映像以外のデータを送りたい場合も出てきます。例えば、ビデオ通話での映像や音声のミュート状態などを送る場合などです。WebRTCにはData Channelsという機能がありカスタムデータ(文字列やBlob、ArrayBufferなど)を送ることができます。
実際にCacooのビデオ通話では映像と音声のミュートにData Channelsを使って状態を送り合っています。
JSではどうやるの?
JSでは、DataChannel APIを使います。createDataChannelメソッドにチャンネル名を渡してDataChannelオブジェクトを生成します。onopenイベントハンドラでチャンネルが開いたことが検知できます。メッセージの送信にはsendメソッド、受信にはonmessageイベントハンドラを使用します。
const dataChannel = peerConnection.createDataChannel("custom-data"); peerConnection.ondatachannel = (e) => { // メッセージの受信 e.channel.onmessage = (evt) => { console.log("on message", evt.data); }; e.channel.onopen = () => { // メッセージの送信 dataChannel.send("open data channel"); }; };
先程のサンプルにメッセージの送受信を追加しました。2つのブラウザで試してみてください。
https://codepen.io/kwst/full/JjRbWMR
WebRTCのセキュリティ – DTLS-SRTPによる暗号化 –
ピア・ツー・ピアの通信のセキュリティについても気になるところです。WebRTCの通信における映像や音声はSRTP(Secure Real-time Transport Protocol)によって暗号化されています。暗号化/復号化の鍵の交換にはDTLS(Datagram Transport Layer Security)を使用しています。DTLSはTLS(Transport Layer Security)をUDPでも使えるようにしたものです。
これらの暗号化の仕組みにより、ビデオ通話を第三者が見るということを防止しています。
最後に
以上がCacooのビデオ通話で使用しているWebRTCの解説になります。
ビデオ通話機能は、たくさんのメンバーが開発に関わってリリースまで来れました。元Cacoo課で現SRE課のスーパーエンジニアnakaharaがサクッと作ってくれたプロトタイプが元になっています。社内でWebRTC勉強会を開いて知識を共有してくれたりと尽力してくれました。また、『WebRTC ブラウザベースのP2P技術』という本も大変参考にさせていただきました(現在は絶版になってしまっていて中古のものしか手に入りません)。
なぜか繋がらなくてデバッグで苦しんだ話などはまたの機会にでも。では、長い記事を読んでくだりありがとうございました。良いお年を。