WebRTC入门教学和一对一通话实现

WebRTC入门学习

简介

大体架构

互联网实时通信平台,html5标准之一,使用简单的API就可以实现音频通信。
在这里插入图片描述

  • 紫色部分的是Web应用开发者需要关注的部门,也就是WebRTC提供给开发者的接口
  • 蓝色部分是提供给浏览器厂商的接口,浏览器厂商使用这些接口实现应用层接口
  • 蓝色虚线部分是浏览器厂商可以选择自己实现的接口

iSAC/ iLBC Codec 音视频编解码器

NetEQ for voice 声音增强

Ecoh Candeler / Noise Reduction 消除回声,减少噪音

VP8 Codec 编解码器

Video jitter buffer 数据包缓冲区

image enhancements 图像增强

发展前景

在这里插入图片描述

相关厂商

直播和WebRTC的区别

在这里插入图片描述

  • 直播是使用RTMP技术,并不需要非常高的实时性,时延有个几秒钟可能都没啥问题。所以主播将视频上传到服务器,然后通过CDN网络分发下去,给各个用户,各个用户就可以看到视频,和主播的交互往往都是通过弹幕的形式,而这种只需要传输文本,也不需要很高的实时性,所以只要带宽足够,无论有多少用户都可以。
  • WebRTC讲究的是实时音频通信,也就是实现多个人在同一个房间对话的效果,直播技术是单向的,用户能看到主播,而主播看不到用户。而WebRTC需要实现双向的通信,也就是所有用户直接都可以相互看到对方的画面,听到对方的声音,并且还要求有很低的延迟(你也不想说一句话,对方几秒后才回复吧),所以这就限制了通信的用户不能太多(否则服务器太多,链路拉长,时延就会很高,达不到实时音频通信的效果)。一般视频的人数最多只能有十几个人,而只传输声音,可以达到上百人(视频的数据量比声音高不少)

WebRTC通话原理

首先思考的问题:两个不同网络环境的浏览器,要实现点对点的实时音视频对话,难点在哪里?

媒体协商

也就是要了解对方支持的媒体格式(编码格式),用什么格式编的码,就需要用相同的格式解码回来。

在这里插入图片描述

所以在传输之前,要找双方支持的媒体格式,然后使用都支持的格式来进行视频传输
在这里插入图片描述

通信双方交换彼此的SDP(Session Description Protocol)信息,以此来了解双方支持的媒体类型,这个过程叫做媒体协商

网络协商

通信之前要了解双方的网络情况,确定大致的带宽,来决定通信的网络质量

NAT 网络地址转换

如果通信的双方都直接有一个公网IP,那么就可以直接进行通信

但是大多数电脑都不会独占一个公网IP(公网IP太少了),所以会将整个局域网内,为每个计算机分配一个私网IP(在这个局域网内唯一即可),这样局域网内部的计算机可以相互通信,然后给这个局域网(路由器)分配一个公网IP,这个局域网内的计算机和外网通信的时候就使用这个公网IP,而为了区分局域网内的各个计算机,路由器会请求STUN服务器给每个计算机(私网IP)分配一个编号(也可以叫端口号),使用公网IP和这个编号来和外网通信,发送数据包的时候,将IP和编号一起放在数据包的头部,这样路由器得到的应答数据包就可以根据端口号分发给私网内的计算机,这就是NAT网络地址转换。这样就可以在使用原来的IP协议的基础上,解决公网IP不足的问题。

在这里插入图片描述

STUN NAT会话穿越应用程序

NAT协议中,NAT端口的分配。以及[公网IP:NAT端口]到私网IP的映射,也是由STUN服务器完成,我们也可以

请求STUN服务器来查询自己计算机分配到的公网IP和端口
1669259633004)(D:\学习笔记\picture\image-20221115182350636.png)]

计算机发起请求前,先请求局域网内的STUN服务器,获取自己的公网IP和NAT端口。但是这还不够,还需要知道对方的公网IP和NAT端口才能进行通信,所以还需要一个公共发服务器保存各个计算机的公网IP和NAT端口,网络协商的时候不仅要从STUN服务器获取自己的公网IP和NAT端口,同时也要获得对方的公网IP和端口,这样才可以进行P2P通信。

[P2P学习(一)NAT的四种类型以及类型探测 - 山上有风景 - 博客园 (cnblogs.com)](https://www.cnblogs.com/ssyfj/p/14791064.html#:~:text=一般来讲, NAT可以分为四种类型,分别是%3A 1%2C 全锥型 (Full Cone) 2%2C 受限锥型,Cone)

四种NAT类型:

  • 全锥形,只要知道了公网IP和端口,任何外网主机都能和对应的内网主机进行通信进行通信
  • ip受限型,只有内网主机先给外网主机(ip)发过数据包,外网主机才能主动和内网主机通信
  • 端口受限型,只有内网主机先给外网主机(ip和端口)发过数据包,外网主机才能主动和内网主机通信
  • 对称型,内网主机每次发送新的数据包都会分配新的端口,所以只有内网主机主动发送的数据包的应答数据包才有效,外网主机不能主动联系内容主机

而如何获取内网的IP和端口,不属于STUNTURN协议的范畴

TURN协议

TURN协议其实是STUN协议的扩展

P2P学习(三)网络传输基本知识—TURN协议 - 山上有风景 - 博客园 (cnblogs.com)
在这里插入图片描述

进行NAT转换后可能不能直接使用P2P协议(NAT无法穿透)
在这里插入图片描述

  • 对称型和对称型不能通信是因为双方都是只能自己先发,而不能接受对方先发,没有能被先发的主机,所以就无法相互通信
  • 对称型和端口受限型和对称型不能相互通信也是同样的道理,他们都不能接受对方先发送数据包
  • 但是对称型和IP受限型(受限锥型)却可以通信,这是因为IP受限型可以先向对称型的相同IP但是不同端口的主机通信,然后因为IP受限型不区分端口,所以对称型此时就可以向IP受限型发送数据包

此时可以使用一个中继服务器来进行转发,是对STUN协议的补充

使用TURN协议的客户端必须能够通过中继地址和对等端进行通讯,并且能够得知每个peer的的IP地址和端口(确切地说,应该是peer的服务器反射地址)。

而这些行为如何完成,是不在TURN协议范围之内的,比如可以使用一个信令服务器来传输这些信息

使用TURN的优先级是最低的,因为它多了一段网络链路,时延增加,并且非常耗费网络带宽,是P2P的2倍,而服务器的带宽都是非常贵的。P2P需要的带宽是单向带宽的2倍,中继服务器则需要四倍(如下图所示,两个Peer都需要上传和下载)

在这里插入图片描述
在这里插入图片描述

在WebRTC中,描述网络信息的术语叫做candidate,前面描述媒体协商的媒体数据叫作SDP

ICE是集成STUN和TURN协议的框架

媒体协商和网络协商数据交换的通道——信令服务器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O6FbXs8l-1669259633007)(D:\学习笔记\picture\image-20221116004413046.png)]

和信令服务器进行通信,可以使用各种不同的变成语言,并且可以自己设计指令,只要能完成交换彼此信息的功能即可

信令服务器需要搭建在通信的主机都能访问到的局域网里面(因为主机Peer需要通过信令服务器来交换SDP,candidate,所以必须保证主机能访问到),一般部署在公网里面,部署在公网里面,这样公网里面的主机都能访问到

信令服务器不仅要交换SDP,Candidate,还需要有房间机制(或者叫会话机制),一个房间里面的人可以相互通话,不同房间的人不能相互通话,所以我们需要知道本次会话包含哪些人(或者说,这个房间包含哪些人),所以就需要房间机制,需要对进出房间进行管理

