WebRTC实现双端音视频聊天(Vue3 + SpringBoot)

目录

概述

相关概念

双端连接整体实现步骤概述

文章代码实现注意点

STUN和TURN服务器的搭建

开发过程描述

后端开发流程

前端开发流程

效果演示

Gitee源码地址


概述

  • 文章描述使用WebRTC技术实现一对一音视频通话

  • 由于设备摄像头限制(一台电脑作测试无法在开启的双端同时获取摄像头数据流),导致一台电脑无法同时测试双端,因此文章使用mp4音视频文件模拟摄像头音视频数据流输入

  • 使用技术

    • 前端:Vue3,WebRTC相关API,axios

    • 后端信令服务器实现:SpringBoot,WebSocket

相关概念

  • Peer-to-Peer (P2P) 连接 :WebRTC主要是基于 P2P 连接的,这意味着通信是直接在两端的浏览器之间进行的,而不需要经过中介服务器(尽管可能会使用服务器来初始化和协调连接)。这种方式降低了延迟并节省了带宽。

  • SDP **(Session Description Protocol)**:**描述媒体信息(如音频、视频编码格式、传输协议等)**的协议。例如我们在双方构建连接时,我们需要知道对方使用的音视频编解码格式,以确保双方使用相同编解码格式。编解码格式就是定义在SDP信息中的其中之一的信息。

  • ICE Candidate :ICE 候选是 WebRTC 在 P2P 连接过程中为寻找最佳传输路径(如 STUN 或 TURN 服务器)提供的一系列地址和端口。在双方构建连接时需要知道对方的公网IP****地址和端口,以实现P2P连接,Candidate信息中就包含自身的公网IP和端口。

  • STUN(Session Traversal Utilities for NAT **)服务器**:是 NAT 穿透的协议,用来获取客户端的公网 IP 地址和端口。我们身处各种局域网中,对方如果想要和我们构建P2P连接,就必然要知道我们的公网IP和端口才能和我们连接上,我们可以通过STUN服务器获取我们的公网IP和端口

  • TURN(Traversal Using Relays around NAT **)服务器**:当 STUN 连接不可用时,TURN 服务器作为中继服务器转发数据。当STUN服务器无法帮助我们获取公网IP和端口时,我们就可以使用TURN服务器作为中转站传递音视频流数据。

  • 信令服务器:上面介绍了媒体信息SDP和网络信息Candidate,这些实际上可以称为"信令",我们如果想要与对端连接,那么我们就需要知道对端的媒体信息和网络信息来构建连接,信令服务器就是帮助我们实现两端的信息交换的。本文中信令服务器就是我们自己编写的SpringBoot后端,来帮助两端互传连接信息。

双端连接整体实现步骤概述

在大致知道了上面介绍的WebRTC基本概念之后,我们以双端音视频互联的整体过程。

假设存在A端(发起端)B端(接收端)

1. 创建RTC连接对象(new RTCPeerConnection),此对象存在构建连接时所需的API。

2. A端和B端分别连接后端WebSocket(信令服务器),以为接下来信息互传奠定基础。

3. A端创建媒体信息SDP(createOffer)保存到本地(setLocalDescription),将A端SDP信息通过WebSocket发送给B端。

4. B端接收到A端的SDP信息,设置为远端媒体信息(setRemoteDescription),然后B端创建应答媒体信息(实际上就是B端的媒体信息)SDP(createAnswer)保存到本地(setLocalDescription),并将B端创建的应答媒体信息SDP通过WebSocket发送给A端。

5. A端收到B端发送的应答媒体信息SDP后,保存为远端媒体信息(setRemoteDescription)。

  1. 至此,A端和B端媒体信息SDP交换完毕。

7. 开始交换网络信息Candidate,我们在创建RTC连接对象时(步骤1)监听网络信息的获取(onicecandidate),当我们调用setRemoteDescription函数设置了远端媒体信息之后,会触发onicecandidate并给予condidate网络信息。

  1. 我们将监听到的网络信息candidate通过WebSocket发送给对端,对端收到后将对方的网络信息配置上(addIceCandidate)以实现连接。

