###################### WebRTC 呼叫流程 ###################### .. include:: ../links.ref .. include:: ../tags.ref .. include:: ../abbrs.ref ============ ========================== **Abstract** WebRTC 呼叫流程 **Authors** Walter Fan **Status** WIP as draft **Category** LearningNote **Updated** |date| ============ ========================== .. contents:: :local: 概论 ============ WebRTC 是一套基于 Web 的实时通信解决方案,通过浏览器内置的 API 来支持音视频通道的搭建。 简而言之,先在信令通道协商出彼此的媒体和通信参数, 再通过媒体通道来传输音视频媒体数据。这一套媒体会话的搭建流程定义为 “JavaScript Session Establishment Protocol” `JavaScript 会话创建协议`_ WebRTC 的信令,媒体以及数据通道 =============================== 首先看一下 WebRTC 的实体之间的拓扑结构 |image0| WebRTC 协议栈如下图所示, 基本上有三个通道需要建立 1) Signal 信令通道, 可以通过 TCP/TLS + HTTP 和 WebSocket 来传输信令消息 2) Media 媒体通道, 可以通过 UDP + DTLS/SRTP 来传输媒体 3) Data 数据通道, 可以通过 UDP + DTLS + SCTP 来传输数据 |image1| 对于媒体传输层,WebRTC 规定了用 ICE/STUN/TURN 来连通,用 DTLS 来协商 SRTP 密钥,用 SRTP 来传输媒体数据, 用 SCTP 来传输应用数据。 而在信令层,WebRTC 并未指定,各个应用可以用自己喜欢的信令协议来进行媒体协商,一般都是用 SDP 来通过 HTTP, WebSocket 或 SIP 协议承载具体的媒体会话描述。 信令和媒体通道是必需的,数据通道和信令通道可以合并。 会话创建过程(JSEP) 与两个最重要的状态机 ======================================= 在客户端即浏览器需要维护两个状态机 1. 信令状态机 通信的双方需要将 SDP 进行交换以了解彼此的媒体通信能力。本地 Local 先创建并设置 SDP Offer, 状态为 “have-local-offer” , 发送给远端(SetRemote),远端服务收到 Offer, 再根据自己的能力结合对方提供的能力,生成一个 SDP answer , 然后发回给对端.于是当对方收到 Answer 并设置给自己的 RTCPeerConnection, 这个状态就会变成 “stable” |image2| 具体的交互如下图所示, Alice 要与 Bob 通信就要先交换 SDP, 进行一轮 Offer/Answer 的协商 |image3| 1. ICE 连接状态机 信令通道搭建好进行 SDP 媒体能力的协商, 还要进行媒体通道的搭建, 首要的就是要创建好连接,能不能连接需要检查. 也即要将对收集到的候选者对 Candidate-pair 进行检查 “checking”,如果能连通, 最终状态转换为 “connected” |image4| 通过 ICE/STUN/TURN 协议,将位于防火墙,准确地来说是 NAT (网络地址转换器) 之后的通信双方连接起来 可以象下面这样在SDP 在携带地址信息(ice-candidate: ip+port) |image5| 让我们来举一个具体例子, 客户端初始界面如下: |image6| 1. 我们要写一个上面提到的信令服务器, 同时也作为一个网页服务器 ============================================================= nodejs 有强大的 express 和 socket.io 库,可以帮助我们轻松实现一个视频聊天服务器。 express 是非常流行的 nodejs Web 框架,它简单易用, socket.io 是一个支持浏览器和服务器之间的实时、双向和基于事件的通信的 JavaScript 库。它包括 nodejs 服务器和Javascript客户端库。 第一步创建 package.json ----------------------- :: { "name": "webrtc_primer", "version": "0.1.0", "description": "video_chat_demo", "main": "video_chat_server.js", "scripts": { "test": "echo \"Error: no test specified! Configure in package.json\" && exit 1", "start": "nodemon video_chat_server.js" }, "repository": { "type": "git", "url": "https://github.com/walterfan/webrtc_primer" }, "keywords": [ "WebRTC" ], "author": "Simon Pietro Romano", "license": "BSD", "dependencies": { "body-parser": "^1.18.3", "express": "^4.16.3", "log4js": "^6.3.0", "moment": "^2.29.1", "nc": "^1.0.3", "node-static": "~0.7.11", "nodemon": "^1.17.5", "socket.io": "~3.0.4", "sqlite3": "^5.0.2", "webrtc-adapter": "^7.7.0" }, "bugs": { "url": "https://github.com/spromano/WebRTC_Book/issues" }, "homepage": "https://github.com/spromano/WebRTC_Book" } 第二步: 创建 video_chat_server.js ---------------------------------- 这里我用 express 框架来启动一个web 网页服务器,注意这里需要使用 https 协议。 从安全性考虑,WebRTC是不允许用 http 协议的 首先使用openssl 来生成一个私钥和自签名的证书 :: openssl req \ -newkey rsa:2048 -nodes -keyout domain.key \ -x509 -days 365 -out domain.crt 你也可以跳过这一步,使用我生成好的。 添加一个文件 video_chat_server.js :: const fs = require('fs'); const http = require('http'); const https = require('https'); const bodyParser = require('body-parser'); //const sqlite3 = require('sqlite3'); const moment = require('moment'); const express = require('express'); const path = require('path'); const log4js = require("log4js"); log4js.configure({ appenders: { 'stdout': { type: 'stdout' }, 'video_chat': { type: "file", filename: "video_chat.log" } }, categories: { default: { appenders: ["stdout","video_chat"], level: "info" } } }); const logger = log4js.getLogger("video_chat"); const options = { index: "video_chat_demo.html" }; const httpsPort = 8183; const certificate = fs.readFileSync('./domain.crt', 'utf8'); const privateKey = fs.readFileSync('./domain.key', 'utf8'); const credentials = {key: privateKey, cert: certificate}; const app = express(); app.use('/', express.static(path.join(__dirname, '/'), options)); const httpsServer = https.createServer(credentials, app); console.log(`video chart serve on https://localhost:${httpsPort}`); httpsServer.listen(httpsPort); 第三步:引入 socket.io 库,添加对于客户端连接的管理 --------------------------------------------------- 主要就两件事: 一是把有相同房间名称的参会者的连接加入(join) 到一个房间(room) 二是在参会者之间广播消息 :: var io = require('socket.io')(httpsServer); function getParticipantsOfRoom(roomId, namespace) { var count = 0; var ns = io.of(namespace||"/"); // the default namespace is "/" for (let [key, value] of ns.adapter.rooms) { if(key === roomId) { count += value.size; } } return count; } // Let's start managing connections... io.sockets.on('connection', function (socket){ // Handle 'message' messages socket.on('message', function (message) { log('Server --> got message: ', message); logger.info('will broadcast message:', message); // channel-only broadcast... //socket.broadcast.to(socket.channel).emit('message', message); socket.broadcast.emit('message', message); }); // Handle 'create or join' messages socket.on('create or join', function (room) { var numClients = getParticipantsOfRoom(room); log('Server --> Room ' + room + ' has ' + numClients + ' client(s)'); log('Server --> Request to create or join room', room); // First client joining... if (numClients == 0){ socket.join(room); socket.emit('created', room); logger.info(room + " created: " + numClients); } else if (numClients == 1) { // Second client joining... io.sockets.in(room).emit('join', room); socket.join(room); socket.emit('joined', room); logger.info(room + " joined: " + numClients); } else { // max two clients socket.emit('full', room); logger.info(room + " full: " + numClients); } }); function log(){ var array = ["* " + moment().format() + ">>> "]; for (var i = 0; i < arguments.length; i++) { array.push(arguments[i]); } socket.emit('log', array); } }); 2. 写一个支持视频和文本聊天的网页 ================================= 这个网页就是实现一开始我展示的那个界面 ::