在这里插入图片描述

各个部分如何进行交互

在这里插入图片描述
在这里插入图片描述

  1. 双方通信前的准备工作
    1. 发起者和接收者和信令服务器建立链接
    2. 发起者和接受者在本地创建PeerConnection,并添加媒体流(视频流和音频流,输入流和输出流)
  2. 进行媒体协商
    1. 通话发起者创建一个offer(可以理解为数据包),在里面设置浏览器自己支持的媒体类型,然后把这个SDP数据包发送给信令服务器,信令服务器再转发给接受者(双方能通信肯定在一个房间,在一个房间就可以知道对方的IP以及NAT端口)
    2. 接收者收到SDP数据包后,在本地保存对方的SDP信息,同时查询自己的SDP信息,保存在本地,并创建应答数据包将自己的SDP信息返回给信令服务器,中继服务器再转发给发起者,发起者也保存对方的SDP信息,这样媒体协商就完成了。
  3. 进行网络协商
    1. 服务发起者向STUN/TURN服务器发送ICE请求,然后默认的回调函数OnICECandidate会将Candidate信息保存对象内部,保存这一操作由对象自动完成。
    2. 然后将拿到的Candidate信息发送给信令服务器,信令服务器再转发给接收者,接收者将收到的Candidate信息保存再本地
    3. 查询自己的Candidate信息,然后保存并发送给信令服务器,信令服务器再转发给发起者,发起者将接受者的Candidate保存下来,这样媒体协商就完成了
  4. 主机之间进行通信
    1. 接受端和发送端之间会尝试进行通信,看看NAT打洞是否能成功,如果能成功就直接使用P2P进行通信,如果不行就使用中继服务器(STUN和TURN
    2. 确定完通信方式后,回调函数onAddStream会接受以及发送对方的媒体流,这样就能完成双方的数据通信

环境搭建

windows本地需要安装vscode,webstorm等js编译器,然后需要安装node和npm,下面介绍服务器安装流程

安装必要的依赖

ubuntu

apt-get install libssl-dev
apt-get install libevent-dev

centos

yum install libssl-dev
yum install libevent-dev

编译安装coturn

官方网站https://github.com/coturn/coturn

使用docker

Coturn/Docker/Coturn at Master ·转弯/转弯 ·GitHub

 docker run -d --privileged=true --network=host -p 3478:3478 -p 3478:3478/udp -p 5349:5349 -p 5349:5349/udp -p 49152-65535:49152-65535/udp coturn/coturn

测试是否成功

lsof -i:3478

查看当前端口是否被监听

同时可以打开这个网站来测试STUN的功能 https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

测试STUN服务器的功能:

在这里插入图片描述

STUN不需要用户名密码,Priority显示Done,表示打洞功能是正常的

测试TURN功能:

用户名密码默认都是服务器的用户和密码

在这里插入图片描述

显示done表示成功

音频的采集和播放

打开摄像头

播放视频的代码流程

在这里插入图片描述

onOpenCamera是需要我们自己的编写的事件,在点击按钮后实现视频的播放

按钮自不必说使用<button>标签,播放视频需要使用<video>标签,相关的属性如下

属性描述
autoplayautoplay如果出现该属性,则视频在就绪后马上播放。
controlscontrols如果出现该属性,则向用户显示控件,比如播放按钮。
heightpixels设置视频播放器的高度。
looploop如果出现该属性,则当媒介文件完成播放后再次开始播放。
mutedmuted如果出现该属性,视频的音频输出为静音。
posterURL规定视频正在下载时显示的图像,直到用户点击播放按钮。
preloadauto metadata none如果出现该属性,则视频在页面加载时进行加载,并预备播放。如果使用 “autoplay”,则忽略该属性。
srcURL要播放的视频的 URL。
widthpixels设置视频播放器的宽度。
playsinline设置后,用户无法拖动进度条

可以触发的事件:HTML 事件 | 菜鸟教程 (runoob.com)

初始的简易模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>VideoPlayer</title>
</head>
<body>
<video id="local-video" autoplay playsinline></video>
<button id="showVideo">打开摄像头</button>
<p>通过getUserMedia获取视频</p>
</body>
</html>

在这里插入图片描述
在这里插入图片描述

getUserMedia参考的API:https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getUserMedia

可以从navigator.mediaDevices里面获取浏览器可以使用的设备,而navigator.mediaDevices.getUserMedia则可以用来获取视频,需要传入一个参数constraints,是一个配置对象,里面是JSON格式的配置信息。获取到的这个流不一定是单个媒体的流,也可能是多个媒体混合在一起的流,在constraints配置对象里面说明要采集的媒体,最后得到一个stream对象,表示所有媒体的流

比如最简单的constraintsaudio表示使用音频,video表示使用视频

{ audio: true, video: true }

设置为true,表示使用这个媒体,并且所有的属性都取默认值。我们也可以进一步进行配置,比如设置宽高,也就是分辨率

{
  audio: true,
  video: { width: 1280, height: 720 }
}

也可以设置一个范围的分辨率,会尽可能使用接近ideal的值

{
  audio: true,
  video: {
    width: { min: 1024, ideal: 1280, max: 1920 },
    height: { min: 776, ideal: 720, max: 1080 }
  }
}

还可以设置使用前置摄像头

{ audio: true, video: { facingMode: "user" } }

后置摄像头

{ audio: true, video: { facingMode: { exact: "environment" } } }

video标签的媒体来源可以从src属性指定的文件获取,也可以从srcObject属性指定的流中获取,所以我们将从getUserMedia中获取的媒体流赋值给srcObject属性后,就可以在屏幕上显示视频

有了上面这些知识后就可以实现打开摄像头,获取媒体流的功能

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>VideoPlayer</title>
</head>
<body>
<video id="local-video" autoplay playsinline></video>
<button id="showVideo">打开摄像头</button>
<p>通过getUserMedia获取视频</p>
</body>
<script>
    //摄像头的配置信息,指定需要采集的媒体有视频和音频
    const constraints = {
        audio: true,
        video: true
    };

    //打开摄像头成功的回调函数,参数从摄像头设备里面会获取到的媒体流
    function handleSuccess(stream) {
        //媒体对象
        const video = document.querySelector("#local-video");
        video.srcObject = stream;
    }

    //打开摄像头按钮的回调函数
    function onOpenCamera(e) {
        // 获取媒体流,获取成功后触发回调函数handleSuccess,入参是获取到的媒体流
        // 获取失败(比如用户不给权限或者电脑没有摄像头)则触发catch里面的失败函数,参数是错误信息
        navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).catch(null);
    }

    //给id为showVideo添加点击事件
    document.querySelector("#showVideo").addEventListener("click", onOpenCamera);
</script>
</html>

看到摄像头里的画面就表示摄像头正常,媒体流获取成功

上面这个例子技能播放视频也能播放音频,如果只想播放视频,将配置对象改成

    const constraints = {
        audio: false,
        video: true
    };

即可,也就是及那个audio改成false,表示不播放音频

想要达到不播放音频还可以这么做

<video id="local-video" autoplay playsinline muted></video>

加上muted属性,这样虽然会接受音频流,但是不会播放声音

打开麦克风

在这里插入图片描述