9. 当媒体信息SDP和网络信息Candidate互相交换并设置上之后,就可以开始音视频流数据互传显示了。

10. 通过addTrack发送本地流数据,通过ontrack监听对端音视频流数据的发送,监听到就显示对端音视频。


媒体协商和网络协商时序图:

**总结:**在视频互传之前重要的就是交换媒体SDP信息和网络Candidate信息(媒体和网络协商),当双方都获取到对方的媒体和网络信息之后。就能够成功构建连接并传递音视频数据了。

文章代码实现注意点

在最开始的概述中有提到,本文提供的1对1音视频聊天代码示例中没有真实调用用户摄像头获取音视频流数据,因为作者只有一台电脑,为了可以更方便的在一台电脑上开启两端并测试,因此使用了MP4音视频作为音视频流数据输入作为测试。

这实际上并不会和真实开启摄像头获取音视频数据流有很大的区别。仅仅是获取流数据的方式不同罢了。

在真实的场景下,可以使用API:getUserMedia去获取摄像头音视频流数据即可。

复制代码
const stream = await navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true
});

STUN和TURN服务器的搭建

为了能够获取到我们本地的公网IP和端口去和对端创建连接,我们可以尝试去搭建STUN服务器和TURN中继服务器。

**注:**此步骤不是一定需要做,因为Google给我们提供了一个免费公用的STUN服务器地址:stun:stun.l.google.com:19302,如果你发现用不了,或需要搭建复杂的音视频通话应用,还是推荐自己搭建一下STUN/TURN服务器。

我们直接搭建开源的Coturn服务器即可,因为Coturn 同时支持 TURN 和 STUN 协议。

下面会介绍在CentOS8中搭建Coturn服务器步骤:

1. 安装所需依赖包

复制代码
yum install -y make gcc cc gcc-c++ wget openssl-devel libevent libevent-devel openssl 

2. yum直接一键下载安装

复制代码
sudo yum install coturn

# (验证安装)安装程序结束后执行如下命令查看是否正确输出turnserver路径
which turnserver

3. 配置Coturn相关属性,找到配置文件路径:

复制代码
find / -name turnserver.conf

4. 获取服务器内网IP和公网IP

复制代码
# 输入命令查看Ip
ifconfig

找到自己启用的网络下的内网IP,公网IP就是你连接服务器的IP地址。

5. 使用openSSL生成cert和pkey配置的自签名证书

复制代码
openssl req -x509 -newkey rsa:2048 -keyout /turn_server_pkey.pem -out /turn_server_cert.pem -days 999 -nodes 

输入上面命令后,填写一下证书的一些信息(城市,地区等),随便填一下回车回车!就行。

上面的/turn_server_pkey.pem和/turn_server_cert.pem 请自己设置好保存证书的路径,上面默认放到了根路径下。

6. 编辑刚才找到的配置文件

将下面的配置部分修改后替换掉原配置文件的所有内容。

复制代码
# 网卡名
relay-device=eth0
#内网IP
listening-ip=172.24.52.189 
listening-port=3478
#内网IP,加密访问配置
relay-ip=172.24.52.189
tls-listening-port=5349
# 外网IP
external-ip=自己的外网IP
relay-threads=500
#打开密码验证
lt-cred-mech
cert=/turn_server_cert.pem
pkey=/turn_server_pkey.pem
min-port=40000
max-port=65535
#设置用户名和密码,创建IceServer时使用
user=user:123456
# 外网IP绑定的域名
realm=你自己IP绑定的域名
# 服务器名称,用于OAuth认证,默认和realm相同,部分浏览器本段不设可能会引发cors错误。
server-name=你自己IP绑定的域名
# 认证密码,和前面设置的密码保持一致
cli-password=123456

7. 开启端口访问

7.1 开启云服务器安全组端口

