WebRTC實(shí)現(xiàn)雙端音視頻聊天功能(Vue3 + SpringBoot )
概述
- 文章描述使用WebRTC技術(shù)實(shí)現(xiàn)一對(duì)一音視頻通話。
- 由于設(shè)備攝像頭限制(一臺(tái)電腦作測(cè)試無(wú)法在開啟的雙端同時(shí)獲取攝像頭數(shù)據(jù)流),導(dǎo)致一臺(tái)電腦無(wú)法同時(shí)測(cè)試雙端,因此文章使用mp4音視頻文件模擬攝像頭音視頻數(shù)據(jù)流輸入。
- 使用技術(shù)
- 前端:Vue3,WebRTC相關(guān)API,axios
- 后端信令服務(wù)器實(shí)現(xiàn):SpringBoot,WebSocket
相關(guān)概念
- Peer-to-Peer (P2P) 連接:WebRTC主要是基于 P2P 連接的,這意味著通信是直接在兩端的瀏覽器之間進(jìn)行的,而不需要經(jīng)過(guò)中介服務(wù)器(盡管可能會(huì)使用服務(wù)器來(lái)初始化和協(xié)調(diào)連接)。這種方式降低了延遲并節(jié)省了帶寬。
- SDP**(Session Description Protocol)**:**描述媒體信息(如音頻、視頻編碼格式、傳輸協(xié)議等)**的協(xié)議。例如我們?cè)陔p方構(gòu)建連接時(shí),我們需要知道對(duì)方使用的音視頻編解碼格式,以確保雙方使用相同編解碼格式。編解碼格式就是定義在SDP信息中的其中之一的信息。
- ICE Candidate:ICE 候選是 WebRTC 在 P2P 連接過(guò)程中為尋找最佳傳輸路徑(如 STUN 或 TURN 服務(wù)器)提供的一系列地址和端口。在雙方構(gòu)建連接時(shí)需要知道對(duì)方的公網(wǎng)IP****地址和端口,以實(shí)現(xiàn)P2P連接,Candidate信息中就包含自身的公網(wǎng)IP和端口。
- STUN(Session Traversal Utilities for NAT**)服務(wù)器**:是 NAT 穿透的協(xié)議,用來(lái)獲取客戶端的公網(wǎng) IP 地址和端口。我們身處各種局域網(wǎng)中,對(duì)方如果想要和我們構(gòu)建P2P連接,就必然要知道我們的公網(wǎng)IP和端口才能和我們連接上,我們可以通過(guò)STUN服務(wù)器獲取我們的公網(wǎng)IP和端口。
- TURN(Traversal Using Relays around NAT**)服務(wù)器**:當(dāng) STUN 連接不可用時(shí),TURN 服務(wù)器作為中繼服務(wù)器轉(zhuǎn)發(fā)數(shù)據(jù)。當(dāng)STUN服務(wù)器無(wú)法幫助我們獲取公網(wǎng)IP和端口時(shí),我們就可以使用TURN服務(wù)器作為中轉(zhuǎn)站傳遞音視頻流數(shù)據(jù)。
- 信令服務(wù)器:上面介紹了媒體信息SDP和網(wǎng)絡(luò)信息Candidate,這些實(shí)際上可以稱為"信令",我們?nèi)绻胍c對(duì)端連接,那么我們就需要知道對(duì)端的媒體信息和網(wǎng)絡(luò)信息來(lái)構(gòu)建連接,信令服務(wù)器就是幫助我們實(shí)現(xiàn)兩端的信息交換的。本文中信令服務(wù)器就是我們自己編寫的SpringBoot后端,來(lái)幫助兩端互傳連接信息。
雙端連接整體實(shí)現(xiàn)步驟概述
在大致知道了上面介紹的WebRTC基本概念之后,我們以雙端音視頻互聯(lián)的整體過(guò)程。
假設(shè)存在A端(發(fā)起端)和B端(接收端)。
1. 創(chuàng)建RTC連接對(duì)象(new RTCPeerConnection),此對(duì)象存在構(gòu)建連接時(shí)所需的API。
2. A端和B端分別連接后端WebSocket(信令服務(wù)器),以為接下來(lái)信息互傳奠定基礎(chǔ)。
3. A端創(chuàng)建媒體信息SDP(createOffer)保存到本地(setLocalDescription),將A端SDP信息通過(guò)WebSocket發(fā)送給B端。
4. B端接收到A端的SDP信息,設(shè)置為遠(yuǎn)端媒體信息(setRemoteDescription),然后B端創(chuàng)建應(yīng)答媒體信息(實(shí)際上就是B端的媒體信息)SDP(createAnswer)保存到本地(setLocalDescription),并將B端創(chuàng)建的應(yīng)答媒體信息SDP通過(guò)WebSocket發(fā)送給A端。
5. A端收到B端發(fā)送的應(yīng)答媒體信息SDP后,保存為遠(yuǎn)端媒體信息(setRemoteDescription)。
6. 至此,A端和B端媒體信息SDP交換完畢。
7. 開始交換網(wǎng)絡(luò)信息Candidate,我們?cè)趧?chuàng)建RTC連接對(duì)象時(shí)(步驟1)監(jiān)聽網(wǎng)絡(luò)信息的獲?。╫nicecandidate),當(dāng)我們調(diào)用setRemoteDescription函數(shù)設(shè)置了遠(yuǎn)端媒體信息之后,會(huì)觸發(fā)onicecandidate并給予condidate網(wǎng)絡(luò)信息。
8. 我們將監(jiān)聽到的網(wǎng)絡(luò)信息candidate通過(guò)WebSocket發(fā)送給對(duì)端,對(duì)端收到后將對(duì)方的網(wǎng)絡(luò)信息配置上(addIceCandidate)以實(shí)現(xiàn)連接。
9. 當(dāng)媒體信息SDP和網(wǎng)絡(luò)信息Candidate互相交換并設(shè)置上之后,就可以開始音視頻流數(shù)據(jù)互傳顯示了。
10. 通過(guò)addTrack發(fā)送本地流數(shù)據(jù),通過(guò)ontrack監(jiān)聽對(duì)端音視頻流數(shù)據(jù)的發(fā)送,監(jiān)聽到就顯示對(duì)端音視頻。
媒體協(xié)商和網(wǎng)絡(luò)協(xié)商時(shí)序圖:

**總結(jié):**在視頻互傳之前重要的就是交換媒體SDP信息和網(wǎng)絡(luò)Candidate信息(媒體和網(wǎng)絡(luò)協(xié)商),當(dāng)雙方都獲取到對(duì)方的媒體和網(wǎng)絡(luò)信息之后。就能夠成功構(gòu)建連接并傳遞音視頻數(shù)據(jù)了。
文章代碼實(shí)現(xiàn)注意點(diǎn)
在最開始的概述中有提到,本文提供的1對(duì)1音視頻聊天代碼示例中沒(méi)有真實(shí)調(diào)用用戶攝像頭獲取音視頻流數(shù)據(jù),因?yàn)樽髡咧挥幸慌_(tái)電腦,為了可以更方便的在一臺(tái)電腦上開啟兩端并測(cè)試,因此使用了MP4音視頻作為音視頻流數(shù)據(jù)輸入作為測(cè)試。
這實(shí)際上并不會(huì)和真實(shí)開啟攝像頭獲取音視頻數(shù)據(jù)流有很大的區(qū)別。僅僅是獲取流數(shù)據(jù)的方式不同罷了。
在真實(shí)的場(chǎng)景下,可以使用API:getUserMedia去獲取攝像頭音視頻流數(shù)據(jù)即可。
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});STUN和TURN服務(wù)器的搭建
為了能夠獲取到我們本地的公網(wǎng)IP和端口去和對(duì)端創(chuàng)建連接,我們可以嘗試去搭建STUN服務(wù)器和TURN中繼服務(wù)器。
**注:**此步驟不是一定需要做,因?yàn)镚oogle給我們提供了一個(gè)免費(fèi)公用的STUN服務(wù)器地址:stun:stun.l.google.com:19302,如果你發(fā)現(xiàn)用不了,或需要搭建復(fù)雜的音視頻通話應(yīng)用,還是推薦自己搭建一下STUN/TURN服務(wù)器。
我們直接搭建開源的Coturn服務(wù)器即可,因?yàn)镃oturn 同時(shí)支持 TURN 和 STUN 協(xié)議。
下面會(huì)介紹在CentOS8中搭建Coturn服務(wù)器步驟:
1. 安裝所需依賴包
yum install -y make gcc cc gcc-c++ wget openssl-devel libevent libevent-devel openssl
2. yum直接一鍵下載安裝
sudo yum install coturn # (驗(yàn)證安裝)安裝程序結(jié)束后執(zhí)行如下命令查看是否正確輸出turnserver路徑 which turnserver
3. 配置Coturn相關(guān)屬性,找到配置文件路徑:
find / -name turnserver.conf
4. 獲取服務(wù)器內(nèi)網(wǎng)IP和公網(wǎng)IP
# 輸入命令查看Ip ifconfig
找到自己?jiǎn)⒂玫木W(wǎng)絡(luò)下的內(nèi)網(wǎng)IP,公網(wǎng)IP就是你連接服務(wù)器的IP地址。