流程和打开麦克风差不多,只是处理媒体流的控件改成了audio标签,video标签既能播放视频也能播放音频,而audio标签只能播放音频,不过音频和视频的传输速率不一样,我们可以分开处理(比如视频可能只能支持十几个人,音频却可以支持上百人,所以可以让小部分人视频,其他人只用音频,这样也能达到通话的效果)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>audio player</title>
</head>
<body>
<!--controls 可以为audio和video标签加上一个控件,来控制播放-->
<audio id="localAudio" autoplay controls>播放麦克风的路由</audio>
<button id="playAudio">打开麦克风</button>
<p>通过getUserMedia()获取音频</p>
</body>
<script>
    //媒体的配置信息,指定需要采集的媒体是音频,没有视频
    const constraints = {
        audio: true,
        video: false
    };

    //打开麦克风成功的回调函数,参数从麦克风设备里面会获取到的媒体流
    function handleSuccess(stream) {
        //媒体对象
        const audio = document.querySelector("#localAudio");
        audio.srcObject = stream;
    }
    function handleError(err) {
        console.log(err)
    }

    //打开麦克风按钮的回调函数
    function onOpenMicrophone(e) {
        // 获取媒体流,获取成功后触发回调函数handleSuccess,入参是获取到的媒体流
        // 获取失败(比如用户不给权限或者电脑没有摄像头)则触发catch里面的失败函数,参数是错误信息
        navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).catch(handleError);
    }

    //给id为playAudio添加点击事件
    document.querySelector("#playAudio").addEventListener("click", onOpenMicrophone);
</script>
</html>

属性controls 可以为audio和video标签的控件加上一个额外的控件,来控制播放

Nodejs实战

nodejs实现信令服务器会比较方便,所以这里用nodejs实现信令服务器

WebSocket

在这里插入图片描述

websocket可以在进行一次握手后,建立持久的链接,后续双方就可以在这个连接上持续地传输数据。没有websocket之前,应用之间每次传输数据都需要发送http请求,实现服务器推送的功能也只能使用AJAX进行轮询,但是网络IO的轮询要频繁地进行握手和挥手,效率低,浪费流量。而使用websocket之后,进行一次握手后就可以进行持续地进行数据传输,在有信息到来后通知监听的一方执行回调函数,从而避免了轮询,后续的数据不需要再进行握手和挥手,大大提高了效率。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Nodejs服务器 websocket

简单来说,Node.js就是运行在服务端的JavaScript

安装websocket(Linux或者windows)

cd 工程目录
npm init
npm init -y #表示所有选项都是用默认,所有yes或no都使用yes
npm install nodejs-websocket

在这里插入图片描述
在这里插入图片描述

WebSocket聊天室

在这里插入图片描述
客户端:
在这里插入图片描述

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>聊天室客户端</title>
</head>
<body>
<h1>Websocket简易聊天</h1>
<div id="app">
    <label for="sendMsg">发送消息</label><input id="sendMsg" type="text"/>
    <button id="submitBtn">发送</button>
</div>
</body>
<script>
    //创建div元素,并放在body的后面
    function showMessage(str, type) {
        let div = document.createElement("div");
        div.innerHTML = str;
        if (type === "enter") div.style.color = "blue";
        else if (type === "leave") div.style.color = "red";
        document.body.appendChild(div);
    }

    //创建一个websocket
    const websocket = new websocket("ws://114.116.28.229:8010")
    //打开websocket链接
    websocket.onopen = () => {
        console.log("已经连接上服务器")
        //为按钮绑定点击事件
        document.getElementById("submitBtn").onclick = () => {
            const txt = document.getElementById("sendMsg").value;
            if (txt) {
                //向服务器发送数据
                websocket.send(txt)
            }
        }
        //关闭链接的触发事件
        websocket.onclose = () => {
            console.log("websocket close")
        }
        //服务器发送过来的都是JSON包,包含type和data
        websocket.onmessage = e => {
            const mes = JSON.parse(e.data)
            showMessage(mes.data,mes.type)
        }
    }
</script>
</html>

服务器端

服务器端的connection.sendText被客户端的websocket.onmessage监听

客户端的websocket.send()函数,被服务器端的connection.on("text",()={})监听

客户端关闭链接(关闭选项卡)会被客户端的

websocket.onclose监听,和被服务器端的connection.on("close",()=>{})监听

客户端建立socket链接new websocket会被服务器端的ws.createServer监听,回调函数就是得到的链接对象

信令服务器 房间机制

信令服务器需要使用map来管理房间

js创建类可以通过方法的形式来创建,方法就体就是构造函数,通过this.xxx来添加成员变量和成员方法

JS知识补充

  • 可以用=>代替function关键字
function(e){} 等价于 e=>{}

使用=>更加简洁,只有一条语句大括号可以省略

  • promise可以将多个方法按顺序执行下去,上一个函数的返回值就是下一个函数的参数,最后还可以使用catch来捕获异常,catch后可以继续then执行任务
let promise = Promise.resolve();
promise.then(() => {
    console.log(1)
    return 2;
}).then(num => {
    console.log(num)
    return 3;
}).then(num => {
    console.log(num)
    throw new Error()
}).catch(err => {
    console.log(err)
})

在这里插入图片描述

如果没有出现异常就一直执行then,如果出现了异常,会先执行第一次遇到的catch,跳过这中间的then,然后执行后面的then

使用ajax时,会有成功和失败函数,我们可以使用promise对ajax的调用过程进行封装

使用格式:

        let p=new Promise((resolve, reject) => { 
            resolve(data)
            reject(err)
        })
        p.then((data)=>{
            console.log(data);
        }).catch(err=>console.log(err));

resolve的实现来自外面的then的参数

reject的实现来自于完美catch的实现

使用promise我们可以将ajax的成功和失败函数封装到resolve和reject中,并将函数的实现延迟到外面

我们可以使用promise将异步请求封装起来

使用ajax之前需要引入

<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>

        function get(url, data,method) {
            return new Promise((resolve, reject) => {
                $.ajax({
                    url: url,
                    data: data,
                    method:method,
                    success(data) {
                        resolve(data)
                    },
                    error(err) {
                        reject(err)
                    }
                })
            })
        }

然后我们就可以按照我们习惯的方式来发生异步请求:

        get("./user.json","get").then((data) => {
            console.log(data)
            return get(`./user${data.userId}.json`,"get")
        }).then((data) => {
            console.log(data)
            return get(`./course.json`,"get")
        }).then((data) => {
            console.log(data)
        }).catch((err) => {
            console.log(err)
        })

每发送一个请求都return一个promise,这样调用结束后就可以继续.then进行下一步处理,javascript并不是一个严格的语言,函数参数不全并不会报错

在then里面传入请求成功后的方法,在catch里面传入请求失败的方法,参数都由get方法中ajax的回调的返回值来设置

不同Promise和主线程是异步执行的,而Promise内部是顺序执行的,只有前面的then执行完,才会执行后面的then

实现一对一通话

