HerokuでWebRTCのシグナリングサーバーを作る

7 min読了の目安(約6300字TECH技術記事

WebRTCとは

RTCはReal Time Communicationの略で低レイテンシーでのCommunicationを取るためのAPIである、ブラウザでのビデオチャットシステムなどはこれを用いておりビデオや音声をインターネット越しでやるために欠かせないものである。
WebRTCはP2P(peer-to-peer)通信を用いる、P2Pなので端末同士が直接通信するがその端末を見つけるためにサーバーが必要である。それらの信号を送るサーバーのことをシグナリングサーバーと呼ぶ。
video_chatを作ることを目指して部屋名(roomName)と名前(name)で接続元を判別するようにするがここではシグナリングを主に解説する。
本番用からシグナリング関係だけを抜き出しているので単体で動くことは保証しない。

使うもの

PeerConnection(RTCPeerConnection)

接続するための本体

SDP(RTCSessionDescription)

WebRTCのセッションは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で追加しておき

index.js
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を多用するので間違えると例外を飛ばすので注意)

クライアント側の処理

クライアントは以下のようにした。

signaling.js
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によってどっちに送るかを判定して送ってはいるがエコーサーバーに毛が生えたレベルのものである。

src/video_chat.js

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をあたってください。