考虑到后续增加平台直播的可能性,笔记记录一下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建立连接:接收到offer
if (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_candidate
if (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 候选者交换,共同实现了观众与主播之间的实时通信和直播功能。