本章将带你学习真正的1V1音视频实时互动直播系统的实现。这部分内容比较重,里边有大量的实现,相信同学位可以从本章收获大量的知识。

12-1 【来点实战】STUN_TURN服务器搭建

image.png
image.png
https://github.com/coturn/coturn

云服务器位置
/home/ubuntu/webrtc/coturn
image.png
配置文件目录
/usr/local/coturn/etc
cp turnserver.conf.default turnserver.conf
1.14.148.67
sudo turnserver -c ls /usr/local/coturn/etc/turnserver.conf
# nohup是重定向命令,输出都将附加到当前目录的 nohup.out 文件中; 命令后加 & ,后台执行起来后按 ctr+c,不会停止
sudo nohup turnserver -L 0.0.0.0 -a -u charles:123456 -v -f -r nort.gov &
image.png
https://www.yuque.com/caokunchao/va2wrk/ylgv06

image.png

12-2 【参数介绍】再论RTCPeerConnection

image.png
image.png
image.png

image.png
image.png
image.png

12-3 【必备原理】直播系统中的信令及其逻辑关系

image.png
image.png
image.png
image.png
image.png

12-4 【来点实战】实现1:1音视频实时互动信令服务器

image.png
主要修改:
添加人数限制
添加otherjoin,full指令

server.js

  1. 'use strict'
  2. var log4js = require('log4js');
  3. var http = require('http');
  4. var https = require('https');
  5. var fs = require('fs');
  6. var socketIo = require('socket.io');
  7. var express = require('express');
  8. var serveIndex = require('serve-index');
  9. var USERCOUNT = 3;
  10. log4js.configure({
  11. appenders: {
  12. file: {
  13. type: 'file',
  14. filename: 'app.log',
  15. layout: {
  16. type: 'pattern',
  17. pattern: '%r %p - %m',
  18. }
  19. }
  20. },
  21. categories: {
  22. default: {
  23. appenders: ['file'],
  24. level: 'debug'
  25. }
  26. }
  27. });
  28. var logger = log4js.getLogger();
  29. var app = express();
  30. app.use(serveIndex('./public'));
  31. app.use(express.static('./public'));
  32. //http server
  33. var http_server = http.createServer(app);
  34. http_server.listen(80, '0.0.0.0');
  35. var options = {
  36. key : fs.readFileSync('./cert/1557605_www.learningrtc.cn.key'),
  37. cert: fs.readFileSync('./cert/1557605_www.learningrtc.cn.pem')
  38. }
  39. //https server
  40. var https_server = https.createServer(options, app);
  41. var io = socketIo.listen(https_server);
  42. io.sockets.on('connection', (socket)=> {
  43. socket.on('message', (room, data)=>{
  44. socket.to(room).emit('message',room, data);
  45. });
  46. socket.on('join', (room)=>{
  47. socket.join(room);
  48. var myRoom = io.sockets.adapter.rooms[room];
  49. var users = (myRoom)? Object.keys(myRoom.sockets).length : 0;
  50. logger.debug('the user number of room is: ' + users);
  51. if(users < USERCOUNT){
  52. socket.emit('joined', room, socket.id); //发给除自己之外的房间内的所有人
  53. if(users > 1){
  54. socket.to(room).emit('otherjoin', room, socket.id);
  55. }
  56. }else{
  57. socket.leave(room);
  58. socket.emit('full', room, socket.id);
  59. }
  60. //socket.emit('joined', room, socket.id); //发给自己
  61. //socket.broadcast.emit('joined', room, socket.id); //发给除自己之外的这个节点上的所有人
  62. //io.in(room).emit('joined', room, socket.id); //发给房间内的所有人
  63. });
  64. socket.on('leave', (room)=>{
  65. var myRoom = io.sockets.adapter.rooms[room];
  66. var users = (myRoom)? Object.keys(myRoom.sockets).length : 0;
  67. logger.debug('the user number of room is: ' + (users-1));
  68. //socket.emit('leaved', room, socket.id);
  69. //socket.broadcast.emit('leaved', room, socket.id);
  70. socket.to(room).emit('bye', room, socket.id);
  71. socket.emit('leaved', room, socket.id);
  72. //io.in(room).emit('leaved', room, socket.id);
  73. });
  74. });
  75. https_server.listen(443, '0.0.0.0');

12-5 【参数介绍】再论CreateOffer