(876条消息) webrtc一对一通话_Lumos`的博客-CSDN博客

一对一通话原理

大致分为这几步:打开设备,媒体协商,网络协商,传输数据,关闭设备
在这里插入图片描述
我们首先要设计信令,所谓信令其实就是通知的类型,告诉服务器或者客户端发生了什么,然后服务端和客户端就可以监听这些信令进行对应的处理

  1. 通信双方连接信令服务器
  2. 通信双方打开麦克风和摄像头,获取媒体流
  3. 双发向信令服务器发送加入请求join,加入指定的房间,回复当前房间是否有人,以及有哪些人
  4. 链接发起者创建RTCPeerConnection对象,也就是通信双方创建链接
  5. 链接发起者将媒体流设置进RTCPeerConnection链接对象里面
  6. 链接发起者获取本地浏览器支持的媒体格式SDP,创建offer发起媒体协商,并由信令服务器转发
  7. 对方收到offer后,也创建链接对象RTCPeerConnection,将自己的媒体流设置进链接,保存对方的媒体信息,获取自己的媒体信息,作为答案返回给对方
  8. 链接发起者收到回应后保存对方支持的媒体信息,这样媒体协商就结束了
  9. 双方监听对方传过来的数据,这里只是创建了码流对象,但是还没有进行网络协商,无法直接传输数据
  10. 双方向STUN/TURN服务器,发送ICE请求,获取打洞地址,然后将这个信息candidate发送给对方,收到candidate信息后保存下来,完成网络协商
  11. 通过P2P协议进行音视频传输和播放
  12. 通话结束后,发送leave信令,关闭链接和音视频(不再显示音视频)。信令服务器收到leave信令后从房间里面删除PeerA,并通知房间里面剩下的人PeerBPeerB收到leave信令后也关闭链接和媒体流

信令协议设计

这个简易的一对一通话系统需要如下信令,每个信令都传输JSON格式的数据,接收端解析JSON后就可以知道是什么信令以及有哪些数据

  1. join:加入房间
  2. resp-­join:当join房间后发现房间已经存在另一个人时则返回另一个人的uid;如果只有自己则不返回
  3. leave:离开房间,服务器收到leave信令则检查同一房间是否有其他人,如果有其他人则通知他有人离开
  4. new­-peer:服务器通知客户端有新人加入,收到new­peer则发起连接请求
  5. peer-­leave:服务器通知客户端有人离开
  6. offer:转发offer sdp
  7. answer:转发answer sdp
  8. candidate:转发candidate sdp

join

var jsonMsg={
	'cmd':'join',
    'roomId':roomId,
    'uid':localUserId
}

resp-join

jsonMsg={
    'cmd':'resp-join',
    'remoteUid':remoteUid
}

leave

var jsonMsg={
	'cmd':'leave',
    'roomId':roomId,
    'uid': localUserId
}

new-peer

var jsonMsh={
    'cmd':'new-peer',
    'remoteUid':uid
}

offer

var jsonMsg={
    'cmd':'offer',
    'roomId':roomId,
    'uid':localUserId,
    'remoteUid':remoteUserId,
    'msg':JSON.stringify(sessionDescription)
}

answer

var jsonMsg={
	'cmd':'answer',
	'roomId':roomId,
	'uid':localUserId,
	'remoteUid':remoteUserId,
	'msg':JSON.stringify(sessionDescription)
}

candidate

var jsonMsg={
    'cmd':'candidate',
    'roomId':roomId,
    'uid':localUserId,
    'remoteUid':remoteUserId,
    'msg':JSON.stringify(candidate)
}

房间机制需要这样的一个Map

key是房间号,value是房间对象,房间对象也是一个Map,key是客户端uid,value是客户端对象,客户端对象里面保存有uid,roomId和链接对象conn

媒体协商

在这里插入图片描述

createOffer

基本格式:

aPromise=myPeerConnection.createOffer([options]);
  • [options]

这个其实也是一个JSON对象,里面包含一些配置信息
在这里插入图片描述

调试技巧:在你在修改的地方的前面打上断点,然后修改为自己想要的代码,然后保存,然后放行

前面两个参数很好理解,选择是否要接受视频和音频,第三个参数iceRestart,如果设置为true,每次ICE请求都会重新获取自己的打洞信息,如果设置为false,只有刚刚启动(还不是活跃状态)的时候会获取打洞信息,启动之后(活跃状态)就不会再获取了,使用之前的打洞信息

createAnswer

基本格式

aPromise=RTCPeerConnection.createAnswer([options])

[options]的格式和上面一样,只是这个是应答,作用是一样的

setLocalDescription

获取并设置本地的媒体信息

aPromise = RTCPeerConnection.setLocalDescription(sessionDescription);
setRemoteDescription

保存对方传来的媒体信息

aPromise = pc.setRemoteDescription(sessionDescription);

加入Stream/Track

addTrack

基本格式
在这里插入图片描述

网络协商

addIceCandidate

在这里插入图片描述

Android和Web端的Candidate信息可能不同(名称不同)

RTCPeerConnection补充

构造函数

语法:

pc = new RTCPeerConnection([configuration])

[configuration]也是一个JSON格式的配置对象,可以配置如下信息
在这里插入图片描述
测试的时候建议使用relay,因为如果使用all,即使打洞失败也能通,因为在局域网内不需要公网IP,而如果relay可以通,说明coturn服务器确实是可以使用的,那么部署到公网也肯定没问题
在这里插入图片描述

iceServer其实就是coturn服务器,url需要带上stun或者turn的前缀,stun不需要密码,而turn需要用户名和密码

可以绑定的相关事件

在这里插入图片描述

一对于通话具体实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n3gGlip4-1669259633020)(D:\学习笔记\picture\image-20221121012227383.png)]

  1. 实现客户端界面(html代码)
  2. 打开摄像头并显示到界面(getUserMedia获取媒体流,这个媒体流传给video就是显示界面,传给Peer就是让对方显示界面)
  3. websocket链接(连接信令服务器,后面两个Peer的交互都要通过信令服务器来传递信息)
  4. 实现信令,信令服务器和Peer客户端通过WebSocket长连接相互监听对方的信令,并对应实现回调函数
  5. 调试和完善

实现的时候可以将信令使用常数保存起来,方便后面使用

//信令组
//客户端加入房间
const SIGNAL_TYPE_JOIN = "join";
//服务器对加入房间的回应
const SIGNAL_TYPE_RESP_JOIN = "resp-join";
//客户端离开房间
const SIGNAL_TYPE_LEAVE = "leave";
//服务器通知所有用户有新用户加入
const SIGNAL_TYPE_NEW_PEER = "new-peer";
//服务器通知所有用户有用户离开
const SIGNAL_TYPE_PEER_LEAVE = "peer-leave";
//发起媒体协商的数据
const SIGNAL_TYPE_OFFER = "offer";
//回应媒体协商的数据
const SIGNAL_TYPE_ANSWER = "answer";
//发送网络协商的数据
const SIGNAL_TYPE_CANDIDATE = "candidate";
//提示信息
const SIGNAL_TYPE_INFO = "info";
客户端界面

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket客户端</title>
</head>
<body>
<h1>WebRTC demo</h1>
<div id="buttons">
    <label for="zero-RoomId">加入房间ID </label><input type="text" id="zero-RoomId" placeholder="请输入房间ID" maxlength="40"/>
    <button id="joinBtn" type="button">加入</button>
    <button id="leaveBtn" type="button">离开</button>
</div>
<div id="videos">
<!--  本地的窗口不用播放自己的讲话,否则会有回声,所以要加上muted -->
    <video id="localVideo" autoplay muted playsinline>本地窗口</video>
<!--    但是对方的要加上所以不能加上muted -->
    <video id="remoteVideo" autoplay playsinline>对方窗口</video>
</div>
</body>
</html>
打开摄像头并显示界面

需要写一份js代码来监听各种事件

js/main.js

'use strict'
//显示本地视频
let localVideo = document.querySelector("#localVideo")
//显示远端的视频
let remoteVideo = document.querySelector("#remoteVideo")
//加入按钮
let joinBtn = document.getElementById("joinBtn");
//保存本地的媒体流
let localStream=null;

//getUserMedia的回调函数的参数里面有stream,表示媒体流
// 这个媒体流可以拷贝一份保存在本地,方便后面传输给对面
function openLocalStream(stream) {
    console.log("open local stream")
    localVideo.srcObject=stream
    localStream=stream
}
//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
    console.log("加入按钮被点击")
    //初始化本地码流
    navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true
    }).then(openLocalStream).catch(e => {
        console.log("getUserMedia() error: " + e.name)
    })
}

index.html

index.html要引入刚才编写的js文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket客户端</title>
</head>
<body>
...
</body>
<script src="js/main.js"></script>
</html>
建立Websocket链接

onMessage的回调函数的参数是一个JSON对象,里面包含如下字段,其中data属性表示服务器传过来的数据,对应服务器的sendText方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MqWIx4Kj-1669259633020)(D:\学习笔记\picture\image-20221121111456358.png)]

server.js

const ws = require("nodejs-websocket")
const PORT = 8099;

const server = ws.createServer(conn => {
    console.log("创建了一个新的链接")

    conn.sendText("收到你的链接了")

    conn.on("text", str => {
        console.log("服务器收到消息:", str)
    })
    conn.on("close", (code, reason) => {
        console.log("链接关闭 code:", code, reason)
    })
    conn.on("error",err=>{
        console.log("监听到错误:",err)
    })
})
server.listen(PORT)

js/main.js

...

//创建一个方法其实也就是创建了一个类,类名和变量名可以重名
function ZeroRTCEngine(webSocketUrl) {
    this.init(webSocketUrl)
    return this
}

ZeroRTCEngine.prototype.init = (websocketUrl) => {
    let zp = ZeroRTCEngine.prototype
    zp.wsUrl = websocketUrl
    zp.signaling = null
}

//websocket打开的时候
ZeroRTCEngine.prototype.onOpen = () => {
    console.log("websocket open")
}
//websocket接收到数据的时候
ZeroRTCEngine.prototype.onMessage = event => {
    console.log("onMessage: ", event)
}
//websocket出现错误的时候
ZeroRTCEngine.prototype.onError = event => {
    console.log("onError: ", event)
}
//websocket关闭的时候
ZeroRTCEngine.prototype.onClose = event => {
    console.log("onClose -> code : ", event.code, ", reason:", EventTarget.reason)
}

ZeroRTCEngine.prototype.createWebsocket = () => {
    let zp = ZeroRTCEngine.prototype
    zp.signaling = new WebSocket(zp.wsUrl)
    zp.signaling.onopen = zp.onOpen
    zp.signaling.onmessage = zp.onMessage
    zp.signaling.onerror = zp.onError
    zp.signaling.onclose = zp.onClose
    console.log(zp)
}

const IP = "127.0.0.1"
const PORT = 8099;

let zeroRTCEngine = new ZeroRTCEngine(`ws://${IP}:${PORT}`);
zeroRTCEngine.createWebsocket()

