一、一对一通话设计
对于前端开发人员来讲,主要关注对象是RTCPeerConnection
类,一个js封装好的类,直接调用。主要分为四个大模块来分析:信令设计、媒体协商、加入Stream/Track、网络协商。
根据下面整个流程图片
Socket.io的基本使用
服务端
js
io.on('connection', (socket) => { // 处理客户端连接事件 console.log('a user connected'); });
客户端
客户端和服务器端都可以发送消息。可以使用socket.emit()方法从客户端发送消息到服务器端,也可以使用socket.emit()方法从服务器端发送消息到客户端。例如,在客户端中发送一条消息:
js
socket.emit('chat message', 'Hello World!');
在服务器端接收该消息并回复:
js
io.on('connection', (socket) => {
socket.on('chat message', (msg) => {
console.log('message: ' + msg);
io.emit('chat message', msg);
});
});
客户端同样监听:
js
socket.on('chat message', (msg) => {
console.log('message: ' + msg);
});
为后面的信令设计做铺垫,就是按照发送-接收来进行数据的交换。
1.信令设计
信令服务器(Signaling Server)是一种用于实时通信系统的服务器,它起到了协调和传递消息的作用。在实时通信应用中,例如语音通话、视频通话和实时消息等,通信双方需要通过信令服务器进行交互,以建立连接、交换媒体信息和控制会话的行为。这里使用Socket.io 服务器,用Node.js创建后台服务
服务端
js
const express=require('express');
const app=express();
const {createServer} =require('http');
const {Server} = require("socket.io");
const httpServer=createServer(app);
var path=require("path");
app.use(express.static(path.join(__dirname,'client')));
app.get("/",function(req,res){
res.sendFile(__dirname+"/index.html");
})
//创建server
const io=new Server(httpServer);
客户端
js
/* 连接socket.io服务器 本地的3000端口作为测试*/
var socket=io('http://localhost:3000')
2.媒体协商
双方进行音视频通信的时候,必须先知道对方的媒体格式是什么,都支持怎么样的格式才能进行通信,而双方进行交换的媒体格式称为SDP信息
,所以必须先交换SDP信息
,这样双方才能知根知底,而交换SDP的过程,也称为"媒体协商"
客户端创建RTCPeerConnection
对象并添加本地的媒体流
js
//创建RTCPeerConnection
function createPeerConnection(){
pc=new RTCPeerConnection(null); //pc对象
localStream.getTracks().forEach(track=>pc.addTrack(track,localStream)); //pc添加媒体流
}
媒体流通过getUserMedia
获取:
js
navigator.mediaDevices.getUserMedia({
audio:true,
video:true
}).then(()=>{
......把媒体流放进相应的video标签
})
.catch(e=>{
alert('error:'+e.name);
})
交换SDP信息
,先把SDP通过offer
发送给信令服务器,再让服务器转发给另一个客户端:
js
pc.createOffer().then(offer=>{
pc.setLocalDescription(offer).then(function(){
socket.emit("offer",SDP信息)
}).catch(()=>{
console.log('offer Error')
})
}).catch(()=>{
console.log('createOffer Error')
})
服务端接收offer信息,再进行相应的转发:
js
//服务器接收SDP offer
socket.on("offer",res=>{
//拿到要发送offer房间号ID
console.log("服务端接收offer并进行转发操作")
})
3.加入Stream/Track
上面提到在创建RTCPeerConnection
的时候,添加了媒体流的操作
js
localStream.getTracks().forEach(track=>pc.addTrack(track,localStream));
接下来要把远端传过来的媒体流加入到自己的本地ontrack
,这样才能获取对方的音视频:
js
pc.ontrack=function(e){
remoteStream=e.streams[0];
console.log("远端媒体流",remoteStream)
//再把远端的媒体流放到自己的标签组件里面
};
4.网络协商
彼此要了解对方的网络情况,这样才有可能找到一条相互通讯的链路:
- 获取外网IP地址映射
- 通过信令服务器(signal server)交换"网络信息"
想要获取对方的外网IP,这里需要介绍stun服务器:
STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址
,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口
。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。该协议由RFC 5389定义。
当打洞不成功的时候,我们需要一个中继服务器来进行转发:
TURN
TURN的全称为Traversal Using Relays around NAT,是STUN/RFC5389的一个拓展,主要添加了Relay功能。如果终端在NAT之后, 那么在特定的情景下,有可能使得终端无法和其对等端(peer)进行直接的通信,这时就需要公网的服务器作为一个中继
, 对来往的数据进行转发。这个转发的协议就被定义为TURN。
需要同学们自行去部署以上服务器
交换ICE
js
// 使用STUN服务器创建RTCPeerConnection对象
const pc = new RTCPeerConnection({
iceServers: [{
urls: '......'
}]
});
// 监听icecandidate事件并发送ICE候选地址给对等端
pc.onicecandidate = event => {
if (event.candidate) { ;
socket.emit('icecandidate', candidate);
}
};
// 接收对等端发送的ICE候选地址并添加到RTCPeerConnection中
socket.on('icecandidate', candidate => {
pc.addIceCandidate(new RTCIceCandidate(candidate));
});
注意!
以上是socket.io实现WebRTC一对一通信的基本原理,代码如下:
客户端Html(要引入adapter-latest.js文件)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTC</title>
</head>
<body>
<h1>webRTC Demo</h1>
<div class="entry">
<input type="text" id="zero-roomId" placeholder="输入房间号">
<button id="join">加入</button>
<button id="leave">离开</button>
</div>
<div class="video-box">
<video id="localVideo" autoplay muted playsinline>本地</video>
<video id="remoteVideo" autoplay playsinline>远端</video>
</div>
</body>
<!-- 引入socketio -->
<script src="/socket.io/socket.io.js"></script>
<script src="./js/index.js"></script>
<script src="./js/adapter-latest.js"></script>
</html>
客户端js代码
js
var localUserId = Math.random().toString(36).substring(2); // 本地uid
var remoteUserId = -1; // 对端
var roomId = 0;
var localVideo=document.getElementById('localVideo')
var remoteVideo=document.getElementById('remoteVideo')
var joinRoom=document.getElementById('join')
var leaveRoom=document.getElementById('leave')
var localStream=null; //本地媒体流
var remoteStream=null; //远端媒体流
var pc=null;
/* 连接socket.io服务器 */
var socket=io('http://localhost:3000')
//创建RTCPeerConnection
function createPeerConnection(){
pc=new RTCPeerConnection(null);
pc.onicecandidate=handleIceCandidate;
pc.ontrack=handleRemoteStreamAdd;
localStream.getTracks().forEach(track=>pc.addTrack(track,localStream));
}
//ice候选项方法
function handleIceCandidate(e){
if(e.candidate){
var candidateJson={
"label":e.candidate.sdpMLineIndex,
"id":e.candidate.sdpMid,
"candidate":e.candidate.candidate
};
var jsonMsg={
"cmd":"candidate",
"roomId":roomId,
"uid":localUserId,
"remoteUid":remoteUserId,
"msg":candidateJson
};
socket.emit("candidate",jsonMsg)
}
}
//获取远端媒体流处理
function handleRemoteStreamAdd(e){
remoteStream=e.streams[0];
console.log("远端媒体流",remoteStream)
remoteVideo.srcObject=remoteStream;
}
//加入房间回调方法
function handleRemoteNewPeer(res){
remoteUserId=res.remoteUid
}
//发送offer方法
function doOffer(){
console.log("doOffer方法")
if(pc==null){
createPeerConnection();
}else{
console.log("doOffer方法进入offer,pc为:",pc)
pc.createOffer().then(offer=>{
pc.setLocalDescription(offer).then(function(){
var jsonMsg={
"cmd":"offer",
"roomId":roomId,
"uid":localUserId,
"remoteUid":remoteUserId,
"msg":offer
}
socket.emit("offer",jsonMsg)
}).catch(()=>{
console.log('offer Error')
})
}).catch(()=>{
console.log('createOffer Error')
})
}
}
//发送answer方法
function doAnswer(){
pc.createAnswer().then(answer=>{
pc.setLocalDescription(answer).then(function(){
var jsonMsg={
"cmd":"answer",
"roomId":roomId,
"uid":localUserId,
"remoteUid":remoteUserId,
"msg":answer
}
socket.emit("answer",jsonMsg)
}).catch(()=>{
console.log('answer Error')
})
}).catch(()=>{
console.log('createAnswer Error')
})
}
//加入房间的方法
function doJoin(roomId){
var jsonMsg={
"cmd":"join",
"roomId":roomId,
"uid":localUserId,
}
socket.emit('join',jsonMsg)
}
//离开房间方法
function doLeave(){
var jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId,
};
//置空对方和本地的媒体流
remoteVideo.srcObject=null;
localVideo.srcObject=null;
//关闭RTCPeerConnection
if(pc!=null){
pc.close();
pc=null;
}
//媒体轨道停止,关闭本地流
if(localStream != null){
localStream.getTracks().forEach(track=>{
track.stop()
})
}
socket.emit('leave',jsonMsg)
}
//有人创建了房间
socket.on("new-peer",res=>{
if(res!=="createRoom"){
console.log("有人加入进来id:",res)
handleRemoteNewPeer(res)
}
doOffer();
})
//回应加入信息
socket.on("resp-join",res=>{
console.log("房间里面已经有人id:",res)
})
//收到离开房间信息
socket.on("peer-leave",res=>{
console.log("有人离开房间id:",res)
//把对方视频置空
remoteVideo.srcObject=null;
})
//接收远端offer信息
socket.on("peer-offer",res=>{
console.log("客户端接收peer-offer",res)
if(pc == null){
createPeerConnection()
}
pc.setRemoteDescription(res.msg);
doAnswer();
})
//接收远端answer信息
socket.on("peer-answer",res=>{
console.log("客户端接收peer-answer",res)
pc.setRemoteDescription(res.msg);
})
//接收远端candidate信息
socket.on("peer-candidate",res=>{
console.log("客户端接收peer-candidate",res)
var candidateMsg={
"sdpMLineIndex":res.msg.label,
"sdpMid":res.msg.id,
"candidate":res.msg.candidate
}
var candidate=new RTCIceCandidate(candidateMsg)
pc.addIceCandidate(candidate).catch(e=>{
console.log("pc.addIceCandidate Error")
})
})
//关闭媒体
function closeLocalStream(stream){
// 停止所有媒体轨道
stream.getTracks().forEach((track) => {
track.stop();
});
// 关闭视频元素
localVideo.pause();
localVideo.srcObject = null;
}
//把视频流赋给dom标签
function openLocalStream(stream){
console.log("打开了本地流")
doJoin(roomId) //调用加入房间的方法
//房间已满人通知
socket.on("roomFull",res=>{
alert(res)
closeLocalStream(stream)
return
})
localVideo.srcObject=stream;
localStream=stream;
roomFull=false
}
//初始化打开摄像头和麦克风
function initLocalStream(){
navigator.mediaDevices.getUserMedia({
audio:true,
video:true
}).then(openLocalStream).catch(e=>{
alert('error:'+e.name);
})
}
//加入房间按钮
joinRoom.onclick=function(){
roomId=document.getElementById("zero-roomId").value
if(!roomId) {
alert("请输入房间ID")
return
}
initLocalStream() //调用打开摄像头和麦克风
}
//离开房间按钮
leaveRoom.onclick=function(){
doLeave()
}
服务端代码
js
const express=require('express');
const app=express();
const {createServer} =require('http');
const {Server} = require("socket.io");
const httpServer=createServer(app);
var path=require("path");
app.use(express.static(path.join(__dirname,'client')));
app.get("/",function(req,res){
res.sendFile(__dirname+"/index.html");
})
const io=new Server(httpServer);
const roomMap = new Map(); // 保存房间信息
//io连接回调
io.on("connection",(socket)=>{
console.log('socket连接成功')
socket.peer=null
//有人加入房间
socket.on("join", res=> {
//拿到要加入房间号ID
let room=roomMap.get(res.roomId);
//判断是否房间已经被创建
if(!room){
room=new Set();
roomMap.set(res.roomId,room);
room.add(res)
socket.join(res.roomId)
socket.peer=res
socket.emit("new-peer","createRoom")
}else if(room.size >=2){
socket.emit("roomFull","该房间已满人!");
return;
}else{
socket.join(res.roomId)
room.add(res)
console.log('---room-----',roomMap)
socket.to(res.roomId).emit("new-peer",res)
socket.peer=res
socket.emit("resp-join",Array.from(room).filter(item=>item.uid!==res.uid))
}
});
//有人离开房间
socket.on("leave",res=>{
//拿到要离开房间号ID
let room=roomMap.get(res.roomId);
if (room) {
const userToRemove = Array.from(room).find(item => item.uid === res.uid);
if (userToRemove) {
room.delete(userToRemove);
console.log(`用户 ${res.uid} 已从房间 ${res.roomId} 中删除`);
console.log('此时房间还有:',room);
}
if(room.size === 0) {
roomMap.delete(res.roomId);
} else {
var jsonMsg={
"cmd":"peer-leave",
"remoteUid":res.uid
}
socket.to(res.roomId).emit("peer-leave", jsonMsg);
}
}
socket.leave(res.roomId);
})
//服务器接收SDP offer
socket.on("offer",res=>{
//拿到要发送offer房间号ID
console.log("服务端接收offer")
let room=roomMap.get(res.roomId);
if(room==null){
console.error("room is null"+res.roomId)
return
}
socket.broadcast.to(res.roomId).emit("peer-offer",res)
})
//服务器接收SDP answer
socket.on("answer",res=>{
//拿到要发送offer房间号ID
console.log("服务端接收answer")
let room=roomMap.get(res.roomId);
if(room==null){
console.error("room is null",res.roomId)
return
}
socket.broadcast.to(res.roomId).emit("peer-answer",res)
})
//服务器接收candidate
socket.on("candidate",res=>{
console.log("服务端接收candidate")
//拿到要发送offer房间号ID
let room=roomMap.get(res.roomId);
if(room==null){
console.error("room is null"+res.roomId)
return
}
socket.broadcast.to(res.roomId).emit("peer-candidate",res)
})
//连接回调出错
socket.on("error", (res) => {
});
//断开连接回调
socket.on("disconnect", () => {
console.log("有客户端断开连接",socket.peer);
//拿到要离开房间号ID
if(socket.peer){
const peer=socket.peer
let room=roomMap.get(peer.roomId);
if (room) {
const userToRemove = Array.from(room).find(item => item.uid === peer.uid);
if (userToRemove) {
room.delete(userToRemove);
console.log(`用户 ${peer.uid} 已从房间 ${peer.roomId} 中删除`);
console.log('此时房间还有:',room);
}
if(room.size === 0) {
roomMap.delete(peer.roomId);
} else {
var jsonMsg={
"cmd":"peer-leave",
"remoteUid":peer.uid
}
socket.to(peer.roomId).emit("peer-leave", jsonMsg);
}
}
socket.leave(peer.roomId);
}
});
})
httpServer.listen(3000,function(){
console.log('服务器启动成功了:http://localhost:3000')
});