WebRTC
一、WebRTC简介
WebRTC(Web Real-Time Communication)是一项实时通信技术,它允许 Web 浏览器之间进行实时的音频、视频和数据传输,无需安装任何插件。这项技术为 Web 应用开发者提供了强大的工具,使得在网页上实现视频通话、屏幕共享、文件传输等实时交互功能变得轻而易举,极大地拓展了 Web 应用的功能边界。
二、WebRTC的原理
(一)架构
WebRTC 的架构主要由三部分组成:
-
媒体处理层:负责处理音频和视频数据,包括编解码、降噪、回声消除、视频分辨率调整等操作,以确保媒体数据的质量和流畅性。
-
网络传输层:主要负责数据的传输,它使用 UDP 协议来实现低延迟的实时数据传输,并通过各种传输控制机制,如拥塞控制、丢包重传等,来保证数据在网络中的可靠传输。
-
信令层:用于在通信双方之间建立连接、交换媒体信息和控制信息等。信令的传输通常通过 HTTP 或 WebSocket 等协议来完成,它不负责传输媒体数据,而是为媒体数据的传输搭建通道。
(二)关键技术
-
编解码技术
-
音频编解码:WebRTC 支持多种音频编码格式,如 Opus、G.711 等。Opus 是一种高效的音频编码格式,它在低比特率下也能提供高质量的音频效果,并且具有很好的抗丢包性能,适用于实时通信场景。
-
视频编解码:常用的视频编码格式有 VP8、VP9 和 H.264 等。VP8 和 VP9 是开源的视频编码格式,具有良好的压缩性能和视觉质量,能够在不同的网络条件下提供流畅的视频播放体验。H.264 也是一种广泛应用的视频编码格式,它在压缩效率和图像质量方面表现出色,但在某些情况下可能需要支付专利费用。
-
-
网络传输技术
-
UDP 传输:WebRTC 选择 UDP 作为底层传输协议,是因为 UDP 具有低延迟、无连接的特点,适合实时数据的传输。然而,UDP 的不可靠性也带来了一些问题,如丢包、乱序等。为了解决这些问题,WebRTC 在 UDP 之上实现了自己的传输控制机制。
-
拥塞控制:WebRTC 采用了基于发送方的拥塞控制算法,如 Google Congestion Control(GCC)。GCC 通过监测网络的拥塞情况,动态调整发送端的发送速率,以避免网络拥塞,保证数据的流畅传输。
-
丢包重传:当发生丢包时,WebRTC 会根据丢包的情况和网络状况,决定是否进行重传。它采用了一些策略来减少重传对实时性的影响,如选择性重传、快速重传等。
-
-
信令技术
-
SDP(Session Description Protocol):SDP 用于描述媒体会话的参数,如媒体类型、编码格式、传输地址等。在 WebRTC 中,通信双方通过交换 SDP 消息来协商媒体会话的参数,确保双方能够正确地接收和处理媒体数据。
-
ICE(Interactive Connectivity Establishment):ICE 用于在不同网络环境下建立通信连接,它通过收集本地和远程的网络候选地址,并进行连接测试,找到最佳的传输路径。ICE 还支持 NAT 穿越,使得在不同 NAT 环境下的设备能够进行通信。
-
STUN(Session Traversal Utilities for NAT):STUN 用于获取本地设备在 NAT 后的公网地址和端口号,它通过向 STUN 服务器发送请求,获取相关的地址信息,为 ICE 的连接建立提供支持。
-
TURN(Traversal Using Relays around NAT):当 ICE 无法直接建立连接时,TURN 作为中继服务器,帮助通信双方转发数据。TURN 服务器通常部署在公网上,它接收来自客户端的数据,并将其转发到目标客户端,实现数据的传输。

