简介:面向Java毕业设计场景的远程视频会议系统完整项目包,涵盖系统源码与配套论文,适合计算机相关专业学生用于课程设计、毕业设计或项目实战参考,对于巩固Java面向对象、集合、异常处理等基础也有帮助。压缩包共310个文件,约3.71MB,主体为206个xmi设计模型、30个class编译文件与11个java源文件,另含xml配置、项目配置和论文文档等,结构上覆盖设计、实现、部署各环节,便于按模块对照检索。该资源在学习平台已有395人浏览学习,属于较受关注的Java网络应用类毕业设计案例。通过源码可重点学习Socket网络编程、音视频采集与RTP传输、多线程并发处理及GUI客户端设计等关键知识点;论文部分则提供需求分析、系统架构和数据流设计思路,便于快速理解整体实现并在此基础上进行二次开发。
1. 基于Java的远程视频会议系统,先想清楚「信令」和「媒体」谁负责
基于Java的远程视频会议系统,最常见的错误是把摄像头采集和视频编码也塞进 Java。真实项目里,Java 只做信令和会议控制,媒体平面由浏览器里的 WebRTC 负责:Java 后端负责「谁进了哪个房间、SDP offer/answer 怎么转发、ICE 候选发给谁」,WebRTC 负责「视频怎么编码、网络抖动怎么扛、丢包怎么重传」。这个分工直接决定整套代码量和答辩深度。下面按毕业设计「系统+论文」的常见写法推进,从拓扑选型讲起,落到 Spring Boot + WebSocket 信令服务器的最小实现、房间状态与 JWT 鉴权,最后给出跨网段联调时 STUN/TURN 的部署步骤和四个验证点。适合要用 Java 快速交付可演示会议系统的开发者,也适合准备回答「为什么不用 MCU」这类答辩追问的人。
2. Java 远程视频会议系统的架构选型:P2P、SFU 与 MCU 的取舍
2.1 媒体平面为什么交给 WebRTC,Java 只做信令
实时音视频的难点不在「能不能拿到画面」,而在传输层:视频帧要按 RTP 分包,用 SRTP 加密,接收端按时间戳和序列号重排,抖动超过阈值时要缓冲,带宽不足时要降码率,这还不算回声消除和丢包重传。这些逻辑如果全部用 Java 从零实现,工作量以人年计,而且浏览器端 Java 根本跑不了——用户在浏览器里打开网页开会,采集和编码只能在 WebRTC 里完成。
所以「基于 Java 的远程视频会议系统」的正确含义是:Java 开发信令与控制面,浏览器内置 WebRTC 承载媒体面。信令面负责三类事情:一是房间管理,谁进谁出、当前有谁、变化后通知全场;二是媒体协商的转发,A 生成的 SDP offer 要原样搬到 B,B 的 answer 要搬回 A;三是会话状态的维护,为断线重连、会议记录提供依据。
一个有力的佐证是 Jitsi Videobridge,这个被广泛部署的开源 SFU 媒体服务器就是用 Java 写的。它证明 JVM 在媒体转发这一层完全够用,但注意它的定位是「转发」,不是「自研一套 WebRTC」。普通项目里,Java 后端连转发都不需要做,把 SDP 和 ICE 候选当不透明字符串转发,就能撑起 2 到 4 人的纯 P2P 会议。
2.2 三种拓扑的带宽模型与选型表
P2P mesh、SFU、MCU 三者的差别,本质是「每份画面要复制几份」的问题。P2P 里每个客户端要把自己的画面分别发给其他所有人;SFU 是全网转发节点,每个客户端只上行一路,由服务器分发给其他人;MCU 则把多路画面解码后合成为一路甚至一个宫格,再编码分发。
以 720p、1.5Mbps 码率为例,4 人 P2P 时每个人上行 3 路、约 4.5Mbps,家用宽带上行普遍只有 20 到 30Mbps,还能撑住;到 8 人就是每人 7 路上行、10Mbps 左右,已经逼近上行瓶颈。SFU 把上行压到每人的 1 路、1.5Mbps,但服务器要给每个人都转发一份,出口带宽按参会人数线性放大。MCU 对客户端最友好,但服务器要做转码,CPU 开销和实现复杂度都最高。
| 拓扑 | 每人上行 | 服务器出口带宽 | 服务器 CPU | 端到端延迟 | 适用人数 | Java 端职责 |
|---|---|---|---|---|---|---|
| P2P mesh | N-1 路 | 0(只走信令) | 低 | 最低 | 2~4 | 信令 + 房间状态 |
| SFU | 1 路 | N 路线性放大 | 中(转发不转码) | 增加一跳 | 5~20 | 信令 + 调用 SFU 接口 |
| MCU | 1 路 | 1 路 | 高(重编码) | 最高 | 10~50 | 信令 + 混流调度 |
毕业设计采用 P2P 作为主拓扑,论文里讲清楚「小规模下延迟最低、服务器零带宽成本」,再把 SFU 作为扩展点写进设计,是性价比最高的写法。答辩时被问到「如果 100 人开会怎么办」,答案不是当场改代码,而是给出 SFU 平滑替换 P2P 的迁移路径。
2.3 先定边界:Java 侧管连接,不管媒体细节
一旦把信令和媒体分开,Java 侧的设计就清晰了。信令服务器只需要维护两样东西:WebSocket 会话集合,和会话所属房间。SDP、ICE candidate 这类载荷对 Java 侧是不透明的,收到就转发,不要尝试解析。解析它们会导致两个后果:一是代码和 Chrome 的媒体实现耦合,浏览器一升级行为就变;二是把不必要的工作量写进论文,答辩时自找麻烦。
如果后续要加录制、字幕、布局切换,插入一个 SFU 即可,Java 与媒体的交互收敛成一层薄薄的网关接口:
public interface SfuGateway { void createRoom(String roomId); // 在 SFU 上建立媒体房间 void addParticipant(String roomId, String userId); // 注册参会者 void removeParticipant(String roomId, String userId); boolean isRoomAlive(String roomId); }浏览器到 Java 的 WebSocket 协议可以完全不动,变化只发生在 Java 与 SFU 之间。这也是下面三章代码的边界:信令协议、房间状态、鉴权与落库,全部围绕「连接管理」展开,媒体细节一个字段都不进 Java 的业务代码。
3. 用 Spring Boot + WebSocket 实现 Java 端信令服务器
3.1 信令协议:先定六种消息类型
P2P 会议的信令协议可以收敛成六类消息,再多都是冗余。设计原则是「服务端只转发、不解析媒体载荷」,所有媒体相关字段都用字符串原样携带。
| type | 发送方 | 接收方 | 载荷要点 | 用途 |
|---|---|---|---|---|
| join | 客户端 | 服务端 | roomId、userId、displayName | 进入会议房间 |
| joined | 服务端 | 刚加入的客户端 | 当前成员列表 | 触发新成员逐个建连 |
| peer-joined | 服务端 | 房间内其他人 | userId、displayName | 通知老成员有新成员 |
| offer | 客户端 A | 客户端 B(经服务端转发) | from、to、sdp | 媒体协商请求 |
| answer | 客户端 B | 客户端 A(经服务端转发) | from、to、sdp | 媒体协商应答 |
| ice-candidate | 任意客户端 | 对端(经服务端转发) | from、to、candidate | 交换 NAT 穿透候选 |
| leave | 客户端 | 服务端 | userId | 主动离开房间 |
这里有个必须写进协议说明的约定:offer 的发起方固定是「后进入房间的人」。两个人同时互发 offer 会产生 glare 冲突,Chrome 虽然有完美协商机制可以兜底,但服务端做一次统一定向,前端的分支能少一半。joined 返回成员列表后,新成员按列表顺序依次创建 RTCPeerConnection 并发 offer,老成员只回 answer,不主动发起。
注意:offer 发起方统一由「后进房间者」承担,既能避免 glare,也让所有参会者的前端代码路径完全一致。
3.2 用 WebSocketHandler 实现最小信令端点
Spring Boot 里我一般选原生 WebSocketHandler 而不是 STOMP:信令是会话级的点对点转发,不需要消息代理、主题订阅和额外的 JSON 转换层,STOMP 在这里带来的抽象全是负担。先注册端点:
@Configuration public class WebSocketConfig implements WebSocketConfigurer { private final SignalHandler signalHandler; public WebSocketConfig(SignalHandler signalHandler) { this.signalHandler = signalHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(signalHandler, "/ws/signal") .addInterceptors(new JwtHandshakeInterceptor()) // 握手阶段完成鉴权 .setAllowedOriginPatterns("*"); // 开发期放开,生产收紧 } }/ws/signal是浏览器连接的路径;JwtHandshakeInterceptor在握手阶段校验令牌,失败直接返回 401,根本不会建立连接;setAllowedOriginPatterns("*")只用于开发期放开跨域,上生产要收紧为会议站点的实际域名,否则任何网页都能往你的信令服务器灌恶意连接。
@Component public class SignalHandler extends TextWebSocketHandler { private final RoomRegistry roomRegistry; public SignalHandler(RoomRegistry roomRegistry) { this.roomRegistry = roomRegistry; } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { SignalMessage msg = SignalMessage.fromJson(message.getPayload()); // 握手拦截器已经把登录态写入 session 属性,这里直接取用 String roomId = (String) session.getAttributes().get("roomId"); String userId = (String) session.getAttributes().get("userId"); switch (msg.type()) { case "join" -> roomRegistry.join(roomId, userId, session); case "offer" -> roomRegistry.forward(roomId, userId, msg.to(), message.getPayload()); case "answer" -> roomRegistry.forward(roomId, userId, msg.to(), message.getPayload()); case "ice-candidate" -> roomRegistry.forward(roomId, userId, msg.to(), message.getPayload()); case "leave" -> roomRegistry.leave(roomId, userId); case "heartbeat" -> session.sendMessage(new TextMessage("{\"type\":\"pong\"}")); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { // 浏览器直接关页面不会发 leave,必须在这里清理僵尸会话 String roomId = (String) session.getAttributes().get("roomId"); String userId = (String) session.getAttributes().get("userId"); if (roomId != null) { roomRegistry.leave(roomId, userId); } } }SignalMessage 是一个只保留 type、from、to 三个字段的 record,其余载荷原样透传。handler 里只做消息分发,不写任何业务逻辑。join 以外的消息都要求 session 属性里已经有 roomId 和 userId,这两个值由握手拦截器写入,客户端想伪造 roomId 也改不了。afterConnectionClosed 是必须处理的路径——断电、关页面、切网络都会走这里。
3.3 房间注册表与转发逻辑
房间与成员的映射用 ConcurrentHashMap 实现,单机部署下性能完全够用,一万个并发会议也只是几十万次 map 操作。import 部分按 Spring Boot 3 的常规包走,下面这段就是完整的房间核心:
@Component public class RoomRegistry { private final Map<String, Room> rooms = new ConcurrentHashMap<>(); public void join(String roomId, String userId, WebSocketSession session) { // computeIfAbsent 保证同一房间只初始化一次 Room room = rooms.computeIfAbsent(roomId, Room::new); room.join(userId, session); List<String> peers = room.peerIdsExcept(userId); room.sendTo(userId, "{\"type\":\"joined\",\"peers\":" + Json.toJson(peers) + "}"); for (String peerId : peers) { room.sendTo(peerId, "{\"type\":\"peer-joined\",\"userId\":\"" + userId + "\"}"); } } public void forward(String roomId, String from, String to, String payload) { Room room = rooms.get(roomId); if (room != null) { // attachFrom 负责把 from 字段补进 JSON,接收方才知道 SDP 来自谁 room.sendTo(to, attachFrom(payload, from)); } } public void leave(String roomId, String userId) { Room room = rooms.get(roomId); if (room == null) return; room.remove(userId); for (String peerId : room.peerIds()) { room.sendTo(peerId, "{\"type\":\"peer-left\",\"userId\":\"" + userId + "\"}"); } if (room.isEmpty()) { rooms.remove(roomId, room); // 空房间立即回收,避免 map 无限膨胀 } } static class Room { final String id; final Map<String, WebSocketSession> members = new ConcurrentHashMap<>(); Room(String id) { this.id = id; } void join(String userId, WebSocketSession session) { // 先移除旧连接再写入新连接,保证重连后房间内只有一份会话 WebSocketSession old = members.put(userId, session); if (old != null && old.isOpen()) { try { old.close(); } catch (IOException ignored) { } } } void sendTo(String userId, String payload) { WebSocketSession session = members.get(userId); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(payload)); } catch (IOException e) { // 发送失败说明连接已坏,交给 afterConnectionClosed 清理 } } } List<String> peerIdsExcept(String userId) { return members.keySet().stream().filter(id -> !id.equals(userId)).toList(); } List<String> peerIds() { return List.copyOf(members.keySet()); } void remove(String userId) { members.remove(userId); } boolean isEmpty() { return members.isEmpty(); } } }join 里做两件事:把新会话放进房间,然后通知全房间。新成员收到 joined 后逐个发 offer,老成员收到 peer-joined 后只准备 answer。forward 转发前用 attachFrom 把发送方 userId 补进原始 payload——WebRTC 的 onicecandidate 回调里只给 candidate 本身,不会自动带对方标识。Json 是项目里封装 Jackson 的本地工具类,实际写两行 ObjectMapper 即可。
提示:WebSocket handler 是单例,被所有连接共享,绝对不能在类里声明「当前用户」这种实例字段。所有连接绑定的数据要么放 session.getAttributes(),要么放在以 session 为 key 的注册表里。
4. 会议室状态、JWT 鉴权与参会记录落库
4.1 房间状态放内存还是 Redis:按规模决定接口
毕业设计或小规模部署,房间状态放内存是正确选择,不需要一上来就引 Redis。理由有三:WebSocket 会话本身就是进程内对象,没法跨节点共享;单机内存查找是纳秒级,Redis 是毫秒级,信令转发的热路径上不需要多一次网络往返;多机扩展时真正难的是会话路由,不是状态存储。
| 方案 | 会话路由 | 状态一致性 | 实现成本 | 适用规模 |
|---|---|---|---|---|
| 内存 ConcurrentHashMap | 单机内直接定位 | 天然一致 | 低 | 单实例演示、小规模 |
| Redis + Pub/Sub 广播 | 仍需本地 session 映射 | 需要节点间同步 | 高 | 多实例水平扩展 |
如果确实要上 Redis,注意它只能存房间元数据(成员列表、创建时间),不能存 WebSocketSession。跨节点转发信令需要 Redis Pub/Sub 或消息队列把消息广播到所有节点,再由目标节点找到本地 session 推送。这个复杂度 3 人会议用不到,最务实的做法是把 RoomRegistry 抽成接口,内存实现先跑通,答辩时说明「水平扩展时替换为 Redis 实现」。
比 Redis 更值得做的是断线重连:用户从 WiFi 切到 4G,WebSocket 连接会断开,afterConnectionClosed 会立刻把用户踢出房间。常见妥协方案是「延迟移除」——leave 时把 userId 放进房间的 pendingLeaves 集合,用户 30 秒内重连就恢复,超过 30 秒还没回来再广播 peer-left。这个机制很小,但演示时切网络不黑屏,比任何并发优化都直观。
4.2 握手阶段完成 JWT 鉴权,token 走 query 参数
浏览器 WebSocket API 不支持自定义 Header,前端通常把 token 放 URL query 上传。服务端在 beforeHandshake 里解析并校验,校验通过才允许握手:
public class JwtHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = UriComponentsBuilder.fromUri(request.getURI()) .build().getQueryParams().getFirst("token"); try { Claims claims = JwtUtil.parseToken(token); // 这三个属性后面会被 SignalHandler 直接使用 attributes.put("userId", claims.getSubject()); attributes.put("roomId", claims.get("roomId", String.class)); attributes.put("displayName", claims.get("displayName", String.class)); return true; } catch (JwtException e) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } } }拦截器返回 false 时握手直接失败,浏览器 WebSocket 的 onerror 会触发,连接根本不会建立。JWT 里除了 userId 还应该放 roomId,这样 join 命令到达服务端时,roomId 已经由服务端信任的凭证决定,客户端传什么 roomId 都没用。很多会议系统的越权漏洞就是「客户端传 roomId,服务端只当普通参数转发」造成的——签名载荷里 roomId=X,请求里 roomId=Y,服务端必须能识别这是非法注入。
一个安全细节值得写进论文:token 出现在 URL 里会被 Nginx access log、浏览器历史完整记录。生产环境更稳的做法是两步走——先用 token 调 HTTP 接口换一个 60 秒有效的临时 ticket,再用 ticket 建立 WebSocket,原始 token 不出现在 URL。
4.3 参会记录与会议历史:一张表撑起论文的「数据层」
远程视频会议系统如果只有信令,论文的数据层会显得单薄。加一张参会记录表就能把「会议历史记录」这个功能讲完整,同时给答辩准备一个真实业务查询:
CREATE TABLE meeting_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, user_name VARCHAR(64) NOT NULL, join_time DATETIME NOT NULL, leave_time DATETIME NULL, duration_seconds INT NULL, INDEX idx_room_join (room_id, join_time) );落库操作写在 join 和 afterConnectionClosed 里,但绝不能同步执行。WebSocket handler 的线程池很小,JDBC 写盘或连接池等待会卡住其他参会者的信令转发。用 Spring 的 @Async 把写库丢到独立线程池,用户离开时再执行一条 UPDATE 补 duration_seconds。查询端做一个简单接口:按 roomId 返回某次会议的参与时间线,答辩演示时直接投影这个时间线,比空谈并发模型直观得多。
5. 跨网段联调:coturn 部署与四个验证点
5.1 先核对信令时序,再碰网络
联调时第一个要确认的是信令时序。打开两个浏览器的开发者工具,过滤 WebSocket 帧,预期顺序是:双方都收到 joined/peer-joined → 新成员发出 offer → 老成员回 answer → 双方互相刷 ice-candidate。如果 offer 发出去没有 answer,问题在信令链路而不是网络——检查 forward 的 to 是否拼错、目标 session 是否还活着。这个排查顺序很重要,很多人一上来就查 TURN,结果信令根本没通。
注意:信令走 Nginx 反代时,proxy_read_timeout 默认 60 秒会掐断空闲连接。会议系统里信令连接长时间没有消息非常正常,必须把超时调到 3600s 以上,同时显式带上 Upgrade 和 Connection 头。
5.2 coturn 最小配置与防火墙验证
内网测试时 host 候选就能建连,一旦 A 在家、B 在办公室,NAT 类型不同,没有 relay 候选就永远连不上。coturn 最小配置:
listening-port=3478 tls-listening-port=5349 realm=meet.example.com server-name=meet.example.com listening-ip=10.0.0.8 relay-ip=10.0.0.8 external-ip=203.0.113.10/10.0.0.8 fingerprint lt-cred-mech user=meetuser:strongpass total-quota=100 stale-nonce=600 no-multicast-peersexternal-ip 是云主机上最容易配错的一项。服务器只绑定了内网 IP 时,turnserver 分配的 relay 候选会写内网地址,远端浏览器拿到也连不上,必须按「公网/内网」格式做映射。配置完用 turnutils 自测:
# 第一个命令验证 STUN 能正常响应服务器 turnutils_stunclient -p 3478 203.0.113.10 # 第二个命令验证 TURN 能分配出 relay 地址 turnutils_uclient -u meetuser -w strongpass -p 3478 203.0.113.10防火墙要放行 3478/udp、5349/tcp 和 coturn 动态分配的中继端口,云服务器则对照安全组逐条核对。部署机上 java 环境变量配置正确后,用 java -version 和 mvn -v 确认 JDK 17+ 与 Maven 可用,再启动 Spring Boot 信令服务。
5.3 webrtc-internals 里看候选类型
最后一步,打开 chrome://webrtc-internals,筛选中继候选:
- 只有 host 候选:说明测试双方还在同一局域网,没验证穿透,别急着庆祝。
- 出现 srflx 候选:STUN 生效,至少一方拿到了公网映射。
- 候选对里有 relay:TURN 生效,这是跨不同运营商网络的典型状态,连接类型显示 relay-relay 说明媒体走的是 TURN 转发。
四个验证点按顺序过:信令时序 → STUN 响应 → TURN 分配 → relay 候选出现。任何一个卡住,都能立刻定位到是服务端转发、防火墙端口还是浏览器侧的 ICE 配置问题,20 分钟内可以收工。
本文还有配套的精品资源,点击获取