5. 使用openSSL生成cert和pkey配置的自簽名證書
openssl req -x509 -newkey rsa:2048 -keyout /turn_server_pkey.pem -out /turn_server_cert.pem -days 999 -nodes
輸入上面命令后,填寫一下證書的一些信息(城市,地區(qū)等),隨便填一下回車回車!就行。
上面的/turn_server_pkey.pem和/turn_server_cert.pem 請(qǐng)自己設(shè)置好保存證書的路徑,上面默認(rèn)放到了根路徑下。
6. 編輯剛才找到的配置文件
將下面的配置部分修改后替換掉原配置文件的所有內(nèi)容。
# 網(wǎng)卡名 relay-device=eth0 #內(nèi)網(wǎng)IP listening-ip=172.24.52.189 listening-port=3478 #內(nèi)網(wǎng)IP,加密訪問(wèn)配置 relay-ip=172.24.52.189 tls-listening-port=5349 # 外網(wǎng)IP external-ip=自己的外網(wǎng)IP relay-threads=500 #打開密碼驗(yàn)證 lt-cred-mech cert=/turn_server_cert.pem pkey=/turn_server_pkey.pem min-port=40000 max-port=65535 #設(shè)置用戶名和密碼,創(chuàng)建IceServer時(shí)使用 user=user:123456 # 外網(wǎng)IP綁定的域名 realm=你自己IP綁定的域名 # 服務(wù)器名稱,用于OAuth認(rèn)證,默認(rèn)和realm相同,部分瀏覽器本段不設(shè)可能會(huì)引發(fā)cors錯(cuò)誤。 server-name=你自己IP綁定的域名 # 認(rèn)證密碼,和前面設(shè)置的密碼保持一致 cli-password=123456
7. 開啟端口訪問(wèn)
7.1 開啟云服務(wù)器安全組端口

開啟4000-65535端口的原因:外部客戶端與 TURN 服務(wù)器的通信使用動(dòng)態(tài)端口。通常,操作系統(tǒng)會(huì)為每個(gè)連接分配一個(gè)臨時(shí)端口(通常是大于 1024 的端口),而 40000 到 65535 端口 作為 高端端口,是常用的臨時(shí)端口范圍。因此,為了確保 TURN 服務(wù)器能夠處理大量的并發(fā)連接,并為每個(gè)連接分配一個(gè)端口,需要確保 TURN 服務(wù)器的端口范圍足夠大。
7.2開啟本地防火墻端口
#開放端口 firewall-cmd --zone=public --add-port=3478/udp --permanent firewall-cmd --zone=public --add-port=3478/tcp --permanent #重啟防火墻 firewall-cmd --reload
8. 啟動(dòng)Coturn服務(wù)器
turnserver -o -a -f
9. 測(cè)試啟動(dòng)狀態(tài)
訪問(wèn)測(cè)試網(wǎng)站:Trickle ICE


