当前位置: 首页 > article >正文

vue+springboot+webtrc+websocket实现双人音视频通话会议

前言

最近一些时间我有研究,如何实现一个视频会议功能,但是找了好多资料都不太理想,最终参考了一个文章
WebRTC实现双端音视频聊天(Vue3 + SpringBoot)

只不过,它的实现效果里面只会播放本地的mp4视频文件,但是按照它的原理是可以正常的实现音视频通话的
它的最终效果是这样的

在这里插入图片描述

然后我的实现逻辑在它的基础上进行了优化
实现了如下效果,如下是我部署项目到服务器之后,和朋友验证之后的截图

在这里插入图片描述

针对它的逻辑,我优化了如下几点

  1. 第一个人可以输入房间号创建房间,需要注意的是,当前第二个人还没加入进来的时候,视频两边都不展示
  2. 第二个人根据第一个人的房间号输入进行加入房间,等待视频流的加载就可以互相看到两边的视频和听到音频
  3. 添加了关闭/开启麦克风和摄像头功能
    ps: 需要注意的是,我接下来分享的代码逻辑,如果某个人突然加入别的房间,原房间它视频分享还是在的,我没有额外进行处理关闭原房间的音视频流,大家可根据这个进行调整

题外话,根据如上的原理,你可以进一步优化,将其开发一个视频会议功能,当前我有开发一个类似的,但是本次只分享双人音视频通话会议项目

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

VUE逻辑

如下为前端部分逻辑,需要注意的是,本次项目还是沿用参考文章的VUE3项目

前端项目结构如下:

在这里插入图片描述

package.json

{"name": "webrtc_test","private": true,"version": "0.0.0","type": "module","scripts": {"dev": "vite","build": "vite build","preview": "vite preview"},"dependencies": {"axios": "^1.7.7","vue": "^3.5.12"},"devDependencies": {"@vitejs/plugin-vue": "^5.1.4","vite": "^5.4.10"}
}

换言之,你需要使用npm安装如上依赖

npm i axios@1.7.7

vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import fs from 'fs';
// https://vite.dev/config/
export default defineConfig({plugins: [vue()],server: {// 如果需要部署服务器,需要申请SSL证书,然后下载证书到指定文件夹https: {key: fs.readFileSync('src/certs/www.springsso.top.key'),cert: fs.readFileSync('src/certs/www.springsso.top.pem'),}},
})

main.js

import { createApp } from 'vue'
import App from './App.vue'createApp(App).mount('#app')

App.vue

<template><div class="video-chat"><div v-if="isRoomEmpty"><p>{{ roomStatusText }}</p></div><!-- 视频双端显示 --><div class="video_box"><div class="self_video"><div class="text_tip">我:<span class="userId">{{ userId }}</span></div><video ref="localVideo" autoplay playsinline></video></div><div class="remote_video"><div class="text_tip">对方:<span class="userId">{{ oppositeUserId }}</span></div><video ref="remoteVideo" autoplay playsinline></video></div></div><!-- 加入房间按钮 --><div class="room-controls"><div class="room-input"><input v-model="roomId" placeholder="请输入房间号" /><button @click="createRoom">创建房间</button><button @click="joinRoomWithId">加入房间</button></div><div class="media-controls"><button @click="toggleAudio">{{ isAudioEnabled ? '关闭麦克风' : '打开麦克风' }}</button><button @click="toggleVideo">{{ isVideoEnabled ? '关闭摄像头' : '打开摄像头' }}</button></div></div><!-- 日志打印 --><div class="log_box"><pre><div v-for="(item, index) of logData" :key="index">{{ item }}</div></pre></div></div>
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
import axios from "axios";// WebRTC 相关变量
const localVideo = ref(null);
const remoteVideo = ref(null);
const isRoomEmpty = ref(true); // 判断房间是否为空let localStream; // 本地流数据
let peerConnection; // RTC连接对象
let signalingSocket; // 信令服务器socket对象
let userId; // 当前用户ID
let oppositeUserId; // 对方用户IDlet logData = ref(["日志初始化..."]);// 请求根路径,如果需要部署服务器,把对应ip改成自己服务器ip
let BaseUrl = "https://localhost:8095/meetingV1s"let wsUrl = "wss://localhost:8095/meetingV1s";// candidate信息
let candidateInfo = "";// 发起端标识
let offerFlag = false;// 房间状态文本
let roomStatusText = ref("点击'加入房间'开始音视频聊天");// STUN 服务器,
// const iceServers = [
//   {
//     urls: "stun:stun.l.google.com:19302"  // Google 的 STUN 服务器
//   },
//   {
//     urls: "stun:自己的公网IP:3478" // 自己的Stun服务器
//   },
//   {
//     urls: "turn:自己的公网IP:3478",   // 自己的 TURN 服务器
//     username: "maohe",
//     credential: "maohe"
//   }
// ];
// ============< 看这 >================
// 没有搭建STUN和TURN服务器的使用如下ice配置即可
const iceServers = [{urls: "stun:stun.l.google.com:19302"  // Google 的 STUN 服务器}
];// 在 script setup 中添加新的变量声明
const roomId = ref(''); // 房间号
const isAudioEnabled = ref(true); // 音频状态
const isVideoEnabled = ref(true); // 视频状态onMounted(() => {generateRandomId();
})// 加入房间,开启本地摄像头获取音视频流数据。
function joinRoomHandle() {roomStatusText.value = "等待对方加入房间..."getVideoStream();
}// 获取本地视频 模拟从本地摄像头获取音视频流数据
async function getVideoStream() {try {localStream = await navigator.mediaDevices.getUserMedia({video: true,audio: true});localVideo.value.srcObject = localStream;wlog(`获取本地流成功~`)createPeerConnection(); // 创建RTC对象,监听candidate} catch (err) {console.error('获取本地媒体流失败:', err);}
}// 初始化 WebSocket 连接
function initWebSocket() {wlog("开始连接websocket")// 连接ws时携带用户ID和房间号signalingSocket = new WebSocket(`${wsUrl}/rtc?userId=${userId}&roomId=${roomId.value}`);signalingSocket.onopen = () => {wlog('WebSocket 已连接');};// 消息处理signalingSocket.onmessage = (event) => {handleSignalingMessage(event.data);};
};// 消息处理器 - 解析器
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 && userIds[0] == userId) {wlog("标识为发起端,等待对方加入房间...")isRoomEmpty.value = true;// 存在一个连接并且是自身,标识我们是发起端offerFlag = true;} else if (userIds.length > 1) {// 对方加入了wlog("对方已连接...")isRoomEmpty.value = false;// 取出对方IDfor (let id of userIds) {if (id != userId) {oppositeUserId = id;}}wlog(`对端ID: ${oppositeUserId}`)// 开始交换SDP和CandidateswapVideoInfo()}
}// 交换SDP和candidate
async function swapVideoInfo() {wlog("开始交换Sdp和Candidate...");// 检查是否为发起端,如果是创建offer设置到本地,并发送给远端if (offerFlag) {wlog(`发起端创建offer`)let offer = await peerConnection.createOffer()await peerConnection.setLocalDescription(offer); // 将媒体信息设置到本地wlog("发启端设置SDP-offer到本地");// 构造消息ws发送给远端let paramObj = {userId: oppositeUserId,type: "offer",data: JSON.stringify(offer)};wlog(`构造offer信息发送给远端:${paramObj}`)// 执行发送const res = await axios.post(`${BaseUrl}/rtcs/sendMessage`, paramObj);}
}// 将candidate信息发送给远端
async function sendCandidate(candidate) {// 构造消息ws发送给远端let paramObj = {userId: oppositeUserId,type: "candidate",data: JSON.stringify(candidate)};wlog(`构造candidate信息发送给远端:${paramObj}`);// 执行发送const res = await axios.post(`${BaseUrl}/rtcs/sendMessage`, paramObj);}// 创建RTC连接对象并监听和获取condidate信息
function createPeerConnection() {wlog("开始创建PC对象...")peerConnection = new RTCPeerConnection(iceServers);wlog("创建PC对象成功")// 创建RTC连接对象后连接websocketinitWebSocket();// 监听网络信息(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.streams[0];remoteVideo.value.play();  // 强制播放}});};// 监听ice连接状态peerConnection.oniceconnectionstatechange = () => {wlog(`RTC连接状态改变:${peerConnection.iceConnectionState}`);};// 添加本地音视频流到 PeerConnectionlocalStream.getTracks().forEach(track => {peerConnection.addTrack(track, localStream);});
}// 日志编写
function wlog(text) {logData.value.unshift(text);
}// 给用户生成随机ID.
function generateRandomId() {userId = Math.random().toString(36).substring(2, 12); // 生成10位的随机IDwlog(`分配到ID:${userId}`)
}// 创建房间
async function createRoom() {if (!roomId.value) {alert('请输入房间号');return;}try {const res = await axios.post(`${BaseUrl}/rtcs/createRoom`, {roomId: roomId.value,userId: userId});if (res.data.success) {wlog(`创建房间成功:${roomId.value}`);joinRoomHandle();}} catch (error) {wlog(`创建房间失败:${error}`);}
}// 加入指定房间
async function joinRoomWithId() {if (!roomId.value) {alert('请输入房间号');return;}try {const res = await axios.post(`${BaseUrl}/rtcs/joinRoom`, {roomId: roomId.value,userId: userId});if (res.data.success) {wlog(`加入房间成功:${roomId.value}`);joinRoomHandle();}} catch (error) {wlog(`加入房间失败:${error}`);}
}// 切换音频
function toggleAudio() {if (localStream) {const audioTrack = localStream.getAudioTracks()[0];if (audioTrack) {audioTrack.enabled = !audioTrack.enabled;isAudioEnabled.value = audioTrack.enabled;wlog(`麦克风已${audioTrack.enabled ? '打开' : '关闭'}`);}}
}// 切换视频
function toggleVideo() {if (localStream) {const videoTrack = localStream.getVideoTracks()[0];if (videoTrack) {videoTrack.enabled = !videoTrack.enabled;isVideoEnabled.value = videoTrack.enabled;wlog(`摄像头已${videoTrack.enabled ? '打开' : '关闭'}`);}}
}
</script>
<style scoped>
.video-chat {display: flex;flex-direction: column;align-items: center;
}video {width: 300px;height: 200px;margin: 10px;
}.remote_video {border: solid rgb(30, 40, 226) 1px;margin-left: 20px;
}.self_video {border: solid red 1px;
}.video_box {display: flex;
}.video_box div {border-radius: 10px;
}.join_room_btn button {border: none;background-color: rgb(119 178 63);height: 30px;width: 80px;border-radius: 10px;color: white;margin-top: 10px;cursor: pointer;font-size: 13px;
}.text_tip {font-size: 13px;color: #484848;padding: 6px;
}pre {width: 600px;height: 300px;background-color: #d4d4d4;border-radius: 10px;padding: 10px;overflow-y: auto;
}pre div {padding: 4px 0px;font-size: 15px;
}.userId{color: #3669ad;
}.video-chat p{font-weight: 600;color: #b24242;
}.room-controls {margin: 20px 0;display: flex;flex-direction: column;gap: 10px;
}.room-input {display: flex;gap: 10px;align-items: center;
}.room-input input {padding: 5px 10px;border: 1px solid #ccc;border-radius: 5px;
}.media-controls {display: flex;gap: 10px;
}.room-controls button {border: none;background-color: rgb(119 178 63);height: 30px;padding: 0 15px;border-radius: 5px;color: white;cursor: pointer;font-size: 13px;
}.media-controls button {background-color: #3669ad;
}
</style>

SpringBoot逻辑

如下为后端逻辑,项目结构如下:
在这里插入图片描述

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.9</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.mh</groupId><artifactId>webrtc-backend</artifactId><version>0.0.1-SNAPSHOT</version><name>webrtc-backend</name><description>webrtc-backend</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.34</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><version>2.6.2</version><configuration><mainClass>com.mh.WebrtcBackendApplication</mainClass><layout>ZIP</layout></configuration><executions><execution><goals><goal>repackage</goal></goals></execution></executions></plugin></plugins></build></project>

application.yml

server:port: 8095servlet:context-path: /meetingV1sssl: #ssl配置enabled: true  # 默认为true#key-alias: alias-key # 别名(可以不进行配置)# 保存SSL证书的秘钥库的路径,如果部署到服务器,必须要开启ssl才能获取到摄像头和麦克风key-store: classpath:www.springsso.top.jks# ssl证书密码key-password: gf71v8lfkey-store-password: gf71v8lfkey-store-type: JKStomcat:uri-encoding: UTF-8

入口文件

// 这个是自己实际项目位置
package com.mh;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class WebrtcBackendApplication {public static void main(String[] args) {SpringApplication.run(WebrtcBackendApplication.class, args);}}

WebSocket处理器

package com.mh.common;import com.mh.dto.bo.UserManager;
import com.mh.dto.vo.MessageOut;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import com.fasterxml.jackson.databind.ObjectMapper;import java.net.URI;
import java.util.ArrayList;
import java.util.Set;/*** Date:2024/11/14* author:zmh* description: WebSocket处理器**/@Component
@RequiredArgsConstructor
@Slf4j
public class RtcWebSocketHandler  extends TextWebSocketHandler {// 管理用户的加入和退出...private final UserManager userManager;private final ObjectMapper objectMapper = new ObjectMapper();// 用户连接成功@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {// 获取用户ID和房间IDString userId = getParameterByName(session.getUri(), "userId");String roomId = getParameterByName(session.getUri(), "roomId");if (userId != null && roomId != null) {// 保存用户会话userManager.addUser(userId, session);log.info("用户 {} 连接成功,房间:{}", userId, roomId);// 获取房间中的所有用户Set<String> roomUsers = userManager.getRoomUsers(roomId);// 通知房间内所有用户(包括新加入的用户)for (String uid : roomUsers) {WebSocketSession userSession = userManager.getUser(uid);if (userSession != null && userSession.isOpen()) {MessageOut messageOut = new MessageOut();messageOut.setType("join");messageOut.setData(new ArrayList<>(roomUsers));String message = objectMapper.writeValueAsString(messageOut);userSession.sendMessage(new TextMessage(message));log.info("向用户 {} 发送房间更新消息", uid);}}}}// 接收到客户端消息,解析消息内容进行分发@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 转换并分发消息log.info("收到消息");}// 处理断开的连接@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {String userId = getParameterByName(session.getUri(), "userId");String roomId = getParameterByName(session.getUri(), "roomId");if (userId != null && roomId != null) {// 从房间和会话管理中移除用户userManager.removeUser(userId);userManager.leaveRoom(roomId, userId);// 获取更新后的房间用户列表Set<String> remainingUsers = userManager.getRoomUsers(roomId);// 通知房间内的其他用户for (String uid : remainingUsers) {WebSocketSession userSession = userManager.getUser(uid);if (userSession != null && userSession.isOpen()) {MessageOut messageOut = new MessageOut();messageOut.setType("join");messageOut.setData(new ArrayList<>(remainingUsers));String message = objectMapper.writeValueAsString(messageOut);userSession.sendMessage(new TextMessage(message));log.info("向用户 {} 发送用户离开更新消息", uid);}}log.info("用户 {} 断开连接,房间:{}", userId, roomId);}}// 辅助方法:从URI中获取参数值private String getParameterByName(URI uri, String paramName) {String query = uri.getQuery();if (query != null) {String[] pairs = query.split("&");for (String pair : pairs) {String[] keyValue = pair.split("=");if (keyValue.length == 2 && keyValue[0].equals(paramName)) {return keyValue[1];}}}return null;}
}

WebSocket配置类

package com.mh.config;import com.mh.common.RtcWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;/*** Date:2024/11/14* author:zmh* description: WebSocket配置类**/@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {private final RtcWebSocketHandler rtcWebSocketHandler;@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(rtcWebSocketHandler, "/rtc").setAllowedOrigins("*");}
}