image.png
image.png

  1. <html>
  2. <head>
  3. <title>
  4. test createOffer from different client
  5. </title>
  6. </head>
  7. <body>
  8. <button id="start">Start</button>
  9. <button id="restart">reStart ICE</button>
  10. <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
  11. <script src="js/main.js"></script>
  12. </body>
  13. </html>
  1. 'use strict'
  2. var start = document.querySelector('button#start');
  3. var restart = document.querySelector('button#restart');
  4. var pc1 = new RTCPeerConnection();
  5. var pc2 = new RTCPeerConnection();
  6. function handleError(err){
  7. console.log('Failed to create offer', err);
  8. }
  9. function getPc1Answer(desc){
  10. console.log('getPc1Answer', desc.sdp);
  11. pc2.setLocalDescription(desc);
  12. pc1.setRemoteDescription(desc);
  13. /*
  14. pc2.createOffer({offerToRecieveAudio:1, offerToReceiveVideo:1})
  15. .then(getPc2Offer)
  16. .catch(handleError);
  17. */
  18. }
  19. function getPc1Offer(desc){
  20. console.log('getPc1Offer', desc.sdp);
  21. pc1.setLocalDescription(desc);
  22. pc2.setRemoteDescription(desc);
  23. pc2.createAnswer().then(getPc1Answer).catch(handleError);
  24. }
  25. function getPc2Answer(desc){
  26. console.log('getPc2Answer');
  27. pc1.setLocalDescription(desc);
  28. pc2.setRemoteDescription(desc);
  29. }
  30. function getPc2Offer(desc){
  31. console.log('getPc2Offer');
  32. pc2.setLocalDescription(desc);
  33. pc1.setRemoteDescription(desc);
  34. pc1.createAnswer().then(getPc2Answer).catch(handleError);
  35. }
  36. function startTest(){
  37. pc1.createOffer({offerToReceiveAudio:1, offerToRecieveVideo:1})
  38. .then(getPc1Offer)
  39. .catch(handleError);
  40. }
  41. function getMediaStream(stream){
  42. stream.getTracks().forEach((track) => {
  43. pc1.addTrack(track, stream);
  44. });
  45. var offerConstraints = {
  46. offerToReceiveAudio: 1,
  47. offerToRecieveVideo: 1,
  48. iceRestart:false
  49. }
  50. pc1.createOffer(offerConstraints)
  51. .then(getPc1Offer)
  52. .catch(handleError);
  53. }
  54. function startICE(){
  55. var constraints = {
  56. audio: true,
  57. video: true
  58. }
  59. navigator.mediaDevices.getUserMedia(constraints)
  60. .then(getMediaStream)
  61. .catch(handleError);
  62. }
  63. start.onclick = startTest;
  64. restart.onclick = startICE;