開發(fā)過(guò)程描述
如下僅展示關(guān)鍵性代碼解釋說(shuō)明,具體代碼請(qǐng)到文章最后獲取Gitee源碼地址。
后端開發(fā)流程
- websocket連接成功后維護(hù)用戶連接信息并廣播join消息。數(shù)據(jù)攜帶用戶ID列表。
// 后端維護(hù)Session連接的數(shù)據(jù)結(jié)構(gòu)
private final HashMap<String, WebSocketSession> userMap = new HashMap<>();
- 編寫接收信息通用接口,dto對(duì)象包含userID,type,data(JSON序列化字符串),接口根據(jù)傳入userId取出session,給session發(fā)送消息對(duì)象。
前端開發(fā)流程
- 日志系統(tǒng),監(jiān)聽ice狀態(tài)及日志打印。
- 創(chuàng)建隨機(jī)ID,連接ws。
- 協(xié)商函數(shù):協(xié)商前創(chuàng)建peerConnection對(duì)象并監(jiān)聽candidate,當(dāng)雙方都連接成功后調(diào)用,判斷本地offerFlag狀態(tài),如果為true,創(chuàng)建offer設(shè)置本地并發(fā)送消息給對(duì)端。
// STUN 服務(wù)器
const iceServers = [
{
urls: “stun:stun.l.google.com:19302” // Google公開的STUN 服務(wù)器
},
{
urls: “stun:自己的STUN服務(wù)器IP:3478” // 自己的Stun服務(wù)器
},
{
urls: “turn:自己的TRUN服務(wù)器IP:3478”, // 自己的TURN服務(wù)器
username: “userName”,
credential: “Password”
}
];
// 創(chuàng)建RTC連接對(duì)象并監(jiān)聽和獲取condidate信息
function createPeerConnection() {
wlog(“開始創(chuàng)建PC對(duì)象…”)
peerConnection = new RTCPeerConnection(iceServers);
wlog(“創(chuàng)建PC對(duì)象成功”)
// 創(chuàng)建RTC連接對(duì)象后連接websocket
initWebSocket();
// 監(jiān)聽網(wǎng)絡(luò)信息(ICE Candidate)
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
candidateInfo = event.candidate;
wlog(“candidate信息變化…”);
// 將candidate信息發(fā)送給遠(yuǎn)端
setTimeout(()=>{
sendCandidate(event.candidate);
}, 150)
}
};
// 監(jiān)聽遠(yuǎn)端音視頻流
peerConnection.ontrack = (event) => {
nextTick(() => {
wlog(“> 收到遠(yuǎn)端數(shù)據(jù)流 <=”)
if (!remoteVideo.value.srcObject) {
remoteVideo.value.srcObject = event.streams[0];
remoteVideo.value.play(); // 強(qiáng)制播放
}
});
// remoteVideo.value.srcObject = event.streams[0];
};
// 監(jiān)聽ice連接狀態(tài)
peerConnection.oniceconnectionstatechange = () => {
wlog(RTC連接狀態(tài)改變:${peerConnection.iceConnectionState});
};
// 添加本地音視頻流到 PeerConnection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
}- candidate監(jiān)聽:當(dāng)監(jiān)聽到candidate后判斷雙方是否已連接,如果已連接,構(gòu)造并發(fā)送candidate給對(duì)端。
- 解析消息處理器
- 解析join:type為join取出userId列表,如果為一個(gè)代表僅自己在線,標(biāo)識(shí)為創(chuàng)建offer端,日志打印相關(guān)信息,如果有兩個(gè)者取出對(duì)方ID保存,代表雙方都上線成功,日志打印,調(diào)用協(xié)商函數(shù),開始媒體協(xié)商和網(wǎng)絡(luò)協(xié)商。
- 解析offer:type為offer,說(shuō)明收到發(fā)起端offer,將offer設(shè)置為遠(yuǎn)端信息,然后創(chuàng)建answer設(shè)置到本地,構(gòu)建answer消息發(fā)送給對(duì)端。
- 解析answer:type為answer,說(shuō)明收到接收端應(yīng)答,取出answer設(shè)置為遠(yuǎn)端消息。
- 解析candidate:type為candidate,說(shuō)明收到對(duì)端的網(wǎng)絡(luò)信息,取出設(shè)置到本地。
// 消息處理器 - 解析器
function handleSignalingMessage(message) {
wlog(“收到ws消息,開始解析…”)
wlog(message)
let parseMsg = JSON.parse(message);
wlog(解析結(jié)果:${parseMsg});
if (parseMsg.type == “join”) {
joinHandle(parseMsg.data);
} else if (parseMsg.type == “offer”) {
wlog(“收到發(fā)起端offer,開始解析…”);
offerHandle(parseMsg.data);
} else if (parseMsg.type == “answer”) {
wlog(“收到接收端的answer,開始解析…”);
answerHandle(parseMsg.data);
}else if(parseMsg.type == “candidate”){
wlog(“收到遠(yuǎn)端candidate,開始解析…”);
candidateHandle(parseMsg.data);
}
}
// 遠(yuǎn)端Candidate處理器
async function candidateHandle(candidate){
peerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate)));
wlog(“+++++++ 本端candidate設(shè)置完畢 ++++++++”);
}
// 接收端的answer處理
async function answerHandle(answer) {
wlog(“將answer設(shè)置為遠(yuǎn)端信息”);
peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(answer))); // 設(shè)置遠(yuǎn)端SDP
}
// 發(fā)起端offer處理器
async function offerHandle(offer) {
wlog(“將發(fā)起端的offer設(shè)置為遠(yuǎn)端媒體信息”);
await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(offer)));
wlog(“創(chuàng)建Answer 并設(shè)置到本地”);
let answer = await peerConnection.createAnswer()
await peerConnection.setLocalDescription(answer);
wlog(“發(fā)送answer給發(fā)起端”);
// 構(gòu)造answer消息發(fā)送給對(duì)端
let paramObj = {
userId: oppositeUserId,
type: “answer”,
data: JSON.stringify(answer)
}
// 執(zhí)行發(fā)送
const res = await axios.post(${BaseUrl}/rtcs/sendMessage, paramObj);
}
// 加入處理器
function joinHandle(userIds) {
// 判斷連接的用戶個(gè)數(shù)
if (userIds.length == 1 && userIds[0] == userId) {
wlog(“標(biāo)識(shí)為發(fā)起端,等待對(duì)方加入房間…”)
isRoomEmpty.value = true;
// 存在一個(gè)連接并且是自身,標(biāo)識(shí)我們是發(fā)起端
offerFlag = true;
} else if (userIds.length > 1) {
// 對(duì)方加入了
wlog(“對(duì)方已連接…”)
isRoomEmpty.value = false;// 取出對(duì)方ID
for (let id of userIds) {
if (id != userId) {
oppositeUserId = id;
}
}
wlog(`對(duì)端ID: ${oppositeUserId}`)
// 開始交換SDP和Candidate
swapVideoInfo()
}
}效果演示
初始狀態(tài)

