Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8900

WebRTCを実装してみた

$
0
0

WebRTCとは

Web Real-Time Communicationsの略でWebブラウザにプラグインを追加することなく、リアルタイムにコミュニケーションを可能にする技術のことです。

ネットワークトポロジー

WebRTCでの通信形態は以下3つあります。WebRTCを利用するアプリケーションの特性により何れかを選択することになります。

P2Pタイプ

クライアント同士がそれぞれで接続する仕組みで、メッシュ型と呼ばれたりします。
※本記事もP2Pタイプで実装しています。
トポロジー1.png

MCU(Multipoint Control Unit)

通信を1本に統合する仕組みですが、中央サーバで暗号化を解き、動画や音声を合成し配信するのでCPUに大変負荷がかかります。ただし転送量は最適化されます。
トポロジー2.jpg

SFU (Selective Forwarding Unit)

上りは各クライアントから1本に抑え、下りを複数本にする仕組みです。
中央サーバはクライアントから送られてきたデータをそれぞれのクライアントに転送します。
クライアント側は必要な分だけ接続をはり、中央サーバはそれに合わせてデータを配信する仕組みです。
トポロジー3.jpg

P2Pの確立

P2Pの通信を確立するにあたり、SDPとCandidateの情報をブラウザ間で交換します。

SDP(Session Description Protocol)

SDPは通信を行う際お互いがどのような映像・音声のコーデックを使えるかなどストリーミングメディアの初期化パラメータを記述する形式の一つです。
自端末のChromeブラウザにて取得した同情報(一部)は以下の通りで、VP8(ビデオコーディック)やopus(音声圧縮方式)が列挙されているのが分かります。尚、リストの最初にある形式が優先的に使用されるようです。

SDP
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114 115 116
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=rtpmap:98 VP9/90000
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000

ICE Candidate

ICE(Interactive Connectivity Establishment)はNAT越え(NAT トラバーサル) を実現するメカニズムで、通信相手と通信経路のアドレスの候補 (Candidate) を交換します。相手から受信したCandidateと自身の同情報から最適な経路を決定します。
以下のtyp hostはLAN上の経路情報でPCの物理NICや仮想NICの情報をブラウザが収集したものです。typ srflxはSTUNサーバを利用して確認したNATを経由する経路情報です。同一LAN内でWebRTC通信する際は、hostの経路を利用するのがもっともコストが掛からないためそのように接続しますが、ルータを介して通信する際は、srflxの経路を利用することになります。

Candidate
candidate:2448668656 1 udp 2122260223 192.168.142.1 62373 typ host generation 0 ufrag PHWw network-id 1
candidate:1867667642 1 udp 2122194687 192.168.254.1 62374 typ host generation 0 ufrag PHWw network-id 5
candidate:747801767 1 udp 2122131711 [IPv6のアドレス] 62375 typ host generation 0 ufrag PHWw network-id 3 network-cost 10
candidate:1688413422 1 udp 2122066175 [IPv6のアドレス] 62376 typ host generation 0 ufrag PHWw network-id 4 network-cost 10
candidate:1189248530 1 udp 2121998079 192.168.1.49 62377 typ host generation 0 ufrag PHWw network-id 2 network-cost 10
candidate:3748678400 1 tcp 1518280447 192.168.142.1 9 typ host tcptype active generation 0 ufrag PHWw network-id 1
candidate:567387210 1 tcp 1518214911 192.168.254.1 9 typ host tcptype active generation 0 ufrag PHWw network-id 5
candidate:1645310039 1 tcp 1518151935 [IPv6のアドレス] 9 typ host tcptype active generation 0 ufrag PHWw network-id 3 network-cost 10
candidate:706795550 1 tcp 1518086399 [IPv6のアドレス] 9 typ host tcptype active generation 0 ufrag PHWw network-id 4 network-cost 10
candidate:140608226 1 tcp 1518018303 192.168.1.49 9 typ host tcptype active generation 0 ufrag PHWw network-id 2 network-cost 10
candidate:2968651718 1 udp 1685790463 [グローバルIP] 6817 typ srflx raddr 192.168.1.49 rport 54927 generation 0 ufrag Uliz network-id 2 network-cost 10

STUN(Session Traversal Utilities for NATs)

インターネット上のサーバにリクエストを送り、ルータによって変換されたグローバルIPアドレスをSTUNサーバに返してもらいます。
そこで得たグローバルIPアドレスを相手と交換します。

TURN(Traversal Using Relay around NAT)

外部の端末が発信した通信が自身に届かないネットワーク環境の場合は、STUNサーバだけでは解決しないので、TURNサーバを使って通信を確立します。
TURNサーバは自身も相手も通信できるインターネット上にいて通信を中継するサーバです。
RTCPeerConnectionを生成する際に、STUNサーバやTURNサーバのアドレスを設定できます。