开启4000-65535端口的原因:外部客户端与 TURN 服务器的通信使用动态端口 。通常,操作系统会为每个连接分配一个临时端口(通常是大于 1024 的端口),而 40000 到 65535 端口 作为 高端端口,是常用的临时端口范围。因此,为了确保 TURN 服务器能够处理大量的并发连接,并为每个连接分配一个端口,需要确保 TURN 服务器的端口范围足够大。

7.2开启本地防火墙端口

复制代码
#开放端口
firewall-cmd --zone=public --add-port=3478/udp --permanent
firewall-cmd --zone=public --add-port=3478/tcp --permanent
#重启防火墙
firewall-cmd --reload

8. 启动Coturn服务器

复制代码
turnserver -o -a -f

9. 测试启动状态

访问测试网站:Trickle ICE

开发过程描述

如下仅展示关键性代码解释说明,具体代码请到文章最后获取Gitee源码地址。

后端开发流程

  • websocket连接成功后维护用户连接信息并广播join消息。数据携带用户ID列表。

    // 后端维护Session连接的数据结构

    private final HashMap<String, WebSocketSession> userMap = new HashMap<>();

  • 编写接收信息通用接口,dto对象包含userID,type,data(JSON序列化字符串),接口根据传入userId取出session,给session发送消息对象。

前端开发流程

  • 日志系统,监听ice状态及日志打印。

  • 创建随机ID,连接ws。

  • 协商函数:协商前创建peerConnection对象并监听candidate,当双方都连接成功后调用,判断本地offerFlag状态,如果为true,创建offer设置本地并发送消息给对端。

    // STUN 服务器

    const iceServers = [

    {

    urls: "stun:stun.l.google.com:19302" // Google公开的STUN 服务器

    },

    {

    urls: "stun:自己的STUN服务器IP:3478" // 自己的Stun服务器

    },

    {

    urls: "turn:自己的TRUN服务器IP:3478", // 自己的TURN服务器

    username: "userName",

    credential: "Password"

    }

    ];

    // 创建RTC连接对象并监听和获取condidate信息

    function createPeerConnection() {

    wlog("开始创建PC对象...")

    peerConnection = new RTCPeerConnection(iceServers);

    wlog("创建PC对象成功")

    // 创建RTC连接对象后连接websocket

    initWebSocket();

    // 监听网络信息(ICE Candidate)

    peerConnection.onicecandidate = (event) => {

    if (event.candidate) {

    candidateInfo = event.candidate;

    wlog("candidate信息变化...");

    // 将candidate信息发送给远端

    setTimeout(()=>{

    sendCandidate(event.candidate);

    }, 150)

    }

    };

    // 监听远端音视频流

    peerConnection.ontrack = (event) => {

    nextTick(() => {

    wlog("> 收到远端数据流 <=")

    if (!remoteVideo.value.srcObject) {

    remoteVideo.value.srcObject = event.streams0;

    remoteVideo.value.play(); // 强制播放

    }

    });

    // remoteVideo.value.srcObject = event.streams0;

    };

    // 监听ice连接状态

    peerConnection.oniceconnectionstatechange = () => {

    wlog(RTC连接状态改变:${peerConnection.iceConnectionState});

    };

    // 添加本地音视频流到 PeerConnection

    localStream.getTracks().forEach(track => {

    peerConnection.addTrack(track, localStream);

    });

    }

  • candidate监听:当监听到candidate后判断双方是否已连接,如果已连接,构造并发送candidate给对端。

  • 解析消息处理器

    • 解析join:type为join取出userId列表,如果为一个代表仅自己在线,标识为创建offer端,日志打印相关信息,如果有两个者取出对方ID保存,代表双方都上线成功,日志打印,调用协商函数,开始媒体协商和网络协商。

    • 解析offer:type为offer,说明收到发起端offer,将offer设置为远端信息,然后创建answer设置到本地,构建answer消息发送给对端。

    • 解析answer:type为answer,说明收到接收端应答,取出answer设置为远端消息。

    • 解析candidate:type为candidate,说明收到对端的网络信息,取出设置到本地。

    // 消息处理器 - 解析器

    function handleSignalingMessage(message) {

    wlog("收到ws消息,开始解析...")

    wlog(message)

    let parseMsg = JSON.parse(message);

    wlog(解析结果:${parseMsg});

    if (parseMsg.type == "join") {

    joinHandle(parseMsg.data);

    } else if (parseMsg.type == "offer") {

    wlog("收到发起端offer,开始解析...");

    offerHandle(parseMsg.data);

    } else if (parseMsg.type == "answer") {

    wlog("收到接收端的answer,开始解析...");

    answerHandle(parseMsg.data);

    }else if(parseMsg.type == "candidate"){

    wlog("收到远端candidate,开始解析...");

    candidateHandle(parseMsg.data);

    }

    }

    // 远端Candidate处理器

    async function candidateHandle(candidate){

    peerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate)));

    wlog("+++++++ 本端candidate设置完毕 ++++++++");

    }

    // 接收端的answer处理

    async function answerHandle(answer) {

    wlog("将answer设置为远端信息");

    peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(answer))); // 设置远端SDP

    }

    // 发起端offer处理器

    async function offerHandle(offer) {

    wlog("将发起端的offer设置为远端媒体信息");

    await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(offer)));

    wlog("创建Answer 并设置到本地");

    let answer = await peerConnection.createAnswer()

    await peerConnection.setLocalDescription(answer);

    wlog("发送answer给发起端");

    // 构造answer消息发送给对端

    let paramObj = {

    userId: oppositeUserId,

    type: "answer",

    data: JSON.stringify(answer)

    }

    // 执行发送

    const res = await axios.post(${BaseUrl}/rtcs/sendMessage, paramObj);

    }

    // 加入处理器

    function joinHandle(userIds) {

    // 判断连接的用户个数

    if (userIds.length == 1 && userIds0 == userId) {

    wlog("标识为发起端,等待对方加入房间...")

    isRoomEmpty.value = true;

    // 存在一个连接并且是自身,标识我们是发起端

    offerFlag = true;

    } else if (userIds.length > 1) {

    // 对方加入了

    wlog("对方已连接...")

    isRoomEmpty.value = false;

    复制代码
    // 取出对方ID
    for (let id of userIds) {
      if (id != userId) {
        oppositeUserId = id;
      }
    }
    
    wlog(`对端ID: ${oppositeUserId}`)
    // 开始交换SDP和Candidate
    swapVideoInfo()

    }

    }