發(fā)起端加入房間

接收端加入房間

Gitee源碼地址
源碼地址:點(diǎn)擊訪問(wèn)Gitee項(xiàng)目源代碼。
到此這篇關(guān)于WebRTC實(shí)現(xiàn)雙端音視頻聊天(Vue3 + SpringBoot )的文章就介紹到這了,更多相關(guān)WebRTC雙端音視頻聊天內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
springboot中使用@Transactional注解事物不生效的坑
這篇文章主要介紹了springboot中使用@Transactional注解事物不生效的原因,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01
java實(shí)現(xiàn)文件上傳下載至ftp服務(wù)器
這篇文章主要為大家詳細(xì)介紹了java實(shí)現(xiàn)文件上傳下載至ftp服務(wù)器的方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-06-06
劍指Offer之Java算法習(xí)題精講數(shù)組與列表的查找及字符串轉(zhuǎn)換
跟著思路走,之后從簡(jiǎn)單題入手,反復(fù)去看,做過(guò)之后可能會(huì)忘記,之后再做一次,記不住就反復(fù)做,反復(fù)尋求思路和規(guī)律,慢慢積累就會(huì)發(fā)現(xiàn)質(zhì)的變化2022-03-03
springBoot Junit測(cè)試用例出現(xiàn)@Autowired不生效的解決
這篇文章主要介紹了springBoot Junit測(cè)試用例出現(xiàn)@Autowired不生效的解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09
SpringBoot實(shí)現(xiàn)EMQ設(shè)備的上下線告警
EMQX?的上下線系統(tǒng)消息通知功能在客戶端連接成功或者客戶端斷開連接,需要實(shí)現(xiàn)設(shè)備的上下線狀態(tài)監(jiān)控,所以本文給大家介紹了如何通過(guò)SpringBoot實(shí)現(xiàn)EMQ設(shè)備的上下線告警,文中有詳細(xì)的代碼示例,需要的朋友可以參考下2023-10-10
Python中scrapy框架的ltem和scrapy.Request詳解
這篇文章主要介紹了Python中scrapy框架的ltem和scrapy.Request詳解,Item是保存爬取數(shù)據(jù)的容器,它的使用方法和字典類似,不過(guò),相比字典,Item提供了額外的保護(hù)機(jī)制,可以避免拼寫錯(cuò)誤或者定義字段錯(cuò)誤,需要的朋友可以參考下2023-09-09

