HerokuでWebRTCのシグナリングサーバーを作る
WebRTCとは
RTCはReal Time Communicationの略で低レイテンシーでのCommunicationを取るためのAPIである、ブラウザでのビデオチャットシステムなどはこれを用いておりビデオや音声をインターネット越しでやるために欠かせないものである。
WebRTCはP2P(peer-to-peer)通信を用いる、P2Pなので端末同士が直接通信するがその端末を見つけるためにサーバーが必要である。それらの信号を送るサーバーのことをシグナリングサーバーと呼ぶ。
video_chatを作ることを目指して部屋名(roomName)と名前(name)で接続元を判別するようにするがここではシグナリングを主に解説する。
本番用からシグナリング関係だけを抜き出しているので単体で動くことは保証しない。
使うもの
(RTCPeerConnection)
PeerConnection接続するための本体
(RTCSessionDescription)
SDPWebRTCのセッションはSDPという概要を表したプロコトルによって管理される。この情報を2つの端末でやり取りさせることにより接続を確立する。
WebSocket
このセッションの確立するまでの情報のやり取りにはWebSocketを使う。これは(ほぼ)文字列ベースの双方向通信であるがこれはサーバークライアント型でありサーバーでメッセージのやり取りを制御する。
UserMedia
navigator.mediaDvices.getUserMedia
でありデバイスのマイクとカメラ情報を取得するものである。
専らWebRTCで活用されるが専用というわけではなく単体のアプリケーションとしても利用できる。
これはPromiseベースであるが昔はcallback形式のnavigator.getUserMedia
が用いられていたが廃止され現在は非推奨である。
接続の流れ
SDPは自分の情報をローカルに相手の情報をリモート設定することでセッションを確立する。
まずoffer
する側がSDPを発行しそれを自分にセットして相手に送る。
受け取った相手はそれをセットしてSDPをanswer
として返す。
返された側がこのSDPをリモートしてセットすることによりセッションが確立する。
expressでのWebSocket
expressでWebSocketを使う場合、express-wsというライブラリがある。yarn add express-ws
で追加しておき
const express=require('express');
const app=express();
const expressWS=require('express-ws')(app);
とすることでgetやpost等のメソッドと同じようにWebSocketが使える。
ただしコールバック関数の第一引数がWebSocketになっておりそれにonmessageイベントを登録することによりメッセージを受け取った時の処理を登録する。
具体的な実装
WebSocketの接続先(url)は/video_chat/signaling
としてこのSocketではシグナリング関係だけしか流さず、形式はJSONであるとする。(中でJSON.parse
を多用するので間違えると例外を飛ばすので注意)
クライアント側の処理
クライアントは以下のようにした。
async function setSignaling(roomName, name){
const stream= await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
playVideo(document.getElementById('video-self'), stream);
const ws=new WebSocket(location.origin.replace('http', 'ws')+'/video_chat/signaling');
ws.addEventListener('open', ()=>{ ws.send(JSON.stringify({ method: 'open', 'roomName': roomName, 'name': name })); });
ws.addEventListener('message', message=>{
const json=JSON.parse(message.data);
if( json.method==='rtc' ){
if( json.type==='request' ){
const peer=makePeer(ws, roomName, json.from, json.to);
stream.getTracks().forEach(a=>{ peer.addTrack(a, stream); });
const to=json.to, from=json.from;
peer.createOffer().then(description=> peer.setLocalDescription(description)).then(()=>{ console.log(`${json.to}とのローカルSDPをセット`); });
function getAnswer(message){
const json=JSON.parse(message.data);
if( json.method==='rtc' && json.description!=null && json.description.type==='answer' && to===json.to && from===json.from ){
peer.setRemoteDescription(new RTCSessionDescription(json.description)).then(()=>{ console.log(`${json.to}とのリモートSDPをセット`); });
ws.removeEventListener('message', getAnswer);
}
};
ws.addEventListener('message', getAnswer);
}
else if( json.description!=null && json.description.type==='offer' ){
const peer=makePeer(ws, roomName, json.from, json.to);
stream.getTracks().forEach(a=>{ peer.addTrack(a, stream); });
peer.setRemoteDescription(new RTCSessionDescription(json.description))
.then(()=> peer.createAnswer())
.then(description=> peer.setLocalDescription(description))
.then(()=>{ console.log(`${json.from}とのリモートとローカルSDPをセット`); });
}
}
});
}
function makePeer(socket, roomName, from, to){
const peer=new RTCPeerConnection();
peer.onicecandidate=event=>{
if( event.candidate==null ) socket.send(JSON.stringify({ method: 'rtc', description: peer.localDescription, "roomName": roomName, "from": from, "to": to}));
}
peer.ontrack=(event)=>{
// video要素を作って写す処理
}
return peer;
}
navigator.mediaDevices.getUserMedia({ video: true, audio: true });
はプロミス形式なのでawaitを使うことでコールバックを使わずに取得できる。(ブラウザ側ではユーザーの許可が必要でそのためのホップアップが出る)
WebSocketは開いたときにサーバーにユーザー情報を送るイベントをセットしている。
WebSocketのmessageイベントではMessageEventが渡されるので送られたメッセージ自体はmessage.data
の中に格納されている。
シグナリング自体は前述の流れでするがまず最初のトリガーが必要でそれは{ method: rtc, type: request }
を使う。
SDP情報はonicecandidateイベントでやる。ICEはInteractive Connectivity Establishmentであり接続を確立するプロトコルである。候補がなくなるとevent.candidate=null(undefined)
のイベントが渡されるのでそれを待ってサーバーにSDP情報を送信する。
そしてoffer
を送信した側はAnswer
を待つのでそのイベントをWebSocketに追加する。帰って来てセットするとそのイベントはいらなくなるのでremoveEventListenerで削除する。
answer側
offer
のシグナルに対してanswer
を返す側は一回のやり取りで完結している。
そしてICEに対してはoffer
と同じ処理でいいので共通化してmakePeer
としておく。
サーバー側
サーバー側は疑似コードであるがこんな感じになる。
通信作は{to: name, from: name}
で管理している(当然ながら名前は違うのを振っている
そしてdescriptionのtypeによってどっちに送るかを判定して送ってはいるがエコーサーバーに毛が生えたレベルのものである。
siginaling(ws, req){
const roomName, name=getInfo(); // 名前や部屋名の取得
setOnMessage(roomName, name, ws);
}
function setOnMessage(roomName, name, socket){
if( this.roomMap.has(roomName) && this.roomMap.get(roomName).hasUser(name) ){
this.findUser(roomName, name).setSignaling(socket);
socket.onmessage=message=>{
const json=JSON.parse(message.data);
console.log('signaling ', json);
if( json.method==='rtc' && json.description!=null ){
if( json.description.type==='offer' ) this.findUser(json.roomName, json.to).signalingSocket.send(JSON.stringify(json));
else if( json.description.type==='answer' ) this.findUser(json.roomName, json.from).signalingSocket.send(JSON.stringify(json));
}
}
for( const [ key, user ] of this.roomMap.get(roomName).entries() ){
if( key===name ) continue;
console.log(`${roomName} ${name} require RTC Connection`);
user.signalingSocket.send(JSON.stringify({ method: 'rtc', type: 'request', "roomName": roomName, to: name, from: key }));
}
}
終わりに
一応これで映像と音声は飛ばして相手側で写せると思う。
ここまで簡単にできるWebRCTのAPIを作ってる人たちに感謝の念が絶えない。
しかしながらいろいろと非推奨なものがあったりとまだまだ仕様が固まりきってないと思うので使いたいと思う人は仕様かMDNをあたってください。
Discussion