
简介这是一套基于Spring Boot框架与WebRTC实时通信技术的多人视频在线会议前端源码界面层使用Vue与Element UI构建适合正在准备毕业设计的计算机专业学生、需要项目实战的Java学习者也可以直接作为课程设计或期末大作业。资源包为ZIP格式共包含253个文件以Vue单文件组件、JavaScript业务逻辑、SVG图标资源和SCSS样式为主辅以项目说明、使用说明、环境配置等必要文档整体压缩后仅2.29MB目录结构清晰便于按功能模块阅读、修改和二次部署。项目已实现视频通话、语音通话、共享桌面、大屏预览、聊天室以及管理员统一控制成员视频与麦克风等核心能力会议人数没有硬性上限实际流畅度主要受服务器带宽和客户端性能影响。压缩包内同时提供完整前端工程与配置说明可以帮助开发者理解WebRTC的音视频采集与传输、前端信令交互以及Vue工程化项目的模块组织方式。该资源已有2644人学习下载适合需要快速搭建视频会议前端项目并掌握相关技术链路的开发者参考借鉴。1. 多人视频会议里的 WebRTC 前端Spring Boot 真正负责的是信令浏览器原生支持音视频通话这是 WebRTC 最反直觉的一点——媒体数据根本不经过你自己的服务器P2P 直连才是它的默认形态。那这个标题里的 Spring Boot 和服务器前端是干什么的答案落在「相遇」这个环节两个浏览器要建立连接必须先交换各自的网络地址、媒体能力、加密参数这个交换过程叫信令Signaling。Spring Boot 在这里的角色就是信令服务器加静态资源服务器前端负责用 WebRTC API 完成采集、协商和传输。这个项目解决的是多路视频怎么在纯浏览器里稳定互看的问题适合要快速搭出内部会议系统、在线答辩或远程协作工具的团队。理解「信令是 Spring Boot 的媒体是浏览器之间的」这一句读完这篇剩下的内容就只是补细节。2. WebRTC 多人会议的技术构成与信令设计2.1 P2P 延迟低但多人要面对的是连接数爆炸WebRTC 多人会话有三种架构。Mesh 模式下每个端点与其他人各建一条 PeerConnectionN 路视频就是 N-1 条上行连接带宽和 CPU 都随人数线性增长。SFUSelective Forwarding Unit由服务器做媒体转发每个客户端只上传一路服务器按需分发是目前商业产品的主流。MCUMultipoint Control Unit在服务器端混流客户端压力最小但服务器开销巨大。对于基于 Spring Boot 的自研项目而言小规模2-6 人场景选 Mesh 是合理的。原因是部署简单——不需要额外搭建流媒体服务器不需要处理服务端的音视频编解码Spring Boot 只管信令转发和静态资源托管。常见做法是在服务端维护一个房间映射表把消息路由到房间内其他成员媒体面完全交给浏览器之间的 ICE 连接。超过 6 人后 Mesh 的负担明显上升那是应该切换到 SFU比如 mediasoup、Janus的信号。我们以这个项目的源码实体为准信令服务用 Spring Boot 搭建 WebSocket 端点前端写一个 WebRTC 封装层两者通过 JSON 消息协作。这是最容易复现的起点。2.2 信令消息的完整生命周期从 join 到 ice-candidate一条信令消息从发起到生效的路径是这样的前端 A 通过 WebSocket 发送 join 消息到 Spring Boot 端点服务端把 A 加入房间房间列表向房间内已有成员广播 peer-joined 消息B 收到后创建 RTCPeerConnection通过 addStream 或 addTrack 把本地媒体加进去然后创建 offer通过信令通道回传 offer 消息A 收到 offer 后 setRemoteDescription创建 answer 回传紧接着双方各自触发 ICE 候选收集通过信令通道互发 ice-candidate。整个过程媒体面尚未打通信令只是完成了「谁在哪、对方能力如何、网络路径候选有哪些」的交换。2.3 Spring Boot 侧的信令端点实现创建项目时选 Spring Web、WebSocket 两个 starter。信令端点直接实现 TextWebSocketHandler这是 Spring 对 WebSocket 消息处理的抽象。Component public class SignalingHandler extends TextWebSocketHandler { private final ConcurrentHashMapString, WebSocketSession sessions new ConcurrentHashMap(); private final ConcurrentHashMapString, SetString rooms new ConcurrentHashMap(); Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode node new ObjectMapper().readTree(message.getPayload()); String type node.get(type).asText(); String roomId node.get(roomId).asText(); switch (type) { case join: rooms.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()).add(session.getId()); sessions.put(session.getId(), session); broadcast(session.getId(), roomId, Map.of(type, peer-joined, peerId, session.getId())); break; case offer: case answer: case ice-candidate: String targetId node.get(targetId).asText(); WebSocketSession target sessions.get(targetId); if (target ! null target.isOpen()) { target.sendMessage(new TextMessage(message.getPayload())); } break; case leave: leaveRoom(session, roomId); break; } } private void broadcast(String fromId, String roomId, MapString, Object payload) throws Exception { ObjectMapper mapper new ObjectMapper(); for (String peerId : rooms.getOrDefault(roomId, Set.of())) { if (!peerId.equals(fromId)) { sessions.get(peerId).sendMessage(new TextMessage(mapper.writeValueAsString(payload))); } } } private void leaveRoom(WebSocketSession session, String roomId) { sessions.remove(session.getId()); SetString peerSet rooms.getOrDefault(roomId, Set.of()); peerSet.remove(session.getId()); } }这段代码的关键点有三个。第一WebSocket 是长连接天然适合信令这种高频双向消息session 可以直接做消息路由。第二offer、answer、ice-candidate 这三种消息不需要服务端解析内容直接透传给 targetId 指定的会话服务端不感知 SDP 和 ICE 的具体含义这是 WebRTC 信令设计的一个重要边界——服务端只做路由不做状态机。第三ConcurrentHashMap的选用是为了支撑并发场景下的会话注册避免多人同时加入时丢消息。2.4 静态资源托管与前端源码的放置方式Spring Boot 项目里前端源码通常放在src/main/resources/static/下。构建后打成一个 jar访问http://your-host:8080/时由 Spring Boot 内置的静态资源解析直接返回index.htmlWebSocket 端点和静态资源在同一个端口上服务。这样部署时不需要单独配 Nginx一个java -jar命令就能把整个系统跑起来。开发阶段可以使用spring-boot-devtools前端文件修改后浏览器刷新即可生效。不建议在开发时把前后端分离成两个端口因为 WebRTC 的 getUserMedia 在非 localhost 的 HTTP 环境下会被拒绝如果前端跑在 8080、后端跑在 8081反而要多处理一层跨域和权限问题。3. 前端 WebRTC 核心实现采集、连接与媒体流绑定3.1 本地媒体采集的约束与参数getUserMedia 是 WebRTC 一切功能的前提。调用前要检查浏览器是否支持navigator.mediaDevices这个 API 只在安全上下文HTTPS 或 localhost下可用。多人会议里最常被忽略的是分辨率设置——同为 1080p 的摄像头三路视频同时显示时下行带宽可能吃掉几个 GMesh 模式下这个成本是叠加的。const getLocalStream async (constraints) { if (!navigator.mediaDevices?.getUserMedia) { throw new Error(当前环境不支持 WebRTC请使用 HTTPS 或 localhost); } return await navigator.mediaDevices.getUserMedia({ video: constraints?.video || { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 30, max: 30 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); };参数上值得关注的是ideal与max的配合ideal是首选值max是硬上限。摄像头实际输出的分辨率取决于设备能力和浏览器协商结果并不保证恰好是 1280×720。音频侧三个布尔值几乎必须全开否则回声消除和背景噪声会把会议体验拖垮。这里有个常见的坑Mac 笔记本内置麦克风在权限弹窗还没处理完时getUserMedia会一直挂起需要在 UI 上做一个加载态不能阻塞整个 WebSocket 连接流程。3.2 PeerConnection 的设置与 SDP 协商流程RTCPeerConnection 是每个远端会话的唯一对象。多人 Mesh 场景下每加入一个人就创建一个新实例每离开一个人就关闭对应实例。// RTCPeer 封装 class Peer { constructor(userId, isInitiator, stream) { this.userId userId; this.pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: turn:your-turn-server.example.com:3478, username: user, credential: pass } ] }); stream.getTracks().forEach(track this.pc.addTrack(track, stream)); this.pc.onicecandidate (e) { if (e.candidate) { sendMessage({ type: ice-candidate, targetId: userId, candidate: e.candidate }); } }; this.pc.ontrack (e) { const el document.getElementById(userId); if (el e.streams[0]) el.srcObject e.streams[0]; }; } async createOffer() { const offer await this.pc.createOffer(); await this.pc.setLocalDescription(offer); sendMessage({ type: offer, targetId: this.userId, sdp: this.pc.localDescription }); } async handleAnswer(sdp) { await this.pc.setRemoteDescription(new RTCSessionDescription(sdp)); } async handleOffer(sdp) { await this.pc.setRemoteDescription(new RTCSessionDescription(sdp)); const answer await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); sendMessage({ type: answer, targetId: this.userId, sdp: this.pc.localDescription }); } async addIceCandidate(candidate) { try { await this.pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { console.warn(添加 ICE 候选失败, e); } } close() { this.pc.close(); } }几个容易卡住新手的点逐一说明。addTrack必须在setLocalDescription之前调用否则 SDP 里没有媒体描述协商结果是一段没有音视频轨道的空连接。createOffer之后要立即setLocalDescription因为后续需要把 localDescription 塞进信令消息localDescription这个属性在 setLocalDescription 完成后才是非空值。ontrack回调里的e.streams[0]是远端媒体流的引用赋值给 video 元素的srcObject即可这个赋值必须在远端流到达之后发生。ICE 候选的时序值得单独提一句。onicecandidate的触发时机不可预测可能在 offer 发送前就产生了首批候选。所以前端要在收到远端答案或远端 offer 之前先缓存本地候选等setRemoteDescription完成后再统一addIceCandidate。在 Peer 类里做一个小改动就能兼容这种乱序维护一个pendingCandidates数组setRemoteDescription之后再 flush。3.3 房间内动态增删参与者多人会议的核心体验是动态进出不崩、不串流。服务端把 peer-joined、peer-left 广播到房间前端根据消息类型维护 peers 字典。const peers new Map(); // key: userId, value: Peer const handleSignal async (msg) { switch (msg.type) { case peer-joined: const p new Peer(msg.peerId, true, localStream); peers.set(msg.peerId, p); await p.createOffer(); break; case offer: if (!peers.has(msg.peerId)) { peers.set(msg.peerId, new Peer(msg.peerId, false, localStream)); } await peers.get(msg.peerId).handleOffer(msg.sdp); break; case answer: await peers.get(msg.peerId)?.handleAnswer(msg.sdp); break; case ice-candidate: await peers.get(msg.peerId)?.addIceCandidate(msg.candidate); break; case peer-left: peers.get(msg.peerId)?.close(); peers.delete(msg.peerId); const el document.getElementById(msg.peerId); el?.remove(); break; } };时序上有两个隐蔽的坑。第一peer-left消息可能先于该 peer 的信令消息到达比如被踢用户立即刷新页面导致服务端广播离开但它的 offer 还在路上。处理方式是close()时检查pc.connectionState如果是closed就跳过后续的setRemoteDescription调用。第二offer到来时如果本方的 Peer 实例已经存在比如对方断开后重连要先把旧实例的流从 video 元素里清掉否则srcObject会保留上一次会话的 MediaStream 引用画面停在最后一帧。4. 多人会话的管理房间机制、视频布局与消息路由4.1 服务端房间与参会者模型信令服务只做消息透传还不够多人会议要求服务端维护「谁在哪个房间」这个幂等清单。两个关键字段是 roomId 和 userIduserId 由前端生成UUID并在 join 时带上服务端存储 WebSocketSession 和 userId 的映射。数据项类型说明sessionsConcurrentHashMapuserId, WebSocketSessionuserId 到会话的映射用于定向发送roomMembersConcurrentHashMaproomId, SetuserId房间内成员集合用于广播joinTimeConcurrentHashMapuserId, Long加入时间可扩展为统计与会时长这里有一个细节不要在 rooms 里直接存 WebSocketSession 对象因为会话可能因网络波动重建userId 才是稳定标识。session 断开时通过afterConnectionClosed回调清理映射并广播peer-left。4.2 前端视频容器的网格布局策略多人视频的布局直接决定前端代码的复杂度。最省事的方案是固定网格——每路视频占一个固定比例的容器超过四路时缩放而不是滚动。常见做法是 CSS Grid 动态计算列数.video-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px; height: calc(100vh - 56px); } .video-cell video { width: 100%; height: 100%; object-fit: cover; background: #000; border-radius: 4px; }auto-fit与minmax(320px, 1fr)的组合实现了响应式重排窗口变窄时自动降列数视频块保持至少 320px 宽。object-fit: cover会裁掉视频边缘以确保填满容器如果不想裁切比如共享屏幕场景改成contain两侧会出现黑边。4.3 布局与媒体质量联动网格布局的同时要限制视频码率。默认情况下浏览器会尽力传输最高质量四路 1080p30 的 Mesh 会让客户端上行的编码压力巨大。虽然 WebRTC 没有直接提供「设置码率上限」的 API但可以在 SDP 层面修改不过更可靠的做法是简单地降分辨率。// 根据视频路数动态调整接收分辨率 function updateVideoDecodeStrategy(peerCount) { if (peerCount 6) { // 大于 6 路时降低所有远端视频的显示分辨率 peers.forEach((peer, userId) { const video document.getElementById(userId); if (video) { video.style.width 240px; video.style.height 135px; } }); } }注意这个方案是显示层降级不是编码层降级——远端仍然可能推 1080p 的流到本地只是显示缩小了。真正限制编码码率的做法是在ontrack里添加RTCRtpSender参数没有意义因为RTCRtpSender属于发送方。Mesh 架构下要限制的是对方发送的码率你只能通过协商时规定接收参数来约束没有完全可靠的办法。实用主义的角度限制本地发送分辨率 提示远端用户关闭不需要的摄像头比技术手段更有效。5. 调试、验证与排错清单5.1 用 chrome://webrtc-internals 定位问题这是所有 WebRTC 问题排查的第一站。打开页面后进入该地址能看到每条 PeerConnection 的完整生命周期getUserMedia的采集分辨率、SDP 交换内容、ICE 候选数量、连接状态、每秒收发包统计。重点看三项。一是RTCIceConnectionState是否进入 connected。如果一直卡在checking或反复disconnected说明 STUN/TURN 配置有问题看 ICE 候选里是否只有 host 类型。host 表示同一内网srflx 表示通过 STUN 获取的公网地址relay 表示经过 TURN 中继。只有 host 候选但双方不在同一内网时就会连接失败。二是RTCInboundRtpVideoStream的bytesReceived是否持续增长。如果 SDP 协商成功但 bytesReceived 为 0问题在媒体流本身。三是看channel面板中的信令消息时序结合前端 console 日志对比 offer 和 answer 的顺序。5.2 三个高频报错与对应处理NotFoundError: Requested device not found。这个报错出现在 getUserMedia 阶段表示 constraints 里指定的设备不存在。最常见的原因是同时指定了deviceId: xxx和video: true而设备 ID 是旧设备残留的。处理方式是调用前先navigator.mediaDevices.enumerateDevices()获取当前设备列表确认 ID 有效性后再传入。TypeError: Cannot read properties of null (reading srcObject)。这个是前端 DOM 时序问题。ontrack触发时承载该流的 video 元素还没插入到 DOM 里。解决方法是ontrack里不要直接操作 DOM而是把 userId 加入待渲染队列在 React/Vue 的下一个 tick 或原生 JS 的requestAnimationFrame里再绑定。// 先收集再统一渲染 pendingRemoteStreams.push({ userId, stream }); requestAnimationFrame(() { const el document.getElementById(userId); if (el) el.srcObject stream; });ICE failed, see about:webrtc for more details。这个最刺痛人——大概率是对方在 NAT 后而你的服务端没有配置可用的 TURN 服务器或 TURN 配置错了。验证手段是在 chrome://webrtc-internals 里看 ICE 协商是否有 relay 候选如果没有检查 TURN 服务的 3478 端口是否可达。5.3 自测清单按下面的顺序过一遍能在把功能交付给真实会议之前排除大部分问题。# 1. 检查 WebSocket 端点是否正常 curl -i -N -H Connection: Upgrade -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ http://localhost:8080/signal # 2. 检查信令消息能否双向透传 # 开两个浏览器控制台分别执行 ws new WebSocket(ws://localhost:8080/signal) # 两边分别监听 ws.onmessage一边发送 join观察另一边是否收到 peer-joinedcurl 返回 101 Switching Protocols 说明 WebSocket 握手成功。双浏览器测试能确认服务端路由逻辑没有把消息丢在自己的循环里。信令通了再去测试媒体面打开两个页面分别允许摄像头权限看两边的视频画面是否出现。这一步能过基本功能就走通了。本文还有配套的精品资源点击获取