...
实现JOIN信令

JOIN信令是客户端发送给服务器的信令,表示加入房间

join信令格式

var jsonMsg={
	'cmd':'join',
    'roomId':roomId,
    'uid':localUserId
}

获取用户自己的ID

let userName = prompt("输入您的昵称");

//为每个用户生成一个自己的ID
let localUserId = userName + "_" + crypto.randomUUID().replaceAll("-", "");

发送JOIN信令

//发送JOIN信令
function doJoin(roomId) {
    const jsonMsg = {
        'cmd': 'join',
        'roomId': roomId,
        'uid': localUserId
    };
    //socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
    let message = JSON.stringify(jsonMsg);
    zeroRTCEngine.sendMessage(message);
    console.log("doJoin message", message);
}

发送JOIN信令其实就是使用websocket长连接来发送

zp.sendMessage = message => {
    zp.signaling.send(message);
};

发送时机是点击发送按钮后发送

joinBtn.onclick = () => {
    console.log("加入按钮被点击");
    roomId = document.getElementById("zero-RoomId").value;
    //向信令服务器发送加入信令
    doJoin(roomId);
    //初始化本地码流
    initLocalStream();
};

浏览器启动的时候,会和服务器端创建WebSocket链接,创建成功后,服务器会向客户端发送一条消息,表示链接建立成功

效果

在这里插入图片描述
客户端的onmessage方法会监听接收消息的事件(onmessage方法最终的实现就是这个onMessage方法),收到服务器的消息后,这里会将其打印出来
在这里插入图片描述
onmessage的回调函数是一个JSON对象,内容如下,里面的data属性是服务器传过来的数据
在这里插入图片描述

输入房间号,点击加入按钮后,就会发生:

  1. 发送JOIN信令
  2. 打开摄像头
function doJoin(roomId) {
    const jsonMsg = {
        'cmd': 'join',
        'roomId': roomId,
        'uid': localUserId
    };
    //socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
    let message = JSON.stringify(jsonMsg);
    zeroRTCEngine.sendMessage(message);
    console.log("doJoin message", message);
}

发送的JOIN信令携带的数据是序列化后的JSON字符串
在这里插入图片描述

服务器端使用websocket.on(“text”)方法来监听各个信令,链接持续过程中的所有数据都由这个方法来处理

    conn.on("text", str => {
        console.log("服务器收到消息:", str)
    })

客户端传来的是JSON字符串,服务器收到后进行反序列化就能使用,可以通过type属性区分不同的类型,来进行分门别类的处理。

    conn.on("text", str => {
        console.log("服务器收到消息:", str);
        let jsonMsg = JSON.parse(str);
        switch (jsonMsg.cmd) {
            case SIGNAL_TYPE_JOIN:
                handleJoin(jsonMsg, conn);
                break;
        }
    });

处理JSON信令的服务端方法:

let roomTableMap = new Map();

/**
 * 处理信令的函数
 * @param jsonMsg  传输的数据
 * @param conn socket链接
 *
 * const jsonMsg = {
 *    'cmd': 'join',
 *    'roomId': roomId,
 *    'uid': localUserId
 * };
 *
 */
function handleJoin(jsonMsg, conn) {
    const roomId = jsonMsg.roomId;
    const uid = jsonMsg.uid;
    console.log("user-", uid, "try to join room ", roomId);
    let roomMap = roomTableMap.get(roomId);
    //房间不存在就创建
    if (roomMap == null) {
        roomMap = new Map();
        roomTableMap.set(roomId, roomMap);
    }
    // 因为是一对一通话,所以要判断房间人数不能大于2
    if (roomMap.size >= MAX_COUNT) {
        console.error("房间-", roomId, " 已经有两人存在");
        conn.sendText(`房间-${roomId} 已经满了`);
        return;
    }
    let client = new Client(uid, conn, roomId);
    roomMap.set(uid, client);
    //如果原来房间里面有人
    if (roomMap.size > 1) {
        for (const [iterUid, iterClient] of roomMap) {
            if (iterUid !== uid) {

                //告诉另一个人有新用户来了,发送new-peer信令
                let newPeerJsonMsg = {
                    'cmd': SIGNAL_TYPE_NEW_PEER,
                    'remoteUid': uid
                };
                let newPeerMsg = JSON.stringify(newPeerJsonMsg);
                iterClient.conn.sendText(newPeerMsg);

                //回复新用户另一个人是谁
                let joinRespMsg = {
                    "cmd": SIGNAL_TYPE_RESP_JOIN,
                    "remoteUid": iterUid
                };
                let respJoinMsg = JSON.stringify(joinRespMsg);
                console.log("resp-join: ", respJoinMsg);
                conn.sendText(respJoinMsg)
            }
        }
    }
}