image.png
单击两次Start,终端搜索ice-uflag,发现有四个,但是实际两个是一样值。
ice-ufrag 和 ice-pwd 是用户名和密码。当 A 与 B 建立连接时,A 每次发送数据到B,都要带着它的用户名和密码过来,此时 B 端就可以通过验证 A 带来的用户名和密码与 SDP 中的用户名和密码是否一致的,来判断 A 是否是一个合法用户了。
除此之外,fingerprint也是验证合法性的关键一步,它是存放公钥证书的指纹(或叫信息摘要),在通过 ice-ufrag 和 ice-pwd 验证用户的合法性之余,还要对它发送的证书做验证,看看证书在传输的过程中是否被窜改了。
(黄色片段来自 https://zhuanlan.zhihu.com/p/100007626 )

12-6 【必备原理】WebRTC客户端状态机及处理逻辑

image.png
image.png
image.png
image.png

12-7【来点实战-基本结构】-WebRTC客户端的实现

image.png
conn()

12-8 【来点实战-增加PeerConnecton逻辑】WebRTC客户端的实现-1

12-9 【来点实战-增加PeerConnecton逻辑】WebRTC客户端的实现-2

createPeerConnection()
leave()
hangup()

12-10 【来点实战-增加媒体协商的逻辑】WebRTC客户端的实现-3

call()
getOffer(desc)
sendMessage(roomid, data)
handleError(err)
socket.on(‘message’, (roomid, data)
getAnswer

1) room.html

  1. <html>
  2. <head>
  3. <title>WebRTC PeerConnection</title>
  4. <link href="./css/main.css" rel="stylesheet" />
  5. </head>
  6. <body>
  7. <div>
  8. <div>
  9. <button id="connserver">Connect Sig Server</button>
  10. <!--<button id="start" disabled>Start</button>
  11. <button id="call" disabled>Call</button>
  12. <button id="hangup" disabled>HangUp</button>
  13. -->
  14. <button id="leave" disabled>Leave</button>
  15. </div>
  16. <div>
  17. <input id="shareDesk" type="checkbox"/><label for="shareDesk">Share Desktop</label>
  18. </div>
  19. <div id="preview">
  20. <div >
  21. <h2>Local:</h2>
  22. <video id="localvideo" autoplay playsinline muted></video>
  23. <h2>Offer SDP:</h2>
  24. <textarea id="offer"></textarea>
  25. </div>
  26. <div>
  27. <h2>Remote:</h2>
  28. <video id="remotevideo" autoplay playsinline></video>
  29. <h2>Answer SDP:</h2>
  30. <textarea id="answer"></textarea>
  31. </div>
  32. </div>
  33. </div>
  34. <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
  35. <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
  36. <script src="js/main.js"></script>
  37. </body>
  38. </html>

2) main.js

  1. 'use strict'
  2. var localVideo = document.querySelector('video#localvideo');
  3. var remoteVideo = document.querySelector('video#remotevideo');
  4. var btnConn = document.querySelector('button#connserver');
  5. var btnLeave = document.querySelector('button#leave');
  6. var offer = document.querySelector('textarea#offer');
  7. var answer = document.querySelector('textarea#answer');
  8. var shareDeskBox = document.querySelector('input#shareDesk');
  9. var pcConfig = {
  10. 'iceServers': [{
  11. 'urls': 'turn:stun.al.learningrtc.cn:3478',
  12. 'credential': "mypasswd",
  13. 'username': "garrylea"
  14. }]
  15. };
  16. var localStream = null;
  17. var remoteStream = null;
  18. var pc = null;
  19. var roomid;
  20. var socket = null;
  21. var offerdesc = null;
  22. var state = 'init';
  23. // 以下代码是从网上找的
  24. //=========================================================================================
  25. //如果返回的是false说明当前操作系统是手机端,如果返回的是true则说明当前的操作系统是电脑端
  26. function IsPC() {
  27. var userAgentInfo = navigator.userAgent;
  28. var Agents = ["Android", "iPhone","SymbianOS", "Windows Phone","iPad", "iPod"];
  29. var flag = true;
  30. for (var v = 0; v < Agents.length; v++) {
  31. if (userAgentInfo.indexOf(Agents[v]) > 0) {
  32. flag = false;
  33. break;
  34. }
  35. }
  36. return flag;
  37. }
  38. //如果返回true 则说明是Android false是ios
  39. function is_android() {
  40. var u = navigator.userAgent, app = navigator.appVersion;
  41. var isAndroid = u.indexOf('Android') > -1 || u.indexOf('Linux') > -1; //g
  42. var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端
  43. if (isAndroid) {
  44. //这个是安卓操作系统
  45. return true;
  46. }
  47. if (isIOS) {
  48.   //这个是ios操作系统
  49.    return false;
  50. }
  51. }
  52. //获取url参数
  53. function getQueryVariable(variable)
  54. {
  55. //JS 脚本捕获页面 GET 方式请求的参数?其实直接使用 window.location.search 获得,
  56. //然后通过 split 方法结合循环遍历自由组织数据格式。
  57. var query = window.location.search.substring(1);
  58. var vars = query.split("&");
  59. for (var i=0;i<vars.length;i++) {
  60. var pair = vars[i].split("=");
  61. if(pair[0] == variable){return pair[1];}
  62. }
  63. return(false);
  64. }
  65. //=======================================================================
  66. function sendMessage(roomid, data){
  67. console.log('send message to other end', roomid, data);
  68. if(!socket){
  69. console.log('socket is null');
  70. }
  71. socket.emit('message', roomid, data);
  72. }
  73. //连接函数
  74. function conn(){
  75. socket = io.connect();
  76. socket.on('joined', (roomid, id) => {
  77. console.log('receive joined message!', roomid, id);
  78. state = 'joined'
  79. //如果是多人的话,第一个人不该在这里创建peerConnection
  80. //都等到收到一个otherjoin时再创建
  81. //所以,在这个消息里应该带当前房间的用户数
  82. //
  83. //create conn and bind media track
  84. createPeerConnection();
  85. bindTracks();
  86. btnConn.disabled = true;
  87. btnLeave.disabled = false;
  88. console.log('receive joined message, state=', state);
  89. });
  90. socket.on('otherjoin', (roomid) => {
  91. console.log('receive joined message:', roomid, state);
  92. //如果是多人的话,每上来一个人都要创建一个新的 peerConnection
  93. //
  94. if(state === 'joined_unbind'){
  95. createPeerConnection();
  96. bindTracks();
  97. }
  98. state = 'joined_conn';
  99. call();
  100. console.log('receive other_join message, state=', state);
  101. });
  102. socket.on('full', (roomid, id) => {
  103. console.log('receive full message', roomid, id);
  104. hangup();
  105. closeLocalMedia();
  106. state = 'leaved';
  107. console.log('receive full message, state=', state);
  108. alert('the room is full!');
  109. });
  110. socket.on('leaved', (roomid, id) => {
  111. console.log('receive leaved message', roomid, id);
  112. state='leaved'
  113. socket.disconnect();
  114. console.log('receive leaved message, state=', state);
  115. btnConn.disabled = false;
  116. btnLeave.disabled = true;
  117. });
  118. socket.on('bye', (room, id) => {
  119. console.log('receive bye message', roomid, id);
  120. //state = 'created';
  121. //当是多人通话时,应该带上当前房间的用户数
  122. //如果当前房间用户不小于 2, 则不用修改状态
  123. //并且,关闭的应该是对应用户的peerconnection
  124. //在客户端应该维护一张peerconnection表,它是
  125. //一个key:value的格式,key=userid, value=peerconnection
  126. state = 'joined_unbind';
  127. hangup();
  128. offer.value = '';
  129. answer.value = '';
  130. console.log('receive bye message, state=', state);
  131. });
  132. socket.on('disconnect', (socket) => {
  133. console.log('receive disconnect message!', roomid);
  134. if(!(state === 'leaved')){
  135. hangup();
  136. closeLocalMedia();
  137. }
  138. state = 'leaved';
  139. });
  140. socket.on('message', (roomid, data) => {
  141. console.log('receive message!', roomid, data);
  142. if(data === null || data === undefined){
  143. console.error('the message is invalid!');
  144. return;
  145. }
  146. if(data.hasOwnProperty('type') && data.type === 'offer') {
  147. offer.value = data.sdp;
  148. pc.setRemoteDescription(new RTCSessionDescription(data));
  149. //create answer
  150. pc.createAnswer()
  151. .then(getAnswer)
  152. .catch(handleAnswerError);
  153. }else if(data.hasOwnProperty('type') && data.type == 'answer'){
  154. answer.value = data.sdp;
  155. pc.setRemoteDescription(new RTCSessionDescription(data));
  156. }else if (data.hasOwnProperty('type') && data.type === 'candidate'){
  157. var candidate = new RTCIceCandidate({
  158. sdpMLineIndex: data.label,
  159. candidate: data.candidate
  160. });
  161. pc.addIceCandidate(candidate);
  162. }else{
  163. console.log('the message is invalid!', data);
  164. }
  165. });
  166. roomid = getQueryVariable('room');
  167. socket.emit('join', roomid);
  168. return true;
  169. }
  170. function connSignalServer(){
  171. //开启本地视频
  172. start();
  173. return true;
  174. }
  175. function getMediaStream(stream){
  176. if(localStream){
  177. stream.getAudioTracks().forEach((track)=>{
  178. localStream.addTrack(track);
  179. stream.removeTrack(track);
  180. });
  181. }else{
  182. localStream = stream;
  183. }
  184. localVideo.srcObject = localStream;
  185. //这个函数的位置特别重要,
  186. //一定要放到getMediaStream之后再调用
  187. //否则就会出现绑定失败的情况
  188. //
  189. //setup connection
  190. conn();
  191. //btnStart.disabled = true;
  192. //btnCall.disabled = true;
  193. //btnHangup.disabled = true;
  194. }
  195. function getDeskStream(stream){
  196. localStream = stream;
  197. }
  198. function handleError(err){
  199. console.error('Failed to get Media Stream!', err);
  200. }
  201. function shareDesk(){
  202. if(IsPC()){
  203. navigator.mediaDevices.getDisplayMedia({video: true})
  204. .then(getDeskStream)
  205. .catch(handleError);
  206. return true;
  207. }
  208. return false;
  209. }
  210. function start(){
  211. if(!navigator.mediaDevices ||
  212. !navigator.mediaDevices.getUserMedia){
  213. console.error('the getUserMedia is not supported!');
  214. return;
  215. }else {
  216. var constraints;
  217. if( shareDeskBox.checked && shareDesk()){
  218. constraints = {
  219. video: false,
  220. audio: {
  221. echoCancellation: true,
  222. noiseSuppression: true,
  223. autoGainControl: true
  224. }
  225. }
  226. }else{
  227. constraints = {
  228. video: true,
  229. audio: {
  230. echoCancellation: true,
  231. noiseSuppression: true,
  232. autoGainControl: true
  233. }
  234. }
  235. }
  236. navigator.mediaDevices.getUserMedia(constraints)
  237. .then(getMediaStream)
  238. .catch(handleError);
  239. }
  240. }
  241. function getRemoteStream(e){
  242. remoteStream = e.streams[0];
  243. remoteVideo.srcObject = e.streams[0];
  244. }
  245. function handleOfferError(err){
  246. console.error('Failed to create offer:', err);
  247. }
  248. function handleAnswerError(err){
  249. console.error('Failed to create answer:', err);
  250. }
  251. function getAnswer(desc){
  252. pc.setLocalDescription(desc);
  253. answer.value = desc.sdp;
  254. //send answer sdp
  255. sendMessage(roomid, desc);
  256. }
  257. function getOffer(desc){
  258. pc.setLocalDescription(desc);
  259. offer.value = desc.sdp;
  260. offerdesc = desc;
  261. //send offer sdp
  262. sendMessage(roomid, offerdesc);
  263. }
  264. function createPeerConnection(){
  265. //如果是多人的话,在这里要创建一个新的连接.
  266. //新创建好的要放到一个map表中。
  267. //key=userid, value=peerconnection
  268. console.log('create RTCPeerConnection!');
  269. if(!pc){
  270. pc = new RTCPeerConnection(pcConfig);
  271. pc.onicecandidate = (e)=>{
  272. if(e.candidate) {
  273. sendMessage(roomid, {
  274. type: 'candidate',
  275. label:event.candidate.sdpMLineIndex,
  276. id:event.candidate.sdpMid,
  277. candidate: event.candidate.candidate
  278. });
  279. }else{
  280. console.log('this is the end candidate');
  281. }
  282. }
  283. pc.ontrack = getRemoteStream;
  284. }else {
  285. console.warning('the pc have be created!');
  286. }
  287. return;
  288. }
  289. //绑定永远与 peerconnection在一起,
  290. //所以没必要再单独做成一个函数
  291. function bindTracks(){
  292. console.log('bind tracks into RTCPeerConnection!');
  293. if( pc === null || pc === undefined) {
  294. console.error('pc is null or undefined!');
  295. return;
  296. }
  297. if(localStream === null || localStream === undefined) {
  298. console.error('localstream is null or undefined!');
  299. return;
  300. }
  301. //add all track into peer connection
  302. localStream.getTracks().forEach((track)=>{
  303. pc.addTrack(track, localStream);
  304. });
  305. }
  306. function call(){
  307. if(state === 'joined_conn'){
  308. var offerOptions = {
  309. offerToRecieveAudio: 1,
  310. offerToRecieveVideo: 1
  311. }
  312. pc.createOffer(offerOptions)
  313. .then(getOffer)
  314. .catch(handleOfferError);
  315. }
  316. }
  317. function hangup(){
  318. if(pc) {
  319. offerdesc = null;
  320. pc.close();
  321. pc = null;
  322. }
  323. }
  324. function closeLocalMedia(){
  325. if(localStream && localStream.getTracks()){
  326. localStream.getTracks().forEach((track)=>{
  327. track.stop();
  328. });
  329. }
  330. localStream = null;
  331. }
  332. function leave() {
  333. if(socket){
  334. socket.emit('leave', roomid); //notify server
  335. }
  336. hangup();
  337. closeLocalMedia();
  338. offer.value = '';
  339. answer.value = '';
  340. btnConn.disabled = false;
  341. btnLeave.disabled = true;
  342. }
  343. btnConn.onclick = connSignalServer
  344. btnLeave.onclick = leave;

3)函数流程
btnConn.onclick
-> connSignalServer()
-> start()
-> shareDeskBox.checked && shareDesk( //是否共享桌面,不是则是共享摄像头
-> getMediaStream(stream)
-> conn()

12-11 【阶段作业,练练手吧】共享远程桌面

image.png
if( shareDeskBox.checked && shareDesk())

12-12 完整代码

webserver.zip