システム構成

ブラウザとブラウザで直接やり取りをしますが全くサーバが不要ということはなく、初めにお互いの情報がわからなければやりとりしようがないため、最初だけシグナリングサーバと呼ばれるものが必要になります。また、NATを超える通信が必要な場合はSTUNサーバやTURNサーバも必要です。
本記事ではSTUNとTURNサーバにオープンソフトのCOTURNを採用しました。シグナリングサーバはNodeJSで作成・実行し、sample.htmlを返すWebサーバも用意します。sample.htmlを表示するブラウザはChromeを使用します。
全てhttpsでアクセスしないとChromeに怒られてしまうため、オレオレ証明書も作成しています。
最終的にはPC1とPC2でオンライン会話できることを目標にします。
システム図.png

COTURNサーバ

COTURNサーバを構築しますが、まず依存するツールやライブラリを先にインストールします。

$ sudo apt-get install gcc
$ sudo apt-get install sqlite
$ sudo apt-get install sqlite3
$ sudo apt-get install libssl-dev
$ sudo apt-get install libevent-dev
$ sudo apt-get install make
$ wget https://github.com/libevent/libevent/releases/download/release-2.1.11-stable/libevent-2.1.11-stable.tar.gz
$ tar xvfz libevent-2.1.11-stable.tar.gz
$ cd libevent-2.1.11-stable
$ ./configure
$ make
$ sudo make install
$ wget https://coturn.net/turnserver/v4.5.0.8/turnserver-4.5.0.8.tar.gz
$ tar xvfz turnserver-4.5.0.8.tar.gz
$ cd turnserver-4.5.0.8
$ ./configure
$ make
$ sudo make install

設定ファイルをコピーします。

$ sudo cp /usr/local/etc/turnserver.conf.default /usr/local/etc/turnserver.conf

設定ファイルを編集します。

$ sudo vi /usr/local/etc/turnserver.conf

--- 以下を有効にして内容を書き換えてください ---

realm=foo.bar.com
listening-ip=XXX.XXX.XXX.XXX(さくらVPSで割り振られたグローバルIPを指定)
no-udp
no-tcp
min-port=49152
max-port=65535
verbose
cert=/usr/local/etc/turn_server_cert.pem
pkey=/usr/local/etc/turn_server_pkey.pem
dh2066
tls-listening-port=5349
lt-cred-mech
user=test:password
secure-stun

オレオレ証明書を作成する準備をします。

$ vi /etc/ssl/openssl.cnf

--- 以下を有効にして内容を書き換えてください ---

unique_subject = no
copy_extensions = copy

サーバ証明書のSANに記述する文字列を別ファイルで用意します。

$ vi san.txt

--- 以下の内容で記述してください ---

subjectAltName = DNS:foo.bar.com

サーバ証明書を作成します。

$ openssl genrsa -out server_key.pem 2048
$ openssl req -batch -new -key server_key.pem -out server_csr.pem -subj "/C=JP/ST=Tokyo/L=Minato-ku/O=Foo/OU=Bar/CN=foo.bar.com"
$ openssl x509 -in server_csr.pem -out server_crt.pem -req -signkey server_key.pem -days 73000 -sha256 -extfile san.txt

作成したサーバ証明書と秘密鍵をコピーします。

$ sudo cp ./server_crt.pem /usr/local/etc/turn_server_cert.pem
$ sudo cp ./server_key.pem /usr/local/etc/turn_server_pkey.pem

COTURNサーバを起動させます。
尚、ログは /var/log/turn_*.log となります。

$ sudo /usr/local/bin/turnserver -o -v -c /usr/local/etc/turnserver.conf

シグナリングサーバ

まずファイル群を置くフォルダを作成します。

$ mkdir signaling

プログラムの動作に必要なパッケージをインストールします。

cd ./signaling
$ sudo apt install npm
$ npm install socket.io
$ npm install https
$ npm install fs
$ npm install express

先ほど作成したサーバ証明書と秘密鍵をこちらにもコピー(ファイル名はそのまま)します。

$ sudo cp ../server_crt.pem ./
$ sudo cp ../server_key.pem ./

signaling.jsを配置します。