服务器收到JOIN信令后,要判断这个房间是否有用户,如果有用户,则需要使用resp-join信令通知新用户房间里有其他用户。同时需要使用new-peer信令通知房间原来的用户有新用户加入

房间机制其实是一个Map<roomId,Map<uid,client>>这样的一个map对象,保存有房间信息和用户信息

同样的,客户端收到的来自服务器的信令都会交给onMessage这个回调函数来处理,同样是解析为JSON对象后,通过cmd属性来判断信令的类型,从而进行分门别类的处理

//websocket接收到数据的回调函数
zp.onMessage = event => {
    console.log("onMessage: ", event.data);
    let jsonMsg = JSON.parse(event.data);
    switch (jsonMsg.cmd) {
        case SIGNAL_TYPE_NEW_PEER:
            handleNewPeer(jsonMsg);
            break;
        case SIGNAL_TYPE_RESP_JOIN:
            handleResponseJoin(jsonMsg);
            break;
    }
};

resp-join

信令格式:

jsonMsg={
    'cmd':'resp-join',
    'remoteUid':remoteUid
}

当前客户端加入房间后,如果有其他Peer在房间里面,就会收到服务器发来的resp-join信令,以此得知对方的id

/**
 * 处理respJoin信令的处理函数
 * @param respMsg json对象
 * {
 *     'cmd':'resp-join',
 *     'remoteUid':remoteUid
 * }
 */
function handleResponseJoin(respMsg) {
    console.log(`handleResponseJoin, the old one is user-${respMsg.remoteUid}`);
    remoteUserId = respMsg.remoteUid;
    doOffer();
}

得到对方的userId后,信令服务器就可以来转发给另一个Peer的信息,所以下面需要doOffer发起媒体协商
在这里插入图片描述

new-peer

信令格式:

var jsonMsh={
    'cmd':'new-peer',
    'remoteUid':uid
}

客户端收到服务器发来的new-peer信令后用于处理的回调函数,作用和resp-join信令一致,用于得知对方的id

/**
 * 处理new-peer信令的信息
 * @param newPeerMsg json对象
 * {
 *     'cmd':'new-peer',
 *     'remoteUid':uid
 * }
 */
function handleNewPeer(newPeerMsg) {
    console.log(`handleNewPeer, the new one is user-${newPeerMsg.remoteUid}`);
    remoteUserId = newPeerMsg.remoteUid;
}

得到对方的userId后,信令服务器就可以来转发给另一个Peer的信息,然后等待加进来的那一方发起媒体协商的offer即可
在这里插入图片描述

实现leave,peer-leave

实现思路:

  1. 用户点击离开按钮,触发按钮事件
  2. 将leave信令发送给服务器
  3. 服务器收到leave信令并将发送者删除
  4. 服务器向其他用户发送peer-leave事件
  5. 客户端的其他人响应peer-leave事件

处理离开信令

/**
 * 处理离开信令
 * @param jsonMsg 信令信息
 * @param conn 客户端链接
 */
function handleLeave(jsonMsg, conn) {
    const roomId = jsonMsg.roomId;
    const uid = jsonMsg.uid;
    console.log("用户", uid, "尝试离开房间", roomId);
    let roomMap = roomTableMap.get(roomId);
    if (roomMap == null) {
        let errMsg = "找不到房间" + roomId;
        console.error(errMsg);
        conn.sendText(JSON.stringify({'cmd': 'info', 'data': errMsg}));
        return;
    }
    if (roomMap.get(uid)) {
        roomMap.delete(uid);
        for (let [iterUid, iterClient] of roomMap) {
            let peerLeaveMsg = {
                'cmd': 'peer-leave',
                'remoteUid': uid
            };
            console.log('发送peer-leave信令,', peerLeaveMsg);
            iterClient.conn.sendText(JSON.stringify(peerLeaveMsg));
        }
    } else {
        const errMsg = `用户${uid}并不在房间${roomId}`;
        conn.sendText(JSON.stringify({'cmd': 'info', 'data': errMsg}));
        console.error(errMsg);
    }
}

发送leave信令

/**
 * 处理离开事件
 * @param roomId 房间号
 */
function doLeave(roomId) {
    const jsonMsg = {
        'cmd': 'leave',
        'roomId': roomId,
        'uid': localUserId
    };
    //socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
    let message = JSON.stringify(jsonMsg);
    zeroRTCEngine.sendMessage(message);
    console.log("doLeave message", message);
}

处理leave-peer信令

/**
 * 处理peer-leave信令
 * @param jsonMsg 相关数据
 */
function handlePeerLeave(jsonMsg) {
    console.log("handlePeerLeave 收到消息", jsonMsg);
}

收到的信令都是一个json字符串,解析成对象后,根据cmd属性调用方法即可

实现offer,answer,candidate信令

这些信令用于媒体协商和网络协商,由RTCPeerConnection内部自己完成,而我们只需要处理它的回调。offer创建者是在创建offer的时候创建RTCPeerConnection对象,而接受端是收到answer信令后创建RTCPeerConnection对象。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t5p3sJ5w-1669259633022)(D:\学习笔记\picture\image-20221122152525767.png)]

1.收到new­peer (handleRemoteNewPeer处理),作为发起者创建RTCPeerConnection,绑定事件响应函数,加入本地流

offer是创建RTCPeerConnection将自己的媒体信息发送给对方,在收到new-peer信令的时候发送offer信令(执行doOffer方方法)

function handleNewPeer(newPeerMsg) {
    console.log(`handleNewPeer, the new one is user:${newPeerMsg.remoteUid}`);
    remoteUserId = newPeerMsg.remoteUid;
    doOffer();
}

接下来我们将接下来将后面的逻辑放在doOffer方法里面

/**
 * 媒体协商所需要的函数
 */
function doOffer() {
    //创建RTCPeerConnection
    if(rtcPeerConnection==null){
        rtcPeerConnection=createPeerConnection();
    }
    ...
}

doOffer首先要创建RTCPeerConnection对象,这个对象需要绑定收到candidate和track信息的回调函数,同时需要绑定本地媒体流的track信息

(stream其实是媒体流对象,一个可以获取字符流的接口,本地媒体流从设备中来,对方的媒体流从网络中来,后面需要继续完善所需的信息,才能从对方那里获取字符流)

使用addTrack方法需要我们引入adapter-latest.js文件

(873条消息) 关于adapter.js_sxc1989的博客-CSDN博客_adapter-latest.js

如果是在html的js使用,可以直接在代码前面引入

https://webrtc.github.io/adapter/adapter-latest.js

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FOhJWkAx-1669259633023)(D:\学习笔记\picture\image-20221123003534913-1669134937109-1.png)]

WebStorm可以帮我们从这些链接下载文件,避免每次运行都需要下载

如果是node.js,可以使用

npm install webrtc-adapter

下载依赖,然后就可以使用require引入

创建RTCPeerConnection,在这里绑定本地流

//创建RTCPeerConnection对象
function createPeerConnection() {
    const defaultConfiguration = {
        bundlePolicy: "max-bundle",
        rtcpMuxPolicy: "require",
        iceTransportPolicy: "all",//relay 或者 all
        // 修改ice数组测试效果,需要进行封装
        iceServers: [
            {
                "urls": [
                    `turn:${COTURN_IP}:${COTURN_PORT}?transport=udp`,
                    `turn:${COTURN_IP}:${COTURN_PORT}?transport=tcp`       // 可以插入多个进行备选
                ],
                "username": "root",
                "credential": "lth123456@@"
            },
            {
                "urls": [
                    `stun:${COTURN_IP}:${COTURN_PORT}`
                ]
            }
        ]
    };
    let rtcPeerConnection = new RTCPeerConnection(defaultConfiguration);
    //设置网络协商的回调函数
    rtcPeerConnection.onicecandidate = handleRemoteIceCandidate;
    //收到对方码流的回调函数,设置需要处理哪些流
    rtcPeerConnection.ontrack = handleRemoteStreamAdd;
    //保存本地码流,设置有哪些流需要传输
    localStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track, localStream));
    return rtcPeerConnection;
}
2.创建offer sdp,设置本地sdp,并将offer sdp发送到服务器