Local video Remote video
注意我在最后包含的 js 文件 video_chat_client.js , 这个是我们最主要的代码文件,包含了大部分的逻辑 3. 编写视频聊天的客户端代码,主要就是创建 PeerConnection, 交换 SDP, 通过ICE检查连通性后进行通信 =============================================================================================== 关键是 1) 获取本地媒体 :: async function startMedia() { try { // Call getUserMedia() weblog('Getting user media with constraints: '+ JSON.stringify(constraints)); const stream = await navigator.mediaDevices.getUserMedia(constraints); handleUserMedia(stream); } catch (ex) { handleUserMediaError(ex); } } 2) 创建 PeerConnction , 加入本地媒体流 :: function checkAndStart() { weblog('checkAndStart: isStarted='+ isStarted + ", isChannelReady=" + isChannelReady); if (!isStarted && typeof localStream != 'undefined' && isChannelReady) { createPeerConnection(); pc.addStream(localStream); isStarted = true; if (isInitiator) { doCall(); } } } 创建 peerConnection 时, 注意在onaddstream 时将媒体流附着在 video 元素上. :: function createPeerConnection() { try { pc = new RTCPeerConnection(pc_config, pc_constraints); pc.onicecandidate = handleIceCandidate; weblog('Created RTCPeerConnnection with:\n' + ' config: \'' + JSON.stringify(pc_config) + '\';\n' + ' constraints: \'' + JSON.stringify(pc_constraints) + '\'.'); } catch (e) { weblog('Failed to create PeerConnection, exception: ' + e.message); alert('Cannot create RTCPeerConnection object.'); return; } pc.onaddstream = handleRemoteStreamAdded; pc.onremovestream = handleRemoteStreamRemoved; //... 完整代码如下 :: 'use strict'; const startButton = document.getElementById('startButton'); const stopButton = document.getElementById('stopButton'); const openButton = document.getElementById('openButton'); const closeButton = document.getElementById('closeButton'); stopButton.disabled = true; closeButton.disabled = true; startButton.addEventListener('click', startMedia); stopButton.addEventListener('click', closeConnection); openButton.addEventListener('click', join); closeButton.addEventListener('click', hangup); // Should use navigator.mediaDevices.getUserMedia of webrtc adapter // Clean-up function: // collect garbage before unloading browser's window window.onbeforeunload = function(e){ hangup(); } // Data channel information var sendChannel, receiveChannel; var sendButton = document.getElementById("sendButton"); var sendTextarea = document.getElementById("dataChannelSend"); var receiveTextarea = document.getElementById("dataChannelReceive"); // HTML5