signaling.js
"use strict";varssl_server_key='server_key.pem';varssl_server_crt='server_crt.pem';varport=10443;varfs=require('fs');varapp=require('express')();varhttps=require('https');varserver=https.createServer({key:fs.readFileSync(ssl_server_key).toString(),cert:fs.readFileSync(ssl_server_crt).toString()},app);server.listen(port);vario=require('socket.io').listen(server);io.set('heartbeat interval',5000);io.set('heartbeat timeout',15000);console.log((newDate())+" Server is listening on port: "+port);varsockets={};varusers={};io.sockets.on('connection',function(socket){console.log((newDate())+" remoteAddress: "+socket.request.connection.remoteAddress);socket.on('username',function(message){try{if(message!=null&&message.length>0){sockets[socket.id]=socket;users[socket.id]=message;}letuserlist=getUserList(socket.id);console.log((newDate())+" username: "+JSON.stringify(userlist));socket.emit('message',userlist);}catch(e){}});socket.on('message',function(message){letskt=sockets[message.sendto];try{if(skt){message.sendfrom=socket.id;skt.emit('message',message);}}catch(e){}});socket.on('disconnect',function(){console.log((newDate())+" disconnect ");try{for(letkeyinsockets){if(key==socket.id){deletesockets[socket.id];}}for(letkeyinusers){if(key==socket.id){deleteusers[socket.id];}}}catch(e){}});});functiongetUserList(id){letaryUsers=[];letaryIds=[];for(letkeyinusers){if(key!=id){aryIds.push(key);aryUsers.push(users[key]);}}varuserlist={type:'userlist',ids:aryIds,users:aryUsers,}returnuserlist;}setInterval(()=>{console.log((newDate())+" alluser: "+JSON.stringify(users));},3000);

signalingフォルダのファイル一覧は以下の通りです。

 node_modules/
 package-lock.json
 server_crt.pem
 server_key.pem
 signaling.js

以下のコマンドでsignaling.jsを実行します。

sudo nohup node signaling.js &

Webサーバ

Webサーバの構築については以下のsample.htmlを先ほど作成したサーバ証明書にてhttpsで応答するよう構築してください。apacheやnginx、その他お好きなサーバをご使用ください。