使用RTCPeerConnection的createOffer方法获取自己的SDP信息,并在回到函数中发送给对方,这个逻辑封装在doOffer里面

/**
 * 媒体协商所需要的函数
 */
function doOffer() {
    console.log("doOffer");
    //创建RTCPeerConnection
    if (rtcPeerConnection == null) {
        rtcPeerConnection = createPeerConnection();
    }
    //查询自己的sdp
    rtcPeerConnection.createOffer().then(createOfferAndSendMessage).catch(handleCreateOfferError);
}
/**
 * offer创建完成,拿到自己的的SDP后的回调函数,此时需要保存自己的SDP并发送给对方
 * @param sessionDescription 自己的SDP信息
 */
function createOfferAndSendMessage(sessionDescription) {
    //保存自己的SDP
    rtcPeerConnection.setLocalDescription(sessionDescription)
        .then(() => {
            //然后将自己的SDP发送给对方
            const sdpJsonMsg = {
                'cmd': SIGNAL_TYPE_OFFER,
                'roomId': roomId,
                'uid': localUserId,
                'remoteUid': remoteUserId,
                'msg': JSON.stringify(sessionDescription)
            };
            const sdpJsonStringMsg = JSON.stringify(sdpJsonMsg);
            zeroRTCEngine.sendMessage(sdpJsonStringMsg);
            console.log("handleRemoteIceCandidate message :", sdpJsonMsg);
        })
        .catch(err => console.error("handleRemoteIceCandidate 出现错误:", err));
}
服务器收到offer sdp 转发给指定的remoteClient

其实对于Offer,Answer,Candidate信令,服务器都只是起到一个转发的作用,将转发给房间里的另一个人

首先先添加这三个信令的处理函数

