Socket.io实现音视频通话

一、一对一通话设计

对于前端开发人员来讲,主要关注对象是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.网络协商

彼此要了解对方的网络情况,这样才有可能找到一条相互通讯的链路:

  1. 获取外网IP地址映射
  2. 通过信令服务器(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')
});
相关推荐
YBN娜13 分钟前
Vue实现登录功能
前端·javascript·vue.js
阳光开朗大男孩 = ̄ω ̄=13 分钟前
CSS——选择器、PxCook软件、盒子模型
前端·javascript·css
minDuck18 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
小政爱学习!38 分钟前
封装axios、环境变量、api解耦、解决跨域、全局组件注入
开发语言·前端·javascript
魏大帅。44 分钟前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
花花鱼1 小时前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui
k09331 小时前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang13581 小时前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning1 小时前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人1 小时前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架