sample.html
<!doctype html><htmllang="jp"><head><metacharset="utf-8"/><title>オンライン会話</title></head><bodyonload="startVideo()"><script src="https://foo.bar.com:10443/socket.io/socket.io.js"></script><divclass="display:table;"><divclass="display:table-cell; vertical-align: top; "><videoid="local-video"autoplaystyle="width: 240px; height: 180px; border: 1px solid black;"></video><videoid="remote-video"autoplaystyle="width: 240px; height: 180px; border: 1px solid black;"></video></div><divstyle="display:table-cell; height:100px; vertical-align: top; "><spanstyle="vertical-align: top;">接続名</span><inputtype="text"id="username"value=""><buttonstyle="vertical-align: top;"type="button"onclick="entry();">更新</button><selectstyle="vertical-align: top; width: 100px; "size="6"id="userlist"></select><buttonid="connect-btn"style="vertical-align: top;"type="button"onclick="connect();">接続</button><buttonid="disconnect-btn"style="vertical-align: top;"type="button"onclick="disconnect();"disabled>切断</button></div></div><script type="text/javascript">varlocalVideo=document.getElementById('local-video');varremoteVideo=document.getElementById('remote-video');varlocalStream=null;varpeerConnection=null;varpeerSocketId=null;varlocalDescription=null;varpeerStarted=false;varmediaConstraints={'mandatory':{'OfferToReceiveAudio':true,'OfferToReceiveVideo':true}};varsocketReady=false;varport=10443;varsocket=io.connect('https://foo.bar.com:'+port+'/');socket.on('connect',onOpened).on('message',onMessage);functiononOpened(evt){socketReady=true;}functiononMessage(evt){if(evt.type==='offer'){onOffer(evt);}elseif(evt.type==='answer'&&peerStarted){onAnswer(evt);}elseif(evt.type==='candidate'&&peerStarted){onCandidate(evt);}elseif(evt.type==='user dissconnected'&&peerStarted){stop();}elseif(evt.type==='userlist'){onUserList(evt);}}varbtnConnect=document.getElementById('connect-btn');varbtnDisconnect=document.getElementById('disconnect-btn');vartextUsername=document.getElementById('username');varselectUserlist=document.getElementById("userlist");varCR=String.fromCharCode(13);functiononOffer(evt){setOffer(evt);sendAnswer(evt);peerStarted=true;btnConnect.disabled=true;btnDisconnect.disabled=false;}functiononAnswer(evt){setAnswer(evt);}functiononCandidate(evt){varcandidate=newRTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex,sdpMid:evt.sdpMid,candidate:evt.candidate});peerConnection.addIceCandidate(candidate);}functionsendSDP(message){socket.emit("message",message);}functionsendCandidate(message){socket.emit("message",message);}functionentry(){if(textUsername.value.trim().length>0){socket.emit('username',textUsername.value);}else{alert("接続名を入力してください。");}}functionstartVideo(){navigator.mediaDevices.getUserMedia({video:{width:240,height:180},audio:true}).then(function(stream){localStream=stream;localVideo.srcObject=stream;localVideo.volume=0.7;localVideo.onloadedmetadata=function(e){localVideo.play();};}).catch(function(err){});}functionstopVideo(){if(peerStarted){peerConnection.getSenders().forEach(function(sender){peerConnection.removeTrack(sender);deletesender;sender=null;});}localVideo.pause();localVideo.srcObject=null;localStream.getTracks().forEach(track=>track.stop());}functionprepareNewConnection(){letpc_config={"iceServers":[{"urls":"stun:foo.bar.com:5349"},{"urls":"turn:foo.bar.com:5349","username":"test","credential":"password"}]};varpeer=null;try{peer=newwebkitRTCPeerConnection(pc_config);}catch(e){}peer.onicecandidate=function(evt){if(evt.candidate){letmessage={type:"candidate",candidate:evt.candidate.candidate,sdpMLineIndex:evt.candidate.sdpMLineIndex,sdpMid:evt.candidate.sdpMid};message.sendto=peerSocketId;sendCandidate(message);}else{}};peer.oniceconnectionstatechange=function(){switch(peer.iceConnectionState){case'disconnected':case'closed':case'failed':if(peerConnection){disconnect();}break;}};letvideoSender=peer.addTrack(localStream.getVideoTracks()[0],localStream);letaudioSender=peer.addTrack(localStream.getAudioTracks()[0],localStream);peer.ontrack=function(event){consttrack=event.track;conststream=event.streams[0];remoteVideo.srcObject=stream;};returnpeer;}functionsendOffer(sendto){peerConnection=prepareNewConnection();peerConnection.createOffer(function(sessionDescription){localDescription=sessionDescription;peerConnection.setLocalDescription(localDescription);letmessage={type:sessionDescription.type,sdp:sessionDescription.sdp};if(sendto){message.sendto=sendto;}peerSocketId=message.sendto;sendSDP(message);},function(){},mediaConstraints);}functionsetOffer(evt){if(peerConnection){}peerConnection=prepareNewConnection();peerSocketId=evt.sendfrom;peerConnection.setRemoteDescription(newRTCSessionDescription(evt));}functionsendAnswer(evt){letsendfrom=evt.sendfrom;if(!peerConnection){return;}peerConnection.createAnswer(function(sessionDescription){peerConnection.setLocalDescription(sessionDescription);letmessage={type:sessionDescription.type,sdp:sessionDescription.sdp};message.sendto=evt.sendfrom;sendSDP(message);},function(){},mediaConstraints);}functionsetAnswer(evt){if(!peerConnection){return;}peerConnection.setRemoteDescription(newRTCSessionDescription(evt));}functionconnect(){if(selectUserlist.selectedIndex==-1){alert("接続先を選択してください。");return;}letsendto=selectUserlist.options[selectUserlist.selectedIndex].valueif(!socketReady){alert("シグナリングサーバと接続できません。");}elseif(localStream==null){alert("ビデオが起動していません。");}elseif(peerStarted){alert("既に接続済みです。");}else{sendOffer(sendto);peerStarted=true;btnConnect.disabled=true;btnDisconnect.disabled=false;}}functiondisconnect(){stop();}functionstopRemoteVideo(){remoteVideo.pause();remoteVideo.srcObject=null;}functionstop(){peerConnection.close();peerConnection=null;peerStarted=false;btnConnect.disabled=false;btnDisconnect.disabled=true;}functiononUserList(evt){leti=0;while(i<selectUserlist.length){if(evt.ids.length==0){selectUserlist.remove(i);}else{for(letj=0;j<evt.ids.length;j++){if(selectUserlist[i].value==evt.ids[j]){selectUserlist[i].text=evt.users[j];evt.ids.splice(j,1);evt.users.splice(j,1);i++;break;}elseif(j==evt.ids.length-1){selectUserlist.remove(i);}}}}for(i=0;i<evt.ids.length;i++){letoption=document.createElement("option");option.text=evt.users[i];option.value=evt.ids[i];selectUserlist.appendChild(option);}}setInterval(()=>{try{socket.emit('username',textUsername.value);}catch(e){}},3000);</script></body></html>

クライアント端末

先ほど作成したサーバ証明書(server_crt.pem)をクライアント端末(Chrome)の「信頼されたルート証明機関」にインストールします。インストールしたらChromeを再起動してください。
また、hostsファイルに以下の1行を追加してください。
※xxx.xxx.xxx.xxxはさくらVPSで割り振られたグローバルIPです。

xxx.xxx.xxx.xxx     foo.bar.com

動作確認

PC1とPC2でChromeを立ち上げて以下のURLをたたきます。

https://foo.bar.com/sample.html

カメラとマイクが起動し、以下の画面が表示されました。
太郎.png

ここでPC1には接続名に「太郎」、PC2では「花子」と入力して更新ボタンを押下します。
「花子」を選択して接続ボタンを押下すると無事接続されました。
通信.png

以上でした。


Viewing all articles
Browse latest Browse all 8900

Trending Articles