考虑到后续增加平台直播的可能性,笔记记录一下WebRTC相关.
让我们分别分析两种情况下的WebRTC连接建立过程:
情况一:AB之间可以直接通信
1.信令交换:
设备A和设备B首先通过信令服务器交换SDP(Session Description Protocol)信息和候选者(candidates)。SDP包含有关会话的信息,包括设备的媒体能力和网络地址。
2.ICE框架协商:
设备A和设备B使用ICE框架收集本地候选者(本地网络地址),然后交换候选者信息,包括通过STUN服务器获取的公共IP地址和端口号。
3.连接建立:
根据ICE框架收集的候选者信息,设备A和设备B尝试直接建立点对点连接。根据候选者优先级排序,选择最佳的候选者进行连接。
4.直接通信:
如果ICE框架成功建立了连接,设备A和设备B之间可以直接进行实时通信,例如音频、视频或数据传输。
情况二:AB之间不能直接通信
1.信令交换:
设备A和设备B通过信令服务器交换SDP信息和候选者。
2.ICE框架协商:
设备A和设备B分别收集本地候选者,并将候选者信息发送给对方。
3.无法直接建立连接:
如果ICE框架无法直接建立连接(例如由于双方都位于NAT环境或防火墙后),则ICE框架会返回无法建立连接的错误或超时。
4.使用TURN服务器:
在无法直接建立连接的情况下,设备A和设备B将使用TURN服务器作为中继器来中转数据流。设备A和设备B分别连接到TURN服务器,并将数据流通过TURN服务器进行中转,从而实现对等通信。
什么情况下AB不能直接建立点对点连接?
双方位于不同的私有网络:如果设备A和设备B都处于不同的私有网络(例如家庭网络),则无法直接访问对方的局域网地址。
AB之间无法直接建立点对点连接的情况通常是由于网络地址转换(NAT)或防火墙的存在,导致设备无法直接接收对方发送的数据包。具体情况包括:NAT类型限制:如果设备A或设备B位于对称NAT或受限NAT网络中,会导致UDP包的发送和接收出现问题,从而无法建立直接连接。防火墙限制:防火墙可以阻止对UDP或特定端口的访问,这会影响设备之间的直接通信。公共IP地址不可用:有些设备可能没有公共IP地址,而是通过NAT路由器共享局域网IP地址。
在这些情况下,WebRTC利用ICE框架和STUN/TURN服务器来实现网络穿透,确保设备之间可以建立可靠的实时通信连接。通过STUN服务器获取公共网络地址,通过TURN服务器进行数据中转,解决了设备间无法直接通信的问题。
附一下个人直播间搭建部分代码
<html><head><title>简单直播间</title><style type="text/css">body {background: #888888 center center no-repeat;color: white;}button {cursor: pointer;user-select: none;}.room {border: 1px solid black;cursor: pointer;user-select: none;text-align: center;background: rgba(0, 0, 0, 0.5);}video {width: 75%;border: 2px solid black;border-image: linear-gradient(#F80, #2ED) 20 20;}#chat {position: fixed;top: 5px;right: 5px;width: calc(25% - 30px);height: calc(75% - 10px);background: rgba(0, 0, 0, 0.5);}.content {height: calc(100% - 66px);margin-top: 5px;margin-bottom: 5px;overflow-y: scroll;}#chatSend {white-space: nowrap;}#chatSend>input {width: calc(100% - 90px);}#chatSend>button {width: 80px;}#chatTag {margin-left: 10px;line-height: 30px;}.chatKuang {border: 1px solid white;margin: 3px;border-radius: 5px;}.hintKuang {margin: 3px;text-align: center;}</style>
</head><body><div id="create">名称:<input id="name" type="text" /><button onclick="createRoom()">创建直播间</button><span id="count"></span></div><video id="localVideo" autoplay controls="controls"></video><br /><div id="chat"><div id="chatTag"><button onclick="changeTag(0)">房间列表</button><button onclick="changeTag(1)">房间聊天</button></div><div id="roomContent" class="content"></div><div id="chatContent" class="content" style="display: none"></div><div id="chatSend"><input type="text" /><button onclick="chatSend()">发送</button></div></div><script type="text/javascript">// 背景图片(function() {var img = new Image();img.addEventListener("load", function() {document.querySelector("body").style.background ="url('" + this.src + "') center center no-repeat";let _img = this;calculateBackgroundImageScale(_img);window.onresize = function(_img) {calculateBackgroundImageScale(_img);}});img.src = "https://parva.cool/share/sky043.jpg";})();//计算背景图片缩放(自适应窗口大小)function calculateBackgroundImageScale(img) {let w1 = document.body.clientWidth;let h1 = document.body.clientHeight;let w2 = img.width;let h2 = img.height;let scale1 = w1 / w2;let scale2 = h1 / h2;let scale = scale1 > scale2 ? scale1 : scale2;document.querySelector("body").style.backgroundSize =Math.ceil(w2 * scale) + "px " + Math.ceil(h2 * scale) + "px";}// 存储本地媒体流var localStream;// 与服务器的websocket通信var socket = new WebSocket("wss://parva.cool/rtc");// 判断自己是否正在直播var isMe;// 当前的标签页(0:房间列表, 1:房间聊天)var tag = 0;// 发送聊天信息function chatSend() {let input = document.querySelector("#chatSend input");let msg = input.value;input.value = "";if (Object.keys(pcs).length == 0) return;if (isMe) {socket.send(JSON.stringify({ event: "chatSend", msg: msg }));} else {socket.send(JSON.stringify({event: "chatSend",msg: msg,roomName: Object.keys(pcs)[0]}));}}// 切换标签function changeTag(t) {tag = t;document.querySelector("#roomContent").style.display = "none";document.querySelector("#chatContent").style.display = "none";if (tag == 0) {document.querySelector("#roomContent").style.display = "block";} else if (tag == 1) {document.querySelector("#chatContent").style.display = "block";}}// 创建直播间function createRoom() {let roomName = document.querySelector("#name").value;if (!localStream) {navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then((stream) => {localStream = stream;socket.send(JSON.stringify({event: "createRoom",roomName: roomName}));});} else {socket.send(JSON.stringify({event: "createRoom",roomName: roomName}));}}// 关闭直播间function closeRoom() {socket.send(JSON.stringify({ event: "closeRoom" }));document.querySelector("input").disabled = false;document.querySelector("button").innerHTML = "创建直播间";document.querySelector("button").setAttribute("onclick","createRoom()");document.querySelector("video").srcObject = null;pcs = {};let content = document.querySelector("#chatContent");content.innerHTML = "";changeTag(0);let count = document.querySelector("#count");count.innerHTML = "";localStream.getTracks()[0].stop();}// 进入直播间var 防止双击;var 防止双击setTimeout;function joinRoom(roomName) {if (防止双击 == roomName) return;else 防止双击 = roomName;clearTimeout(防止双击setTimeout);防止双击setTimeout = setTimeout(function() { 防止双击 = ""; }, 800);let name = document.querySelector("#name").value;socket.send(JSON.stringify({event: "joinRoom",name: name,roomName: roomName}));}// 接收服务器的消息socket.onmessage = function(event) {let json = JSON.parse(event.data);// 接收聊天信息if (json.event === "chat") {let chatName = document.createElement("span");chatName.innerHTML = json.name + " : ";chatName.setAttribute("class", "chatName");let chatMessage = document.createElement("span");chatMessage.innerHTML = json.msg;chatName.setAttribute("class", "chatMessage");let chatKuang = document.createElement("div");chatKuang.setAttribute("class", "chatKuang");chatKuang.appendChild(chatName);chatKuang.appendChild(chatMessage);let content = document.querySelector("#chatContent");content.appendChild(chatKuang);content.scrollTop = 9999999;}// 通知新人加入房间if (json.event === "joinHint") {let hintKuang = document.createElement("div");hintKuang.setAttribute("class", "hintKuang");hintKuang.innerHTML = json.name + "加入直播房间!"let content = document.querySelector("#chatContent");content.appendChild(hintKuang);content.scrollTop = 9999999;}// 有人退出当前房间if (json.event === "quitHint") {let hintKuang = document.createElement("div");hintKuang.setAttribute("class", "hintKuang");hintKuang.innerHTML = json.name + "退出房间.."let content = document.querySelector("#chatContent");content.appendChild(hintKuang);content.scrollTop = 9999999;}// 接收房间人数if (json.event === "count") {let count = document.querySelector("#count");count.innerHTML = "\t在场众神数量 : " + json.count;}// 所有房间的信息if (json.event === "roomsInfo") {if (tag != 0) return;let content = document.querySelector("#roomContent");content.innerHTML = "";for (let i = 0; i < json.info.length; i++) {let div = document.createElement("div");div.innerHTML = json.info[i];//点击进入房间事件捆绑div.setAttribute("onclick", "joinRoom('" + json.info[i] + "')");div.setAttribute("class", "room");content.appendChild(div);}}// 创建房间失败if (json.event === "createRoomFailed") {alert("创建房间失败,名称已存在 或 名称格式有误");localStream.getTracks()[0].stop();}// 创建房间成功if (json.event === "createRoomOk") {document.querySelector("input").disabled = true;document.querySelector("button").innerHTML = "关闭直播间";document.querySelector("button").setAttribute("onclick","closeRoom()");//将捕捉的本地媒体流赋值到直播窗口document.querySelector("video").srcObject = localStream;document.querySelector("video").volume = 0;isMe = true;changeTag(1);let hintKuang = document.createElement("div");hintKuang.setAttribute("class", "hintKuang");hintKuang.innerHTML = "创建直播房间成功!"let content = document.querySelector("#chatContent");content.appendChild(hintKuang);content.scrollTop = 9999999;}// 加入房间失败if (json.event === "joinRoomFailed") {alert("加入房间失败,名称已存在 或 名称格式有误");}// 主播下播,退出房间if (json.event === "roomClosed") {document.querySelector("video").srcObject = null;pcs = {};let content = document.querySelector("#chatContent");content.innerHTML = "";changeTag(0);document.querySelector("input").disabled = false;let count = document.querySelector("#count");count.innerHTML = "";}// 有人欲加入我的直播间if (json.event === "joinRoom") {// 为对方创建一个pc实例,并发送offer给他var pc = createRTCPeerConnection(json.name);// rtc建立连接:创建一个offer给对方pc.createOffer(function(desc) {pc.setLocalDescription(desc);socket.send(JSON.stringify({event: "_offer",data: {sdp: desc,nameB: json.name}}));}, function(error) {console.log("CreateOffer Failure callback: " + error);});}// rtc建立连接:接收到offerif (json.event === "_offer") {// 为对方创建一个pc实例,并发送offer给他var pc = createRTCPeerConnection(json.data.nameA);pc.setRemoteDescription(new RTCSessionDescription(json.data.sdp));// rtc建立连接:创建一个answer给对方pc.createAnswer(function(desc) {pc.setLocalDescription(desc);socket.send(JSON.stringify({event: "_answer",data: {sdp: desc,nameA: json.data.nameA}}));}, function(error) {console.log("CreateAnswer Failure callback: " + error);});}// rtc建立连接:接收到answer --A收到B的answer// 将来自对方的 Answer SDP 设置为本地 WebRTC 连接的远程描述,以便双方能够正确地理解和处理对方的媒体数据,从而建立成功的实时通信连接。if (json.event === "_answer") {pcs[json.data.nameB].setRemoteDescription(new RTCSessionDescription(json.data.sdp));}// rtc建立连接:接收到_ice_candidateif (json.event === "_ice_candidate") {pcs[json.data.from].addIceCandidate(new RTCIceCandidate(json.data.candidate));}}// Q:一对多直播情况下 直播用户A的远程描述需要怎么变化?// A:连接都是独立的 分别设置对应的远端描述符 A的描述符不变// 存储pc实例var pcs = {};// stun和turn服务器URL及配置var iceServer = {iceServers: [{ urls: "stun:parva.cool:3478" },{urls: "turn:parva.cool:3478",username: "parva",credential: "Parva089"}]};// 创建RTCPeerConnection实例function createRTCPeerConnection(name) {let pc = new RTCPeerConnection(iceServer);if (!isMe) {for (let n in pcs) pcs[n].close();pcs = {};}// 以{对方的名字:PC实例}键值对形式把PC实例存储起来pcs[name] = pc;if (localStream) pc.addStream(localStream);else pc.addStream(new MediaStream());pc.onicecandidate = function(event) {if (event.candidate !== null)socket.send(JSON.stringify({event: "_ice_candidate",data: {candidate: event.candidate,to: Object.keys(pcs).find(k => pcs[k] == pc)}}));}pc.ontrack = function(event) {if (isMe) return;changeTag(1);document.querySelector("video").srcObject = event.streams[0];document.querySelector("input").disabled = true;let hintKuang = document.createElement("div");hintKuang.setAttribute("class", "hintKuang");hintKuang.innerHTML = "成功进入直播间!"let content = document.querySelector("#chatContent");content.innerHTML = "";content.appendChild(hintKuang);content.scrollTop = 9999999;}return pc;}</script>
</body></html>
建立 BC 和 A 之间的 WebRTC 连接涉及以下步骤和流程:
前提条件:
A 是直播主播,已经在直播间创建了媒体流并开始直播。
B 和 C 是观众,希望加入直播间并观看 A 的直播。
建立 WebRTC 连接的过程:
1.B 或 C 加入直播间:
B 或 C 在前端页面选择要加入的直播间并点击加入按钮。
前端通过 WebSocket 向服务器发送加入房间的请求,包括用户信息和房间名称。
2.服务器收到加入房间请求:
后端服务器接收到 B 或 C 的加入房间请求,将其添加到对应房间的用户列表中。
3.A 发送 WebRTC offer 给 B 或 C:
当 B 或 C成功加入房间后,后端服务器会通知 A(主播)有新用户加入了直播间。
A 在前端收到加入房间的通知后,使用 WebRTC 创建一个 PeerConnection 对象(pc),并生成一个 SDP offer。
A 将这个 SDP offer 通过 WebSocket 发送给服务器。
4.服务器转发 WebRTC offer 给 B 或 C:
后端服务器收到 A 发送的 SDP offer。
后端服务器将 SDP offer 转发给房间内除 A 之外的其他用户(即 B 或 C)。
5.B 或 C 接收 WebRTC offer:
B 或 C 前端收到 A 发送的 SDP offer。
B 或 C 使用 WebRTC 创建一个 PeerConnection 对象(pc),并设置 A 的 SDP offer 作为远端描述(setRemoteDescription)。
6.B 或 C 创建 WebRTC answer 给 A:
B 或 C 使用自己的本地媒体流(视频和音频)创建一个 SDP answer。
B 或 C 将这个 SDP answer 发送给服务器。
7.服务器转发 WebRTC answer 给 A:
后端服务器收到 B 或 C 发送的 SDP answer。
后端服务器将 SDP answer 转发给 A。
8.A 接收 WebRTC answer:
A 前端收到 B 或 C 发送的 SDP answer。
A 设置 B 或 C 的 SDP answer 作为远端描述(setRemoteDescription)。
9.ICE 候选者交换:
PeerConnection 开始收集和交换 ICE 候选者信息(网络地址、端口等)。
10.B、C 和 A 通过服务器交换 ICE 候选者信息,以便彼此建立直接的通信路径。
建立直播观看连接:
完成以上步骤后,B 或 C 和 A 之间的 WebRTC 连接就建立起来了。
B 或 C 的本地媒体流通过 ICE 候选者的协商直接传输到 A,A 的直播内容会被 B 或 C 观看。
总结:
通过以上步骤,B 或 C 可以加入 A 创建的直播间,并与 A 建立起 WebRTC 连接,实现实时的音视频传输和观看直播内容。整个过程涉及前端的用户交互和媒体流处理,以及后端的 WebRTC 信令传递和 ICE 候选者交换,共同实现了观众与主播之间的实时通信和直播功能。