conn.on("text", str => {
        let jsonMsg;
        try {
            jsonMsg = JSON.parse(str);
        } catch (e) {
            console.warn("onMessage parse Json failed:", e);
            return;
        }
        switch (jsonMsg.cmd) {
            case SIGNAL_TYPE_JOIN:
                uid = jsonMsg.uid;
                handleJoin(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_LEAVE:
                handleLeave(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_OFFER:
                handleOffer(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_ANSWER:
                handleAnswer(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_CANDIDATE:
                handleCandidate(jsonMsg, conn);
                break;
            default:
                console.log("服务器收到消息:", jsonMsg);
                break;
        }
    });

编写转发函数,负责数据校验和内容转发

/**
 * 将消息原封不动地转发给另一个用户
 * @param jsonMsg 要转发的JSON对象
 * @param conn 当前链接
 */
function forwardMessage(jsonMsg, conn) {
    const roomId = jsonMsg.roomId;
    const uid = jsonMsg.uid;
    const remoteUid = jsonMsg.remoteUid;
    console.log('转发消息', jsonMsg);
    const userMap = roomTableMap.get(roomId);
    if (userMap === null) {
        let errMsg = "找不到房间" + roomId;
        console.error(errMsg);
        conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
        return;
    }
    const localClient = userMap.get(uid);
    if (localClient === null) {
        const errMsg = `找不到用户${uid}`;
        console.error(errMsg);
        conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
        return;
    }
    const remoteClient = userMap.get(remoteUid);
    if (remoteClient === null) {
        const errMsg = `找不到用户${uid}`;
        console.error(errMsg);
        conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
        return;
    }
    //其实核心就这一行:转发offer信令给远程客户端
    remoteClient.conn.sendText(JSON.stringify(jsonMsg));
}

这三个函数就调用forwardMessage转发消息,并打印日志

/**
 * 转发客户端发来的offer
 * @param offerJsonMsg offer信令信息
 * @param conn 客户端链接
 *
 * var jsonMsg={
 *     'cmd':'offer',
 *     'roomId':roomId,
 *     'uid':localUserId,
 *     'remoteUid':remoteUserId,
 *     'msg':JSON.stringify(sessionDescription)
 * }
 *
 */
function handleOffer(offerJsonMsg, conn) {
    console.log("handleOffer");
    forwardMessage(offerJsonMsg, conn);
}

/**
 * 转发客户端发来的Answer信令
 * @param answerJsonMsg Answer信令信息
 * @param conn 客户端链接
 *
 * var jsonMsg={
 * 	'cmd':'answer',
 * 	'roomId':roomId,
 * 	'uid':localUserId,
 * 	'remoteUid':remoteUserId,
 * 	'msg':JSON.stringify(sessionDescription)
 * }
 *
 *
 */
function handleAnswer(answerJsonMsg, conn) {
    console.log("handleAnswer");
    forwardMessage(answerJsonMsg, conn);
}

/**
 * 转发客户端发来的Candidate信令
 * @param candidateJsonMsg Candidate信令信息
 * @param conn 客户端链接
 *
 * var jsonMsg={
 *     'cmd':'candidate',
 *     'roomId':roomId,
 *     'uid':localUserId,
 *     'remoteUid':remoteUserId,
 *     'msg':JSON.stringify(candidate)
 * }
 *
 */
function handleCandidate(candidateJsonMsg, conn) {
    console.log("handleCandidate");
    forwardMessage(candidateJsonMsg, conn);
}
4.接收者收到offer,也创建RTCPeerConnection,绑定事件响应函数,加入本地流

接收端接受到Offer,也创建RTCPeerConnection,然后调用doAnswer函数创建SDP,保存SDP,发送SDP

/**
 * 处理offer信令,包含对方的sdp信息
 * 收到offer信令后要将sdp保存下来,并返回answer信令作为应答
 * @param offerJsonMsg JSON对象,包含以下属性(服务器端按照这种格式发送)
 * var offerJsonMsg={
 *     'cmd':'offer',
 *     'roomId':roomId,
 *     'uid':localUserId,
 *     'remoteUid':remoteUserId,
 *     'msg':JSON.stringify(sessionDescription)
 * }
 *
 */
function handleRemoteOffer(offerJsonMsg) {
    console.log("handleRemoteOffer ");
    if (rtcPeerConnection == null) {
        rtcPeerConnection = createPeerConnection();
    }
    doAnswer(offerJsonMsg);
}
5.接收者设置远程sdp,并创建answer sdp,然后设置本地sdp并将answer sdp发送到服务器;

具体的逻辑封装在doAnswer函数里面,注意这里调用的是createAnswer而不是createOffer

/**
 * 收到offer后的应答,也需要创建offer后发给对方
 */
function doAnswer(offerJsonMsg) {
    //查询自己的sdp,then回调函数的参数就是查询到的sdp信息
    console.log("doAnswer");
    //保存对方的SDP
    rtcPeerConnection.setRemoteDescription(JSON.parse(offerJsonMsg.msg));
    //查询自己的sdp
    rtcPeerConnection.createAnswer()
        .then(createAnswerAndSendMessage)
        .catch(handleCreateAnswerError)
        .catch(err => console.error("doAnswer setRemoteDescription", err));
}

查询到自己的SDP后,保存并发送

function createAnswerAndSendMessage(sessionDescription) {
    //保存自己的SDP
    rtcPeerConnection.setLocalDescription(sessionDescription)
        .then(() => {
            //然后将自己的SDP发送给对方
            const sdpJsonMsg = {
                'cmd': SIGNAL_TYPE_ANSWER,
                'roomId': roomId,
                'uid': localUserId,
                'remoteUid': remoteUserId,
                'msg': JSON.stringify(sessionDescription)
            };
            const sdpJsonStringMsg = JSON.stringify(sdpJsonMsg);
            zeroRTCEngine.sendMessage(sdpJsonStringMsg);
            console.log("send answer message :", sdpJsonMsg);
        })
        .catch(err => console.error("answer setLocalDescription 出现错误:", err));
}
6.服务器收到answer sdp 转发给指定的remoteClient
/**
 * 转发客户端发来的Answer信令
 * @param answerJsonMsg Answer信令信息
 * @param conn 客户端链接
 *
 * var jsonMsg={
 * 	'cmd':'answer',
 * 	'roomId':roomId,
 * 	'uid':localUserId,
 * 	'remoteUid':remoteUserId,
 * 	'msg':JSON.stringify(sessionDescription)
 * }
 *
 *
 */
function handleAnswer(answerJsonMsg, conn) {
    console.log("handleAnswer");
    forwardMessage(answerJsonMsg, conn);
}
7.发起者收到answer sdp,则设置远程sdp

收到answer信令后,保存远端SDP(保存自己的SDPsetLocalDescription,保存对方的SDPsetRemoteDescription

function handleRemoteAnswer(answerJsonMsg) {
    console.log("收到应答", answerJsonMsg);
    let desc = JSON.parse(answerJsonMsg.msg);
    rtcPeerConnection.setRemoteDescription(desc)
        .catch(err => console.error("handleRemoteAnswer 出现错误,", err));
}

8.发起者和接收者都收到ontrack回调事件,获取到对方码流的对象句柄

在前面创建RTCPeerConnection的时候需要设置这些回调事件,这里会触发ontrack事件,获取到对方的码流,这样赋值给remoteVideo.srcObject就能实现视频的传输

/**
 * 收到对方媒体流的回调函数
 * @param event 传递过来的json对象,媒体流就放在event.stream里面,这是一个数组
 */
function handleRemoteStreamAdd(event) {
    console.log(`handleRemoteStreamAdd `, event);
    remoteStream = event.streams[0];
    remoteVideo.srcObject = remoteStream;
}
9.发起者和接收者都开始请求打洞,通过onIceCandidate获取到打洞信息(candidate)并发送给对方

这个阶段会发送和获取多个Candidate信息,找到能打洞的Candidate信息

/**
 * 收到网络协商信息的回调函数
 * @param event 网络协商的信息
 */
function handleRemoteIceCandidate(event) {
    if (event.candidate) {
        const candidateJsonMsg = {
            'cmd': SIGNAL_TYPE_CANDIDATE,
            'roomId': roomId,
            'uid': localUserId,
            'remoteUid': remoteUserId,
            'msg': JSON.stringify(event.candidate)
        };
        const candidateMsg = JSON.stringify(candidateJsonMsg);
        zeroRTCEngine.sendMessage(candidateMsg);
        console.log(`handleRemoteIceCandidate message`);
    } else {
        console.warn("End of candidates");
    }
}

至此,一对一音视频通话的基本功能就完成了

综合调试和完善

可以添加一个UserMap,保存用户所在的房间,服务器在处理Join信令后,保存Uid,这样在链接端口或者出现错误的时候,就可以将用户从房间里面删除

const server = ws.createServer(conn => {
    console.log("创建了一个新的链接");
    //当前链接的用户ID
    let uid;
    conn.on("text", str => {
        let jsonMsg;
        try {
            jsonMsg = JSON.parse(str);
        } catch (e) {
            console.warn("onMessage parse Json failed:", e);
            return;
        }
        switch (jsonMsg.cmd) {
            case SIGNAL_TYPE_JOIN:
                //用户加入后,保存用户ID
                uid = jsonMsg.uid;
                handleJoin(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_LEAVE:
                handleLeave(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_OFFER:
                handleOffer(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_ANSWER:
                handleAnswer(jsonMsg, conn);
                break;
            case SIGNAL_TYPE_CANDIDATE:
                handleCandidate(jsonMsg, conn);
                break;
            default:
                console.log("服务器收到消息:", jsonMsg);
                break;
        }
    });

    conn.on("close", (code, reason) => {
        userExit(uid);
    });
    conn.on("error", err => {
        console.log("监听到错误:", err);
        userExit(uid);
    });
});

将用户从房间里删除

function userExit(uid) {
    const roomId = userRoomMap.get(uid);
    if (roomId != null) {
        userRoomMap.delete(uid);
        let userMap = roomTableMap.get(roomId);
        userMap.delete(uid);
        console.log(`用户${uid}退出房间${roomId}`);
    }
}
细节问题分析和解决

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A2VDZxF4-1669259633023)(D:\学习笔记\picture\image-20221122233826766.png)]

offer信令里面包含本地的uid和p2p对方的uid,这个实际上可以当成一个链接,如果在多人聊天的时候,可以通过两两之间建立p2p链接来传输音视频

关闭选项卡的时候会出现服务器会监听到错误而不是监听到关闭链接:

Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:217:20) {
  errno: -4077,
  code: 'ECONNRESET',
  syscall: 'read'
}

所以需要在关闭链接以及出现错误的时候,让当前用户退出所在房间

点击加入按钮的时候我们进行如下操作:

//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
    console.log("加入按钮被点击");
    if (!setRoomId()) return;
    initLocalStream()
    //向信令服务器发送加入信令
    doJoin(roomId);
};

因为doJoin方法要用到localStream,这个变量是在initLocalStream方法中getUserMedia执行完后进行赋值,但是这个返回的是一个Promise,会和主线程异步执行,所以initLocalStreamdoJoin谁先执行完是未知的

//初始化本地码流
function initLocalStream() {
    return new Promise((resolve, reject) => {
        navigator.mediaDevices.getUserMedia({audio: true, video: true})
            .then(openLocalStream)
            .then(resolve)
            .catch(e => {
                console.log("getUserMedia() error: " + e.name);
                reject();
            });
    });
}

解决方法是再使用一个Promise,在里面.then(openLocalStream)的后面加上.then(resolve),同一个Promise的then方法是按序执行的,而再使用一个Promise可以将内部的实现抛出到外面,所以外部这么写即可确保执行的顺序

//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
    console.log("加入按钮被点击");
    if (!setRoomId()) return;
    /*
        初始化本地码流,必须先于JOIN,因为后面要将码流添加进RTCPeerConnection,所以在添加之前就要获取到localStream
        又因为getUserMedia是一个Promise,获取流的操作会异步执行,所以如果直接在下面写doJoin,可能initLocalStream还没有执行完就开始执行
        doJoin,导致后面要使用localStream的时候还是null,解决办法是在then(openLocalStream)的后面在加上then,因为同一个Promise的then
        一定是顺序执行的,再创建一个新的Promise,把下一步的实现抛出来,就可以解决这个问题
     */
    initLocalStream().then(() => {
        //向信令服务器发送加入信令
        doJoin(roomId);
    });
};

doOffer是发起offer,双方只能有一方调用这个函数,否则会造成混乱。而根据时序图,先到房间的先发起Offer,所以在处理完new-peer信令后发起offer

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值