-
(三)HTTPS证书
HTTPS 认证证书的下载方式因证书来源而异,主要分为自签名证书和第三方受信任证书
自签名证书
自签名证书主要用于开发和测试环境,由自己生成,不存在从外部下载的情况,而是通过 OpenSSL 工具自行生成。以下是生成步骤:
1. 安装 OpenSSL
-
Windows:从 OpenSSL 官方网站 下载并安装。
-
Linux:使用包管理器安装,如在 Ubuntu 上执行
sudo apt-get install openssl。 -
macOS:使用 Homebrew 安装,执行
brew install openssl。
2. 生成私钥
打开终端,执行以下命令生成一个 2048 位的 RSA 私钥文件 server.key:
openssl genpkey -algorithm RSA -out server.key -pkeyopt rsa_keygen_bits:2048
3. 生成证书签名请求(CSR)
执行以下命令生成 CSR 文件 server.csr,过程中会要求输入一些信息,如国家、组织、域名等,可按需填写:
openssl x509 -req -in server.csr -signkey server.key -out server.crt -days 365
4. 生成自签名证书
使用私钥和 CSR 生成自签名证书 server.crt,有效期通过 -days 参数指定,这里设置为 365 天:
openssl x509 -req -in server.csr -signkey server.key -out server.crt -days 365
第三方受信任证书
第三方受信任证书由受信任的证书颁发机构(CA)签发,适用于生产环境,以下是下载步骤:
1. 选择证书颁发机构
常见的 CA 有 Let's Encrypt(提供免费证书)、DigiCert、Comodo 等。
2. 申请证书
-
生成私钥和 CSR:与生成自签名证书的前两步相同,先生成私钥和 CSR 文件。
-
提交申请:将 CSR 文件提交给所选的 CA,部分 CA 会要求验证域名所有权,验证方式通常有 DNS 验证、文件验证、邮件验证等。
3. 下载证书
-
Let's Encrypt:可以使用 Certbot 工具来获取和管理 Let's Encrypt 证书。安装 Certbot 后,在终端执行相应命令(如
certbot --nginx用于 Nginx 服务器),Certbot 会自动完成证书的申请、下载和配置。证书文件通常存放在/etc/letsencrypt/live/your_domain/目录下,包含fullchain.pem(证书文件)和privkey.pem(私钥文件)。 -
其他商业 CA:申请通过后,登录 CA 的管理控制台,在相关页面找到已签发的证书,点击下载按钮。下载的证书文件可能有多种格式,如
.crt、.pem等,同时可能还需要下载中间证书。
(四)代码实现
基础界面实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>web</title>
<link rel="stylesheet" href="./assets//css/index.css">
</head>
<body>
<input id="roomId" type="text" value="" placeholder="请输入房间号">
<input id="userId" type="text" value="" placeholder="请输入用户名">
<button class="startBtn">开始通话</button>
<button class="stopBtn">暂停/恢复通话</button>
<button class="videoBtn">视频通话</button>
<button class="screenBtn">屏幕共享</button>
<div class="video-container">
<div class="video-wrapper">
<video autoplay controls muted id="offerVideo"></video>
<div class="user-name" id="localUserName"></div>
</div>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>
样式代码
body {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
input, button {
padding: 8px 15px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #45a049;
}
.video-container {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
video {
width: 400px;
height: 300px;
border-radius: 8px;
background-color: #f0f0f0;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.video-wrapper {
position: relative;
margin-bottom: 20px;
}
.user-name {
text-align: center;
padding: 5px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 14px;
}
JavaScript API
import { getLocalMediaStream, setLocalVideoStream,createPeerConnection, createVideoEle,setRemoteVideoStream,getLocalScreenMediaStream} from './utils/common.js'
import {io} from './utils/socket.io.esm.min.js'
const roomInput=document.querySelector('#roomId')
const userInput=document.querySelector('#userId')
const startBtn=document.querySelector('.startBtn')
const stopBtn=document.querySelector('.stopBtn')
const videoBtn=document.querySelector('.videoBtn')
const screenBtn=document.querySelector('.screenBtn')
let offerVideo=document.querySelector('#offerVideo')
let localStream=await getLocalMediaStream({video:true,audio:true})
setLocalVideoStream(offerVideo,localStream)
// 监听用户名输入变化,实时更新本地视频用户名
userInput.addEventListener('input', () => {
document.querySelector('#localUserName').textContent = userInput.value;
});
//房间人数是否已满
let isRoomRull=false
//房间号和用户名
let roomId
let userId
//socket对象
let client
let serverUrl='wss://192.168.43.104:3000/'
//第一次进入
let isInited=false;
/**
* @type {RTCPeerConnection}
*/
let peer
let isStopAudio=false
let isStopVideo=false
startBtn.addEventListener('click',()=>{
if(isRoomRull){
alert('房间人数已满')
return
}
roomId=roomInput.value
userId=userInput.value
// console.log(roomId,userId);
if(!client){
client=new io(serverUrl,{
reconnectDelayMat:10000,
transports:['websocket'],
query:{
roomId,
userId
}
})
client.on('connect',()=>{
console.log('Connection successful');
})
client.on('disconnect',()=>{
console.log('Connection disconnected');
})
client.on('error',()=>{
console.log('Connection error');
})
client.on('people-count-msg',async(count)=>{
console.log('count',count);
if(count==1){
isInited=true
}
peer=createPeerConnection()
localStream.getTracks().forEach(track=>{
peer.addTrack(track,localStream)
})
peer.onicecandidate=(event)=>{
if(event.candidate){
client.emit('candidate-msg',event.candidate)
}
}
peer.ontrack=(event)=>{
// 根据isInited状态确定远程用户名称
let remoteUserName = isInited ? count.userId : userId;
let videoEle=createVideoEle(2, remoteUserName)
setRemoteVideoStream(videoEle,event.track)
}
if(!isInited){
let offerSDP=await peer.createOffer()
await peer.setLocalDescription(offerSDP)
console.log("1. B 发送offer 给 A");
client.emit('offer-sdp-msg',offerSDP)
}
isInited=true
})
client.on('room-full',()=>{
alert('当前房间人数已满')
isRoomRull=true
return
})
//A会监听这个事件
client.on('offer-sdp-msg',async(offerSDP)=>{
console.log("2. A接受B的offer");
await peer.setRemoteDescription(offerSDP)
let answerSDP=await peer.createAnswer()
await peer.setLocalDescription(answerSDP)
//A和B已经收到B的offer-sdp-msg,所以需要把answerSDP发送给B
console.log("3.A发送answer");
client.emit('answer-sdp-msg',answerSDP)
})
//B
client.on('answer-sdp-msg',async(answerSDP)=>{
console.log("4. B接收A的answer");
await peer.setRemoteDescription(answerSDP)
})
client.on('candidate-msg',async(candidate)=>{
console.log('5. 交换candidate信息');
await peer.addIceCandidate(candidate)
})
}
})
stopBtn.addEventListener('click',()=>{
if(peer){
isStopAudio=!isStopAudio
isStopVideo=!isStopVideo
peer.getSenders().find(sender=>sender.track.kind==='audio').track.enabled=!isStopAudio;
peer.getSenders().find(sender=>sender.track.kind==='video').track.enabled=!isStopVideo;
}
})
videoBtn.addEventListener('click',async()=>{
let newStream=await getLocalMediaStream({
video:true,audio:true
});
if(newStream){
localStream=newStream
setLocalVideoStream(offerVideo,localStream)
//让通话用户也能接受到实时的画面
localStream.getVideoTracks().forEach(track=>{
peer.getSenders().find(sender=>sender.track.kind==='video').replaceTrack(track)
})
}
})
screenBtn.addEventListener('click',async()=>{
let newStream=await getLocalScreenMediaStream({
video:{
cursor:'always'|'never'|'motion',
displaySurface:'monitor'|'window'|'application'|'browser'
}
});
if(newStream){
localStream=newStream
setLocalVideoStream(offerVideo,localStream)
//让通话用户也能接受到实时的画面
localStream.getVideoTracks().forEach(track=>{
peer.getSenders().find(sender=>sender.track.kind==='video').replaceTrack(track)
})
}
})
公共函数
/**
* @param {MediaStreamConstraints} constraints
*/
// 导出一个异步函数,用于获取本地媒体流
export const getLocalMediaStream=async(constraints)=>{
try{
// 使用navigator.mediaDevices.getUserMedia方法获取本地媒体流
let stream=await navigator.mediaDevices.getUserMedia(constraints);
// 返回获取到的媒体流
return stream
}catch(error){
// 如果获取媒体流失败,则打印错误信息
console.log("error",error);
}
}
/**
* 获取共享屏幕数据流
* @param {MediaStreamConstraints} constraints
*/
// 导出一个异步函数,用于获取本地屏幕媒体流
export const getLocalScreenMediaStream=async(constraints)=>{
try{
// 使用navigator.mediaDevices.getDisplayMedia方法获取屏幕媒体流
let stream=await navigator.mediaDevices.getDisplayMedia(constraints);
// 返回获取到的媒体流
return stream
}catch(error){
// 如果获取媒体流失败,则打印错误信息
console.log("error",error);
}
}
/**
* @param {HTMLVideoElement} ele
* @param {MediaStream} newStream
*/
// 导出一个异步函数,用于设置本地视频流
export const setLocalVideoStream=async(ele,newStream)=>{
// 如果ele存在
if(ele){
// 获取ele的旧视频流
let oldStream=ele.srcObject;
// 如果旧视频流存在
if(oldStream){
//videoTrack和audioTrack
oldStream.getTracks().forEach(track=>{
oldStream.removeTrack(track)
})
}
ele.srcObject=newStream;
}
}
/**
* @params {HTMLVideoElement} ele
* @param {MediaStreamTrack} track
*/
// 导出一个函数,用于设置远程视频流
export const setRemoteVideoStream=(ele,track)=>{
// 如果ele存在
if(ele){
// 获取ele的srcObject
let stream=ele.srcObject;
// 如果stream存在
if(stream){
// 将track添加到stream中
stream.addTrack(track)
}else{
// 否则创建一个新的MediaStream
let newStream=new MediaStream();
// 将track添加到新的MediaStream中
newStream.addTrack(track)
// 将新的MediaStream赋值给ele的srcObject
ele.srcObject=newStream
}
}
}
export const createPeerConnection=()=>{
const peer=new RTCPeerConnection({
/**
*
* bundlePolicy:这个选项用于控制ICE候选信息的捆绑策略,在这个例子中,它被注释掉了,所以默认值为 "balanced"。这意味着如果可能,会尝试所有的ICE候选信息捆绑在一起发送,以减少网络延迟
*
* rtcpMuxPolicy:这个选项用于控制是否使用RTP/Rtcp多路复用。这这个例子中,它也被注释掉了,所以默认值为 "require"。这意味着如果浏览器支持RTP/Rtcp多路复用,那么就会使用它,否则,将不会使用它。
*
* iceTransportPolicy:这个选项用于控制ICE传输策略。在这个例子中,它被设置为 "relay",表示强制服务器转发所有的TCE候选信息,这在某些情况下可能是有用的,例如当服务器位于NAT后面时
*
* iceServers:这是一个数组,包含了ICE服务器的信息。在这个例子中,只有一个ICE服务器,其URL为 "stun:stun.l.google.com:19302"。这是一个公共的ICE服务器,用于处理NAT穿透和端口映射
*/
// bundlePolicy:"max-bundle",
// rtcpMuxPolicy:"require",
// iceTransportPolicy:"relay",
// iceServers:[
// {urls:'stun:stun.l.google.com:19302'}
// ]
});
return peer
}
// 导出一个函数,用于创建视频元素
export const createVideoEle=(count, userName)=>{
// 获取视频容器元素
let video_container=document.querySelector('.video-container');
// 创建一个视频包裹元素
let videoWrapper = document.createElement('div');
// 设置视频包裹元素的类名
videoWrapper.className = 'video-wrapper';
/**
* @type {HTMLVideoElement}
*/
// 获取指定id的视频元素
let video=document.querySelector('#video'+count)
// 如果没有找到指定id的视频元素
if(!video){
// 创建一个视频元素
video=document.createElement('video')
// 设置视频元素为静音
video.muted=true
// 设置视频元素自动播放
video.autoplay=true
// 设置视频元素显示控制条
video.controls=true
// 设置视频元素的id
video.id='video'+count
// 创建一个用户名元素
let userNameDiv = document.createElement('div');
// 设置用户名元素的类名
userNameDiv.className = 'user-name';
// 设置用户名元素的文本内容
userNameDiv.textContent = userName;
// 将视频元素和用户名元素添加到视频包裹元素中
videoWrapper.appendChild(video);
videoWrapper.appendChild(userNameDiv);
// 将视频包裹元素添加到视频容器元素中
video_container.appendChild(videoWrapper)
}
// 返回视频元素
return video
}
项目入口
// 导入所需的核心模块
import express from 'express' // Express框架,用于创建Web服务器
import fs from 'fs' // 文件系统模块,用于读取SSL证书文件
import path from 'path' // 路径处理模块
import https from 'https' // HTTPS服务器模块
import {Server} from 'socket.io' // WebSocket服务器,用于实时通信
// 导入自定义工具和服务模块
import {getIpAddress} from './utils/common.js' // 获取本机IP地址的工具函数
import {initSDPServer} from './server/sdp.js' // WebRTC信令服务器初始化模块
// import {initSDPServer} from './server/sdp2.js' // 多对多通信的信令服务器(已注释)
// SSL证书配置,用于HTTPS服务器
const options = {
key: fs.readFileSync(path.resolve('./ssl/server.key')), // 私钥文件
cert: fs.readFileSync(path.resolve('./ssl/server.crt')) // 证书文件
}
// 创建Express应用实例
const app = express()
// 配置静态文件服务,将public目录下的文件作为静态资源提供
app.use(express.static('./public'))
// 创建HTTPS服务器
const httpsServer=https.createServer(options,app)
// 创建WebSocket服务器实例
// allowEIO3: 允许Engine.IO 3版本客户端连接
// cors: 启用跨域资源共享
const io=new Server(httpsServer,{allowEIO3:true,cors:true})
// 初始化WebRTC信令服务器
initSDPServer(io)
// 启动服务器,监听3000端口
httpsServer.listen(3000,()=>{
// 获取服务器访问地址,如果无法获取IP则显示"网络不可用"
let str=getIpAddress()?`https://${getIpAddress()}:3000`:'网络不可用';
console.log(`服务器启动成功,访问地址:${str}`)
})
信令服务器
import { Server } from "socket.io";
// 用于存储房间信息的Map,key为房间ID,value为房间内的人数
const roomMap=new Map()
/**
* @param {Server} io - Socket.io服务器实例
* 初始化WebRTC信令服务器
*/
export const initSDPServer=(io)=>{
// 监听客户端连接事件
io.on("connection",(socket)=>{
onEvent(socket)
})
}
/**
* 处理所有socket相关的事件
* @param {Socket} socket - 客户端的socket连接实例
*/
const onEvent=(socket)=>{
// 从socket请求中获取房间ID和用户ID
let {roomId,userId}=socket.request._query
// 房间管理:如果房间不存在则创建,存在则人数+1
if(!roomMap.get(roomId)){
roomMap.set(roomId,1)
}else{
roomMap.set(roomId,roomMap.get(roomId)+1)
}
console.log('新用户加入:房间人数',roomMap.get(roomId));
// 监听用户断开连接事件
socket.on('disconnect',()=>{
console.log('Client disconnect');
// 通知房间内其他用户有人离开
socket.to(roomId).emit('client-leave',userId+':leave')
// 更新房间人数
roomMap.set(roomId,roomMap.get(roomId)-1)
console.log('用户离开:房间总人数',roomMap.get(roomId));
})
// 房间人数限制:最多2人
if(roomMap.get(roomId)>2){
socket.emit('room-full',true)
return
}
// 将用户加入指定房间
socket.join(roomId)
// 向当前用户发送房间人数信息
socket.emit('people-count-msg',{count: roomMap.get(roomId), userId})
// 向房间内其他用户广播人数信息
socket.to(roomId).emit('people-count-msg',{count: roomMap.get(roomId), userId})
// WebRTC信令相关事件处理
// 处理offer信令:当B加入房间时,转发B的offer给A
socket.on('offer-sdp-msg',(offerSDP)=>{
socket.to(roomId).emit('offer-sdp-msg',offerSDP)
})
// 处理answer信令:转发A的answer给B
socket.on('answer-sdp-msg',(answerSDP)=>{
socket.to(roomId).emit('answer-sdp-msg',answerSDP)
})
// 处理ICE候选者信息:在双方之间转发ICE候选者
socket.on('candidate-msg',(candidate)=>{
socket.to(roomId).emit('candidate-msg',candidate)
})
}

1204

被折叠的 条评论
为什么被折叠?