效果演示

初始状态

发起端加入房间

接收端加入房间

Gitee源码地址

源码地址:点击访问Gitee项目源代码。

相关推荐
阿拉斯攀登1 小时前
FFmpeg 入门:命令行的 20 个常用场景
ffmpeg·音视频·webrtc·视频编解码
小明bishe1811 小时前
计算机毕业设计之基于JAVA的植物科普网站
java·spring boot·spring·架构·课程设计
ai产品老杨12 小时前
H264 H265视频分析常见问题和排查清单
人工智能·算法·音视频
“码”力全开12 小时前
AI视频分析API性能优化指南
人工智能·性能优化·音视频
万亿少女的梦16813 小时前
基于Python的高考志愿填报辅助系统设计与实现
java·spring boot·python·mysql·vue
万亿少女的梦16813 小时前
基于SpringBoot与Vue的历史博物馆展品展示与游客管理系统设计
spring boot·mysql·vue·系统设计·博物馆管理系统
美狐美颜SDK开放平台14 小时前
Android/iOS直播APP平台开发中的视频美颜SDK优化技巧
android·ios·音视频·美颜sdk·第三方美颜sdk
AI服务老曹16 小时前
视觉算法模型管理完整流程:多版本上线、灰度发布与回滚的落地实践
人工智能·docker·音视频
cfm_291417 小时前
SpringBoot MockMvc
spring boot·后端·log4j
AaronZZH18 小时前
AAF 框架能力介绍:图文音视频一站式 AIGC 模块上线
aigc·音视频