webRtc相关接口

package com.mh.controller;import com.mh.dto.bo.UserManager;
import com.mh.dto.vo.MessageReceive;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import java.util.HashMap;
import java.util.Map;/*** Date:2024/11/15* author:zmh* description: rtc 相关接口**/@RestController
@Slf4j
@CrossOrigin
@RequiredArgsConstructor
@RequestMapping("/rtcs")
public class RtcController {private final UserManager userManager;/*** 给指定用户发送执行类型消息* @param messageReceive 消息参数接收Vo* @return ·*/@PostMapping("/sendMessage")public Boolean sendMessage(@RequestBody MessageReceive messageReceive){userManager.sendMessage(messageReceive);return true;}@PostMapping("/createRoom")public ResponseEntity<?> createRoom(@RequestBody Map<String, String> params) {String roomId = params.get("roomId");String userId = params.get("userId");// 在 UserManager 中实现房间创建逻辑boolean success = userManager.createRoom(roomId, userId);Map<String, Object> response = new HashMap<>();response.put("success", success);return ResponseEntity.ok(response);}@PostMapping("/joinRoom")public ResponseEntity<?> joinRoom(@RequestBody Map<String, String> params) {String roomId = params.get("roomId");String userId = params.get("userId");// 在 UserManager 中实现加入房间逻辑boolean success = userManager.joinRoom(roomId, userId);Map<String, Object> response = new HashMap<>();response.put("success", success);return ResponseEntity.ok(response);}
}

用户管理器单例对象

package com.mh.dto.bo;import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mh.dto.vo.MessageOut;
import com.mh.dto.vo.MessageReceive;
import java.util.stream.Collectors;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;/*** Date:2024/11/14* author:zmh* description: 用户管理器单例对象**/@Data
@Component
@Slf4j
public class UserManager {// 管理连接用户信息private final HashMap<String, WebSocketSession> userMap = new HashMap<>();// 添加房间管理的Mapprivate final Map<String, Set<String>> roomUsers = new ConcurrentHashMap<>();// 加入用户public void addUser(String userId, WebSocketSession session) {userMap.put(userId, session);log.info("用户 {} 加入", userId);}// 移除用户public void removeUser(String userId) {userMap.remove(userId);log.info("用户 {} 退出", userId);}// 获取用户public WebSocketSession getUser(String userId) {return userMap.get(userId);}// 获取所有用户ID构造成list返回public List<String> getAllUserId() {return userMap.keySet().stream().collect(Collectors.toList());}// 通知用户加入-广播消息public void sendMessageAllUser() throws IOException {// 获取所有连接用户ID列表List<String> allUserId = getAllUserId();for (String userId : userMap.keySet()) {WebSocketSession session = userMap.get(userId);MessageOut messageOut = new MessageOut("join", allUserId);String messageText = new ObjectMapper().writeValueAsString(messageOut);// 广播消息session.sendMessage(new TextMessage(messageText));}}/*** 创建房间* @param roomId 房间ID* @param userId 用户ID* @return 创建结果*/public boolean createRoom(String roomId, String userId) {if (roomUsers.containsKey(roomId)) {log.warn("房间 {} 已存在", roomId);return false;}Set<String> users = new HashSet<>();users.add(userId);roomUsers.put(roomId, users);log.info("用户 {} 创建了房间 {}", userId, roomId);return true;}/*** 加入房间* @param roomId 房间ID* @param userId 用户ID* @return 加入结果*/public boolean joinRoom(String roomId, String userId) {Set<String> users = roomUsers.computeIfAbsent(roomId, k -> new HashSet<>());if (users.size() >= 2) {log.warn("房间 {} 已满", roomId);return false;}users.add(userId);log.info("用户 {} 加入房间 {}", userId, roomId);return true;}/*** 离开房间* @param roomId 房间ID* @param userId 用户ID*/public void leaveRoom(String roomId, String userId) {Set<String> users = roomUsers.get(roomId);if (users != null) {users.remove(userId);if (users.isEmpty()) {roomUsers.remove(roomId);log.info("房间 {} 已清空并删除", roomId);}log.info("用户 {} 离开了房间 {}", userId, roomId);}}/*** 获取房间用户* @param roomId 房间ID* @return 用户集合*/public Set<String> getRoomUsers(String roomId) {return roomUsers.getOrDefault(roomId, new HashSet<>());}// 修改现有的 sendMessage 方法,考虑房间信息public void sendMessage(MessageReceive messageReceive) {String userId = messageReceive.getUserId();String type = messageReceive.getType();String data = messageReceive.getData();WebSocketSession session = userMap.get(userId);if (session != null && session.isOpen()) {try {MessageOut messageOut = new MessageOut();messageOut.setType(type);messageOut.setData(data);String message = new ObjectMapper().writeValueAsString(messageOut);session.sendMessage(new TextMessage(message));log.info("消息发送成功: type={}, to={}", type, userId);} catch (Exception e) {log.error("消息发送失败", e);}}}
}

消息输出前端Vo对象

package com.mh.dto.vo;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;/*** Date:2024/11/15* author:zmh* description: 消息输出前端Vo对象**/@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageOut {/*** 消息类型【join, offer, answer, candidate, leave】*/private String type;/*** 消息内容 前端stringFiy序列化后字符串*/private Object data;
}

消息接收Vo对象

package com.mh.dto.vo;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;/*** Date:2024/11/15* author:zmh* description: 消息接收Vo对象**/@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageReceive {/*** 用户ID,用于获取用户Session*/private String userId;/*** 消息类型【join, offer, answer, candidate, leave】*/private String type;/*** 消息内容 前端stringFiy序列化后字符串*/private String data;
}

结语

如上为vue+springboot+webtrc+websocket实现双人音视频通话会议的全部逻辑,如有遗漏后续会进行补充

相关文章:

vue+springboot+webtrc+websocket实现双人音视频通话会议

前言 最近一些时间我有研究&#xff0c;如何实现一个视频会议功能&#xff0c;但是找了好多资料都不太理想&#xff0c;最终参考了一个文章 WebRTC实现双端音视频聊天&#xff08;Vue3 SpringBoot&#xff09; 只不过&#xff0c;它的实现效果里面只会播放本地的mp4视频文件&…...

2025年单片机毕业设计选题物联网计算机电气电子通信类

当然&#xff0c;以下是基于物联网技术设计的20个单片机类题目&#xff0c;旨在考察学生在物联网环境下单片机应用、系统设计、数据传输与处理等方面的能力&#xff1a; 基于物联网的智能家居温度湿度控制系统设计&#xff1a;利用单片机和传感器实现室内环境的温湿度监测&…...

堡垒机调用xshell 无反应

安装sso_client 确认db_path.ini xhsell路径 如图调整为本机安装的路径即可。 实战问题&#xff1a; 操作完成之后 Chrome还是无法调用&#xff0c;使用360浏览器没问题。...

python后端调用Deep Seek API

python后端调用Deep Seek API 需要依次下载 ●Ollama ●Deepseek R1 LLM模型 ●嵌入模型nomic-embed-text / bge-m3 ●AnythingLLM 参考教程&#xff1a; Deepseek R1打造本地化RAG知识库:安装部署使用详细教程 手把手教你&#xff1a;deepseek R1基于 AnythingLLM API 调用本地…...

Easy系列PLC 线性变换功能块(模拟量相关功能块汇总)

线性转换函数S_RTR 线性转换函数S_RTR(SCL和ST代码)_线性函数的scl语言如何编写-CSDN博客文章浏览阅读440次。博客介绍了线性转换函数S_RTR,包括其在PLC中的应用,如何与工艺PID组合使用,以及在张力开环控制中的具体实践。还提到了函数的C99兼容性,并提供了S_RTR的功能块源…...

【VB语言】EXCEL中VB宏的应用

【VB语言】EXCEL中VB宏的应用 文章目录 [TOC](文章目录) 前言一、EXCEL-VB1.实验过程2.代码 二、EXCEL-VB 生成.c.h文件1.实验过程2.代码 四、参考资料总结 前言 1.WPS-VB扩展包 提示&#xff1a;以下是本篇文章正文内容&#xff0c;下面案例可供参考 一、EXCEL-VB 1.实验过…...

【人工智能】如何选择合适的大语言模型,是能否提高工作效率的关键!!!

DeepSeek R1入门指南 导读一、提示语差异1.1 指令侧重点不同1.2 语言风格差异1.3 知识运用引导不同 二、挑选原则2.1 模型选择2.2 提示语设计2.3 避免误区 结语 导读 大家好&#xff0c;很高兴又和大家见面啦&#xff01;&#xff01;&#xff01; 在前面的内容中&#xff0c…...

Unity使用反射进行Protobuf(CS/SC)协议,json格式

protobuf生成的协议,有挺多协议的.利用反射生成dto进行伪协议的响应 和 发送请求 应用场景: 请求(CS)_后端先写完了,前端还搞完时,可使用此请求,可自测 响应(SC)_可自行构建一个响应,对数据进行测试 // 请求 使用物品 CS message ReqUseItem{optional Opcodes MessageID1[def…...

初学 mybatis

前言 回顾之前 不使用 mybatis 框架&#xff0c;我们是怎么通过Java 操作数据库的 "jdbc" 前提&#xff1a;使用maven 构建的项目 1 添加 关于jdbc 的依赖&#xff0c;以及辅助操作数据库的 commons-dubli jar包 截取 前后端项目 2 添加配置文件里面内容有&…...

java.lang.IllegalArgumentException: 在请求目标中找到无效字符。有效字符在RFC 7230和RFC 3986中定义

Tomcat 屏蔽错误信息。java.lang.IllegalArgumentException: 在请求目标中找到无效字符。有效字符在RFC 7230和RFC 3986中定义 <h1>HTTP状态 400 - 错误的请求</h1><hr class"line" /><p><b>类型</b> 异常报告</p><p&…...

C语言进阶习题(4结构体)【1】通讯录的实现

目录 1.使用结构体实现通讯录功能2.思路3. 代码实现3.1 test.c3.2 contact.c3.3 contact.h 1.使用结构体实现通讯录功能 主要功能有&#xff1a;显示通讯录信息&#xff0c;增加通讯录中人的信息&#xff0c;删除通讯录中人的信息&#xff0c;查找通信录中信息&#xff0c;修改…...

释放你的元数据:使用 Elasticsearch 的自查询检索器

作者&#xff1a;来自 Elastic Josh Asres 了解如何使用 Elasticsearch 的 “self-quering” 检索器来通过结构化过滤器提高语义搜索的相关性。 在人工智能搜索的世界中&#xff0c;在海量的数据集中高效地找到正确的数据至关重要。传统的基于关键词的搜索在处理涉及自然语言的…...

【Python】如何在 Linux/Windows 系统中设置 PYTHONPATH 环境变量

什么是 PYTHONPATH&#xff1f; PYTHONPATH 是一个环境变量&#xff0c;它告诉 Python 解释器在哪些目录中查找要导入的模块。这对于包含不在标准目录中的自定义模块非常有用。 Linux 系统中设置 PYTHONPATH 环境变量 在 Python 开发环境中&#xff0c;正确设置 PYTHONPATH …...

1.14学习总结

日常刷题单 刷了题目后&#xff0c;对于排序方法更加熟练&#xff0c;手搓代码的速度也得到了提高。 感觉字符串还不熟练&#xff0c;高精度更是云里雾里&#xff0c;上升空间极大。 同时看见今晚有个入门难度的测试&#xff0c;去练了练手&#xff0c;想看看自己是什么成分&…...

【Prometheus】prometheus黑盒监控balckbox全面解析与应用实战

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全…...

游戏引擎学习第101天

回顾当前情况 昨天的进度基本上完成了所有内容&#xff0c;但我们还没有进行调试。虽然我们在运行时做的事情大致上是对的&#xff0c;但还是存在一些可能或者确定的bug。正如昨天最后提到的&#xff0c;既然现在时间晚了&#xff0c;就不太适合开始调试&#xff0c;所以今天我…...

海康摄像头IPV6模式,手动,自动,路由公告

海康摄像头DS-2DC7220IW-A 网络设置中的IPv6配置选项。IPv6是互联网协议&#xff08;IP&#xff09;的第六版&#xff0c;用于替代IPv4&#xff0c;提供更多的IP地址和改进的网络功能。图片中的选项允许用户选择如何配置设备的IPv6网络连接&#xff1a; 手动&#xff1a;用户可…...

架构设计系列(二):CI/CD

一、概述 CI/CD 是 持续集成&#xff08;Continuous Integration&#xff09; 和 持续交付/持续部署&#xff08;Continuous Delivery/Continuous Deployment&#xff09; 的缩写&#xff0c;是现代软件开发中的一套核心实践和工具链&#xff0c;旨在提高软件交付的效率、质量…...

执行js生成json文件并动态写入数据

项目中需要执行js后生成一个新的json文件&#xff0c;并在该文件内写入json数据&#xff0c; 示例&#xff1a;生成一个json文件&#xff0c;内含执行这个js的时间戳作为json文件中的数据。 新建一个js文件create.js&#xff0c;js代码如下&#xff1a; const fs require(fs)…...

DDoS技术解析

这里是Themberfue 今天我们不聊别的&#xff0c;我们聊聊著名的网络攻击手段之一的 DDoS&#xff0c;看看其背后的技术细节。 DoS 了解 DDoS 前&#xff0c;先来讲讲 DoS 是什么&#xff0c;此 DoS 而不是 DOS 操作系统啊。1996年9月6日&#xff0c;世界第三古老的网络服务提供…...

28 在可以控制 postgres 服务器, 不知道任何用户名的情况下怎 进入 postgres 服务器

前言 最近有这样的一个需求, 有一个 postgres 服务器 但是 不知道 他的任何的用户名密码, 但是我想要查询这台 postgres 服务器 然后 基于这个需求, 我们看一下 怎么来处理 pg_hba.conf 认证方式修改为 trust 首先将 postgres 服务器的认证方式修改为 trust 这时候 …...

《图解设计模式》笔记(十)用类来表现

二十二、Command模式:命令也是类 一个类调用某方法,虽然调用结果会反映在对象的状态中,但不会留下工作的历史记录。 若有一个类表示“请进行这项工作”的“命令”,每一项想做的工作就不再是“方法的调用”这种动态处理了,而是一个表示命令的类的实例,即可以用“物”来表…...

Kafka日志数据深度解析:从基础查看到高级操作全攻略

#作者&#xff1a;孙德新 文章目录 查看log日志文件(kafka-dump-log.sh)1、查看Log文件基本数据信息2、index文件健康性检查(--index-sanity-check)3、转储文件(--max-message-size)4、偏移量解码(--offsets-decoder)5、日志数据解析(--transaction-log-decoder)6、查询Log文件…...

docker容器部署jar应用导入文件时候报缺少字体错误解决

如题&#xff0c;在导入文件时候报错如下&#xff1a; Handler dispatch failed; nested exception is java.lang.NoClassDefFoundError: Could not initialize class sun.awt.X11FontManager 经查是缺少对应字体&#xff0c;解决办法有两张&#xff1a; 第一种&#xff1a;…...

npm安装时无法访问github域名的解决方法

个人博客地址&#xff1a;npm安装时无法访问github域名的解决方法 | 一张假钞的真实世界 今天在用npm install的时候出现了github项目访问不了的异常&#xff1a; npm ERR! Error while executing: npm ERR! /bin/git ls-remote -h -t https://github.com/nhn/raphael.git np…...

APP端弱网模拟与网络测试:如何确保应用在各种网络环境下稳定运行

随着智能手机的普及&#xff0c;APP的网络性能成为用户体验的关键因素之一。尤其是在弱网环境下&#xff0c;应用的表现可能严重影响用户的满意度。因此&#xff0c;APP端的网络测试&#xff0c;尤其是弱网模拟&#xff0c;成为了提升产品质量和用户体验的重要环节。 当前APP网…...

从 ClickHouse 到 Apache Doris:在网易云音乐日增万亿日志数据场景下的落地

导读&#xff1a;日志数据已成为企业洞察系统状态、监控网络安全及分析业务动态的宝贵资源。网易云音乐引入 Apache Doris 作为日志库新方案&#xff0c;替换了 ClickHouse。解决了 ClickHouse 运维复杂、不支持倒排索引的问题。目前已经稳定运行 3 个季度&#xff0c;规模达到…...

wordpress模板文件结构超详解

wordpress网站建设中&#xff0c;主题的制作是最为核心的环节。了解模板文件结构是模板制作的第一步&#xff0c;本文所讲的模板文件结构包括两部分&#xff0c;一是指以文件名为概念的文件结构&#xff0c;二是指文件内容的代码结构。 一、如何使模板文件起作用 ↑ wordpres…...

BFS 走迷宫

#include<bits/stdc.h> using namespace std; int a[100][100],v[100][100];//访问数组 n,m<100 struct point {int x;int y;int step; }; queue<point> r;//申请队列 int dx[4]{0,1,0,-1};//四个方向 右下左上 int dy[4]{1,0,-1,0}; int main() { /* 5 4 1 …...

尚硅谷爬虫note005

一、编解码 1.get请求的quote方法 将汉字转为Unicode字符 # _*_ coding : utf-8 _*_ # Time : 2025/2/12 16:33 # Author : 20250206-里奥 # File : demo19_get请求的quote方法 # Project : PythonProject10-14# 景甜page # https://www.baidu.com/s?ieutf-8&f8&rsv…...