前言:叮叮当,叮叮当,铃儿响叮当。 全文分为两种方式去实现视频通话或者说是直播。
技术选型
1、P2P实现
2、流媒体服务实现
流媒体服务实现
1、nodejs-media-server(经典版)
2、mediasoup(流行版)
区别
Mediasoup和Node.js Media Server都是基于Node.js的开源流媒体服务器框架,用于构建实时通信应用和流媒体处理系统。
- 架构和设计理念:Mediasoup采用了可扩展的SFU(Selective Forwarding Unit)架构,它将音视频流转发给参与者而不进行混流。这种架构适用于实时通信应用,如视频会议、直播和WebRTC应用。Node.js Media Server则更加灵活,可以用于构建不同类型的流媒体应用,包括录制、转码和实时处理。
- WebRTC支持:Mediasoup专注于提供高性能、稳定的WebRTC通信。它通过WebRTC技术实现了低延迟、高质量的音视频通信,并提供了丰富的API和功能来管理和控制通信会话。Node.js Media Server也支持WebRTC,但不像Mediasoup那样专注于WebRTC通信。
- API和功能:Mediasoup提供了丰富的API和功能来管理WebRTC通信,包括处理音视频流、音频混音、流量控制等。它提供了灵活的路由策略和媒体处理能力。Node.js Media Server也提供了一些流媒体处理功能,如录制、转码和实时处理,但功能较Mediasoup更为全面。
- 社区和生态系统:Mediasoup拥有活跃的开发者社区和强大的生态系统,提供了广泛的文档、示例和插件支持。Node.js Media Server相对较小的社区。
需要根据具体的应用需求和技术要求来选择合适的框架。如果你需要构建实时通信应用,并且专注于WebRTC通信,Mediasoup可能更适合。如果你需要更广泛的流媒体处理功能,如录制、转码和实时处理,Node.js Media Server可能更适合。
P2P 实现
1、WebRtc 对等通信
特点
- 低延迟:WebRTC P2P通信允许直接在浏览器之间建立点对点的连接,绕过传统的中心化服务器中转。这减少了通信路径的长度和延迟,使得实时通信应用能够实现更低的延迟。
- 高带宽利用率:在传统的中心化服务器模型中,所有数据流都必须通过服务器进行中转,这会增加服务器的负载和网络带宽需求。WebRTC P2P通信允许直接在对等方之间传输数据,减少了对服务器带宽的依赖,提高了带宽利用率。
- 增强的隐私和安全性:由于WebRTC P2P通信直接在对等方之间建立连接,而不需要通过中间服务器,因此减少了数据在网络中的暴露风险。这提供了更好的隐私和安全性,特别是在敏感数据或实时通信场景中。
- 可扩展性:WebRTC P2P通信允许直接在对等方之间建立多对多的连接,这意味着通信可以同时在多个对等方之间进行。这提供了更好的可扩展性,可以支持更多的参与者同时进行实时通信。
- 减少服务器成本:通过使用WebRTC P2P通信,可以减少对中心化服务器的依赖,从而降低服务器的成本和维护复杂性。对于规模较小的应用,可能可以完全避免使用中心化服务器。
- 麻烦:在涉及对称NAT的网络环境中,通过WebRTC P2P建立连接可能需要使用技术如STUN(会话穿越实用工具)和TURN(转发用户数据)服务器来帮助中转数据
关键
接下来会使用三种不同的方式去实现 视频通话/直播 的功能。
P2P实现
先看图,搂一眼大概就好
太多理论就不在这里赘述了,因为可以google查到。我简单的用故事描述一下大致原理。
A特工想和B特工说一些机密情报 要讲上一天一夜,然后A特工就在黑板上(信令服务器)写了特殊暗语,B特工就找到了特殊暗语,并在下面回复了暗语表达了内心想听情报的诉求。但是B特工不知道A住在哪,A特工就说我在渣渣银行某个保险柜里放了我的地址,你拿着我给你的暗号就能知道我的地址,然后B特工就去了银行(Ice服务器)找到了A特工的地址,然后双方就进行了友好的交流。
故事讲的稀烂,希望能知道个大概,下面有专业版本的解释,不抄进来是不想让文章篇幅太长。🔗链接在此。接下来,开干。
ICE服务器(turn服务器 & stun服务)
为什么需要turn服务呢?文字太长我就不抄了 👉点击链接👈
stun服务 一般用的就是google提供的
stun:stun.l.google.com:19302
但是turn服务器需要自己搭建,我使用的是华为云服务器,操作系统是Ubuntu。
第一步 首先安装 coturn
sudo apt-get update -y
sudo apt-get install coturn
第二步 编辑配置文件
sudo vim /etc/default/coturn
// 修改配置文件中 这一行代码 取消注释即可
TURNSERVER_ENABLED=1
第三步 用systemctl启动coturn服务
systemctl start coturn
第四步 防火墙允许端口
sudo ufw allow 3478
第五步 配置coturn
sudo vim /etc/turnserver.conf
第六步 完整配置如下
#Turn server name and realm 这个名字要用的
realm=bain
server-name=turnantalk
# 写你的服务器的IP地址
listening-ip=0.0.0.0
external-ip=116.205.246.245
# Use fingerprint in TURN message
fingerprint
# Specify the user for the TURN authentification <你的用户名:自己设的密码>
user=bain:999999
lt-cred-mech
# Main listening port
listening-port=3478
# Further ports that are open for communication
min-port=10000
max-port=20000
# Log file path
log-file=/home/ubuntu/server/turn/turnserver.log
# Enable verbose logging
verbose
# Enable long-term credential mechanism
lt-cred-mech
# If running coturn version older than 4.5.2, uncomment these rules and ensure
# that you have listening-ip set to ipv4 addresses only.
# Prevent Loopback bypass https://github.com/coturn/coturn/security/advisories/GHSA-6g6j-r9rf-cm7p
#denied-peer-ip=0.0.0.0-0.255.255.255
#denied-peer-ip=127.0.0.0-127.255.255.255
#denied-peer-ip=::1
第七步 重启
sudo service coturn restart
如何验证呢? 使用 👉 验证地址
没报错就行啦,如下图。
那如果不行怎么办呢?
先试试能不能ping通机器。
排查下:华为云服务器这边是需要对安全组规则进行设置,入出方向规则进行放开。
信令服务器
我选的技术栈是 egg+egg-socket。
设计这个信令服务器需要考虑这么几个事情。
他的作用:
1、做一些校验
2、开房间
3、对事件进行转发
校验
其实是偏向于业务测,就是比如房间人数限制,参数校验等等。 对于实际应用影响不大,就简单提一下。
举例: /middleware/connection.js
/**
* 在每一个客户端连接或者退出时发生作用
* @param app
* @returns {(function(*, *): Promise<void>)|*}
*/
module.exports = (app) => {
return async (ctx, next) => {
const { room } = ctx.socket.handshake.query;
if (!room) {
return ctx.socket.emit('error','无房间号')
}
const clientsInRoom = app.io.sockets.adapter.rooms[room];
const numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
// 限制房间人数
if(numClients >= 2){
return ctx.socket.emit('full',room)
}
await next();
};
};
开房间 对事件进行转发
class ChatController extends Controller {
async ping() {
const message = this.ctx.args[0];
await this.ctx.socket.emit("res", `Hi! I've got your message: ${message}`);
}
async message(){
const message = this.ctx.args[0];
switch (message.action){
case 'join': return this.handleJoin();
case 'leave': return this.disconnect();
case 'offer': return this.handleSend(message);
case 'answer': return this.handleSend(message);
case 'candidate': return this.handleSend(message);
default: return this.ping();
}
}
async handleSend({action,data}){
const { room, userId } = this.ctx.socket.handshake.query;
this.ctx.logger.info(`type:${action}-时间戳${Date.now()}`)
this.ctx.socket.broadcast.to(room).emit(action, { data });
}
async handleJoin(){
const { room, userId } = this.ctx.socket.handshake.query;
// 加入房间
this.ctx.socket.join(room);
// 单线程没问题 线上环境最好使用redis 之类
if(!this.ctx.app.rooms){
this.ctx.app.rooms = {}
}
if(!this.ctx.app.rooms[room]){
return this.ctx.app.rooms[room] = {
[userId]: {}
}
}
this.ctx.app.rooms[room][userId] = {};
// new people 有新人加入房间
this.ctx.socket.broadcast.to(room).emit('new-people', { userId });
}
async disconnect() {
const { room, userId } = this.ctx.socket.handshake.query;
this.ctx.logger.info(`room:${room}- userId:${userId}- 时间戳${Date.now()}`)
this.ctx.socket.broadcast.to(room).emit('leave', { msg: userId });
}
}
本文未对 媒体协商、网络协商、offer、answer、candidate 等 专业名词进行过多的解释,主要是文字内容过多,知识细节太多。所以,看这里👉链接👈。
以下是前端核心部分,
export class VideoControl {
constructor(props) {
this.client = null; // 房间
this.socket = null;
this.local = {
pc: null,
stream: null,
elementId: props.locale,
};
this.remote = {
pc: null,
elementId: props.remote,
stream: null,
uid: null,
};
}
//获取权限 音频权限 视频权限
getUserDevicePermissions() {
return navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
// 用户已授权访问摄像头和麦克风
// 可以使用stream对象进行进一步的操作
this.isAuthorized = true;
// 成功获取到视频流
const videoDom = document.createElement("video");
videoDom.style.height = "100%";
videoDom.style.width = "100%";
videoDom.srcObject = stream;
this.local.stream = stream;
document.getElementById(this.local.elementId).append(videoDom);
videoDom.play();
})
.catch((error) => {
// 用户拒绝了访问权限或发生了错误
// 可以在此处处理错误情况
this.isAuthorized = false;
});
}
//初始化
async init() {
// 获取摄像头
this.getCameras();
this.connectWs();
}
// 建立ws链接
connectWs() {
const socket = io("ws://127.0.0.1:7001", {
query: {
room: "demo",
userId: `client_${Math.random()}`,
},
forceNew: false,
transports: ["websocket", "polling"],
reconnectionDelay: 5000,
autoConnect: true,
reconnection: true,
secure: true,
});
this.socket = socket;
// 监听连接成功事件
socket.on("connect", () => {
console.log("Connected to the server");
// 执行一些初始化操作或显示连接成功的提示
});
socket.on("new-people", ({ userId }) => {
//有新人加入
this.remote.uid = userId;
this.getOffer();
});
socket.on("offer", ({ data }) => {
this.handleRemoteOffer(data);
});
socket.on("leave", ({ data }) => {
this.handleLeave();
});
socket.on("answer", ({ data }) => {
this.handleAnswer(data);
});
socket.on("candidate", ({ data }) => {
this.handleIceCandidate(data);
});
socket.on("disconnect", () => {
this.handleLeave();
});
//重新连接时,将transports选项重置为Websocket
socket.on("reconnect_attempt", () => {
socket.io.opts.transports = ["polling", "websocket"];
});
}
// 获取offer sdp
getOffer() {
// 获取实例
const pc = this.createRTCPeerConnection();
pc.createOffer()
.then((offer) => {
return pc.setLocalDescription(offer);
})
.then(() => {
this.socket.emit("chat", {
action: "offer",
data: pc.localDescription,
});
});
}
async handleRemoteOffer(offer) {
const pc = this.createRTCPeerConnection();
const remoteOffer = new RTCSessionDescription(offer);
// 设置远程描述
await pc.setRemoteDescription(remoteOffer);
// 创建 answer
const answer = await pc.createAnswer();
// 设置本地描述
await pc.setLocalDescription(answer);
// 发送 answer 给对方
this.socket.emit("chat", { action: "answer", data: answer });
}
// 处理收到的 answer
async handleAnswer(answer) {
const remoteAnswer = new RTCSessionDescription(answer);
// 设置远程描述
await this.remote.pc.setRemoteDescription(remoteAnswer);
}
// 处理收到的 ICE 候选者
async handleIceCandidate(candidate) {
const iceCandidate = new RTCIceCandidate(candidate);
// 添加 ICE 候选者到连接
this.remote.pc.addIceCandidate(iceCandidate);
}
// 共享屏幕
shareScreen() {
// 获取屏幕共享流
navigator.mediaDevices
.getDisplayMedia({ video: true, audio: true })
.then((stream) => {
// 在这里处理获取到的屏幕共享流
const videoDom = document.createElement("video");
videoDom.style.height = "100%";
videoDom.style.width = "100%";
videoDom.srcObject = stream;
this.local.stream = stream;
console.log('stream',stream)
document.getElementById(this.local.elementId).append(videoDom);
videoDom.play();
})
.catch(function (error) {
console.error("获取屏幕共享流失败:", error);
});
}
// 用户加入房间
async join() {
this.socket.emit("chat", {
action: "join",
});
}
// 共享视频
async shareVideo(){
this.getUserDevicePermissions()
}
// 切换摄像头
// 获取摄像头信息 包含个数
getCameras() {
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
// 遍历设备列表
const cameraList = [];
devices.forEach(function (device) {
if (device.kind === "videoinput") {
cameraList.push(device);
}
});
this.videoDevices = cameraList;
})
.catch(function (error) {
console.error("获取设备列表失败:", error);
});
}
// 结束
async leave() {
this.socket.emit('chat',{action: 'leave'})
this.handleLeave();
}
handleLeave(){
document.getElementById(this.remote.elementId).innerHTML = '';
this.remote.pc = null;
this.remote.stream = null;
}
// 创建rtc 实例
createRTCPeerConnection() {
const configuration = {
iceServers: [
{
urls: ["stun:stun.l.google.com:19302"], // STUN服务器地址 一般使用google的 不
},
{
urls: [""], // TURN服务器地址
username: "", // TURN服务器的用户名
credential: "", // TURN服务器的密码
},
],
};
let pc = new RTCPeerConnection(configuration);
this.remote.pc = pc;
if (this.local.stream) {
// 获取音频和视频轨道
const audioTrack = this.local.stream.getAudioTracks()[0];
const videoTrack = this.local.stream.getVideoTracks()[0];
// 创建新的媒体流
const mediaStream = new MediaStream();
mediaStream.addTrack(audioTrack);
mediaStream.addTrack(videoTrack);
// 将媒体流添加到连接
pc.addTrack(audioTrack, mediaStream);
pc.addTrack(videoTrack, mediaStream);
// pc.addStream(this.local.stream)
} else {
// 没有本地视频
pc.addStream(new MediaStream());
}
const that = this;
pc.onicecandidate = function (event) {
if (event.candidate !== null)
that.socket.emit("chat", {action:'candidate',data: event.candidate});
};
pc.ontrack = function (event) {
const track = event.track;
const stream = event.streams[0];
if(track.kind === 'video'){
const videoDom = document.createElement("video");
videoDom.style.height = "100%";
videoDom.style.width = "100%";
videoDom.srcObject = new MediaStream([track]);
that.remote.stream = stream;
document.getElementById(that.remote.elementId).append(videoDom);
videoDom.play();
}
};
return pc;
}
}
效果展示:
剩余两种实现方法,写不完了,太长了 待续....