本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:基于SpringBoot搭建的实时音视频会议系统,后端用WebSocket处理房间创建、用户加入、状态同步等信令逻辑,核心类包括WebrtcWS处理器和WebrtcRoomServiceImpl服务;数据库采用MySQL,附带meeting.sql建表脚本与初始数据,支持快速导入;配置文件application.properties中已预留数据库连接参数,部署前需修改为实际地址及服务器IP;前端源码独立提供,可直接联调;项目结构标准,含Maven配置(pom.xml)、资源目录(resources)、主启动类、单元测试支持;配套项目说明.md文档详细列出环境准备、数据库导入、配置替换、服务启动及常见问题排查步骤;适用于毕业设计、课程实践或Java全栈学习,覆盖WebRTC信令流程、SpringBoot集成WebSocket、实时通信系统开发等关键技能点。

1. 这不是Demo,是能跑通的视频会议后端骨架:从毕业设计到轻量级商用的临界点

你手头这份“Java视频会议系统源码”,名字听起来像教学示例,但实际拆开看,它踩在了一个非常关键的分水岭上——既不是玩具级的Hello WebSocket,也不是动辄几十万行、依赖K8s和音视频中继集群的工业级方案。它用SpringBoot+WebSocket+MySQL这三件套,把WebRTC信令层最核心、最不可绕过的那部分逻辑,稳稳地托住了。关键词里反复出现的 SpringBoot、WebSocket、视频会议、WebRTC、MySQL,不是堆砌,而是这个项目真实的技术栈坐标:后端不碰音视频流(那是浏览器和客户端的事),只专注做“会议调度员”——谁进房间、谁离开、谁正在发言、谁的媒体描述(SDP)要转发给谁、ICE候选者怎么交换……这些全靠WebSocket这条“高速专线”实时传递。

我带过不少学生做毕设,也帮初创团队快速搭过MVP,最常听到的抱怨是:“网上教程全是单页WebSocket聊天室,一加音视频就崩”“WebRTC文档看得懂,但信令服务器到底该怎么组织状态?”这份源码的价值,恰恰在于它把教科书里的“信令通道”概念,变成了可调试、可打断点、可改配置的真实Java类。WebrtcWS处理器不是个空壳,它处理OPEN、CLOSE、JOIN、LEAVE、OFFER、ANSWER、CANDIDATE这些标准信令;WebrtcRoomServiceImpl也不是个接口,它用ConcurrentHashMap管理着所有活跃房间,每个房间里又用CopyOnWriteArrayList存着当前用户列表,连用户加入时的“房间满员”判断、离开时的“最后一人自动销毁房间”逻辑都写死了。这不是伪代码,这是你改两行就能在局域网里拉起三人会议的生产级骨架。尤其对计算机专业学生来说,它避开了FFmpeg编解码、SFU/MCU架构选型这些深水区,让你能集中火力理解“为什么信令必须可靠、为什么状态必须强一致、为什么WebSocket比HTTP轮询更适合这个场景”——这些才是全栈开发中真正卡脖子的认知门槛。部署前全局替换IP?这恰恰说明它没用localhost硬编码,而是预留了生产环境适配的意识。配套的meeting.sql里甚至预置了admin用户和测试房间数据,你导入数据库、改好application.properties里的jdbc.url,mvn spring-boot:run之后,前端一连,会议室就活了。它不承诺替代Zoom,但它绝对能让你亲手摸到实时音视频系统的神经中枢。

2. 整体架构与设计思路:为什么是SpringBoot+WebSocket+MySQL这个组合?

2.1 技术选型背后的现实权衡:不做技术洁癖,只求稳定交付

看到这个组合,有人会问:“为什么不用Netty?为什么不用Redis做房间状态?为什么MySQL不换MongoDB?”——这些问题背后,藏着一个资深开发者最朴素的判断:在毕业设计、课程实践或小型内部工具的语境下,技术选型的第一优先级永远是“降低认知负荷、缩短验证周期、规避非必要复杂度”。我们来逐层拆解这个看似“传统”的组合如何精准命中需求:

  • SpringBoot作为底座:它解决的从来不是“性能天花板”,而是“启动即服务”。pom.xml里几行starter依赖(spring-boot-starter-web、spring-boot-starter-websocket、spring-boot-starter-data-jpa),就把Tomcat内嵌容器、自动配置、健康检查、Actuator监控这些企业级能力打包送到了你面前。你不需要纠结Servlet版本兼容性,不用手动注册WebSocketEndpoint,更不用为日志框架打架。@SpringBootApplication主类一行注解,整个应用就立起来了。对于需要快速验证信令逻辑的学生或工程师,省下的这几个小时,足够你多调通三次ICE连接失败。

  • WebSocket作为信令通道:这里必须划重点——它只负责信令(Signaling),不传输音视频流(Media Stream)。WebRTC的媒体流是P2P直连的(或经TURN中继),信令只是交换SDP Offer/Answer和ICE Candidate的“媒人”。WebSocket的天然优势在此刻凸显:全双工、低延迟、长连接、基于HTTP升级(防火墙友好)。对比HTTP轮询,它避免了频繁建连开销和消息延迟;对比SSE(Server-Sent Events),它支持客户端主动推送(比如用户点击“静音”按钮,前端立刻发一条{"type":"MUTE","roomId":"abc","userId":"u1"}给后端);对比原始TCP Socket,它有成熟的协议规范、浏览器原生支持、丰富的Java生态库(如Spring的WebSocketHandler)。项目里WebrtcWS类继承TextWebSocketHandler,就是吃透了这一层抽象——你只管处理afterConnectionEstablished(用户进房)、handleTextMessage(收信令)、afterConnectionClosed(用户离房)这三个生命周期钩子,底层连接管理、心跳保活、异常重连(需自行扩展)都由Spring帮你兜底。

  • MySQL作为状态存储:很多人第一反应是“房间状态放内存不就行了?干嘛用数据库?”——这恰恰是本项目设计成熟度的体现。meeting.sql脚本里建的meeting_roommeeting_usermeeting_message三张表,承担的是持久化、审计、冷启动恢复三大刚需。试想:服务器重启后,内存里的ConcurrentHashMap清空了,但MySQL里记录的“房间ID=room123,创建时间=2024-05-20 14:00,状态=ACTIVE”还在。WebrtcRoomServiceImpl初始化时,可以从数据库捞出所有未关闭的房间,重建内存状态,避免“服务一挂,所有会议消失”的灾难。meeting_message表则记录了所有信令日志(类型、发送者、接收者、时间戳),当出现“用户A说发了Offer,用户B却没收到”这类问题时,直接查库比翻日志快十倍。至于为什么不用Redis?因为MySQL对初学者更友好:安装简单(sudo apt install mysql-server)、可视化工具多(DBeaver、Navicat)、SQL语法通用、事务ACID保障强(比如“创建房间+插入管理员”必须原子性)。Redis当然更快,但为了一百个并发用户的会议系统,MySQL的IO瓶颈远不如你写错一个@Transactional注解来得致命。

这个组合的本质,是用经过千锤百炼的、文档齐全的、社区支持强大的“老技术”,去承载一个逻辑清晰、边界明确、规模可控的新场景。它不炫技,但每一步都踩在降低失败概率的实地上。

2.2 核心模块职责划分:谁该做什么,边界在哪里?

一个健康的系统,模块职责必须像刀切豆腐一样清晰。这份源码的结构(src/main/java/com/example/webrtc/)体现了典型的分层思想,我们按包名和类名来解剖:

  • controller包(可选,本项目可能精简):严格来说,纯信令系统可以没有REST Controller,因为所有交互都走WebSocket。但如果需要提供“获取房间列表”、“查询用户状态”等管理API,这里就是入口。本项目若未实现,恰说明它聚焦核心——信令通道本身。

  • websocket包(核心!)WebrtcWS.java是整个系统的“心脏起搏器”。它继承TextWebSocketHandler,重写了三个关键方法:

  • afterConnectionEstablished(WebSocketSession session):当浏览器WebSocket连接建立成功,后端拿到session.getId()session.getAttributes().put("userId", userId)(通常从URL参数或JWT解析),此时应将该session与用户ID绑定,并尝试加入默认房间或等待JOIN指令。
  • handleTextMessage(WebSocketSession session, TextMessage message)所有信令消息的总入口message.getPayload()是JSON字符串,需反序列化为SignalingMessage对象(项目应定义此DTO),根据type字段分发:JOIN调用roomService.joinRoom()OFFER调用roomService.broadcastToOthers()CANDIDATE则需校验候选者有效性后再广播。这里必须做严格的JSON Schema校验,否则恶意构造的{"type":"DROP_DATABASE"}会直接击穿系统(虽然后端无此逻辑,但防御性编程是本能)。
  • afterConnectionClosed(WebSocketSession session, CloseStatus status):连接关闭时的清理工作。必须从roomService中移除该session关联的用户,并检查房间是否只剩一人(需同步锁),若是则销毁房间。CloseStatus还能告诉你关闭原因(网络中断?主动关闭?),用于日志分析。

  • service包(业务中枢)WebrtcRoomServiceImpl.java是“房间大脑”。它持有ConcurrentHashMap<String, WebrtcRoom>(房间ID->房间对象),每个WebrtcRoom内部又用CopyOnWriteArrayList<WebSocketSession>存用户会话。关键方法包括:

  • createRoom(String roomId, String creatorId):生成新房间,设置创建者为管理员,存入Map。注意:roomId应全局唯一,项目可能用UUID或时间戳+随机数生成,避免碰撞。
  • joinRoom(String roomId, WebSocketSession session):先查房间是否存在且未满员(room.getUserCount() < MAX_USERS),再将session加入CopyOnWriteArrayList此处必须加锁或使用线程安全集合,否则高并发下userList.add(session)可能丢失。
  • broadcastToOthers(String roomId, String excludeUserId, SignalingMessage message):遍历房间内所有session,跳过excludeUserId对应的session,调用session.sendMessage(new TextMessage(JSON.toJSONString(message)))。这是信令广播的核心,也是性能瓶颈点——如果房间有50人,就要发49次消息。优化方向是批量序列化一次JSON,再循环发送,而非每次序列化。

  • entityrepository包(数据契约)MeetingRoom.javaMeetingUser.java这些JPA Entity,与meeting.sql中的表一一对应。MeetingRoomRepository继承JpaRepository,提供了findByIdsavefindAll等开箱即用的方法。WebrtcRoomServiceImpl在需要持久化时(如房间创建成功后记录到DB),只需调用meetingRoomRepository.save(roomEntity),Spring Data JPA自动完成INSERT/UPDATE。

这种划分让修改变得极其安全:你想改信令协议?只动websocket包;想换数据库?只改repository实现和application.properties;想加房间密码?在joinRoom方法里加校验逻辑即可。边界清晰,改动不扩散。

2.3 为什么信令必须独立于媒体流?WebRTC的“双通道”哲学

这是理解整个系统设计的底层钥匙。很多初学者最大的误区,就是试图用WebSocket传视频帧——这就像用邮政信件寄快递包裹,效率极低且不可靠。WebRTC的设计哲学是信令与媒体分离(Signaling and Media Separation)

  • 信令通道(Signaling Channel):负责交换“元信息”。想象两个陌生人要见面,他们先通过电话(信令)约定:“我在星巴克东门第三张桌子等你(SDP Offer)”,“好的,我马上到,我穿蓝衬衫(SDP Answer)”,“等等,我路上堵车,可能晚5分钟(ICE Candidate)”。这些对话不传送任何实体物品,只传递见面所需的协调信息。WebSocket在这里扮演的就是这通电话,它必须可靠、有序、低延迟——丢一条“Answer”,整个连接就失败。

  • 媒体通道(Media Channel):负责传输“实体内容”。一旦双方通过信令约定了见面地点(即完成了SDP交换和ICE连通性检查),他们就直接走到星巴克(P2P直连),开始面对面交谈(音视频流)。这个通道追求的是高吞吐、低延迟、容忍丢包——语音丢几个包,人耳几乎听不出;视频丢一帧,画面稍卡顿而已。它走的是UDP协议,不保证送达,但速度飞快。

本项目的所有Java代码,只处理前者。WebrtcWS收到前端发来的{"type":"OFFER","sdp":"v=0\r\no=- 123456789..."},解析出sdp字段,再原样(或稍作处理)转发给房间内其他用户。它绝不解析SDP内容,绝不触碰a=sendrecv这样的行,更不会去解码H.264帧。这种“只做邮差,不做翻译”的设计,让它极度轻量、稳定、易维护。当你在前端用peerConnection.setRemoteDescription(new RTCSessionDescription(offer))时,背后驱动的正是这套信令逻辑。理解这一点,你就明白了为什么这个Java后端可以和任何WebRTC前端(React/Vue/Angular甚至原生Android/iOS)无缝对接——只要它们遵循相同的信令协议(JSON格式、type字段定义、错误码规范)。

3. 核心细节解析与实操要点:从源码到可运行服务的关键步骤

3.1 数据库准备:meeting.sql不只是脚本,是状态基线

meeting.sql文件是整个系统的“数据基石”,绝不能当成普通建表语句草草执行。我们来逐行解读其设计意图和实操陷阱:

-- meeting_room 表:记录所有创建过的房间
CREATE TABLE `meeting_room` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `room_id` varchar(64) NOT NULL COMMENT '房间唯一标识,如 UUID',
  `name` varchar(100) DEFAULT NULL COMMENT '房间名称',
  `creator_id` varchar(64) NOT NULL COMMENT '创建者用户ID',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-活跃, 0-已关闭',
  `created_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_room_id` (`room_id`) -- 强制房间ID唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- meeting_user 表:记录用户与房间的关联关系(多对多)
CREATE TABLE `meeting_user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `room_id` varchar(64) NOT NULL,
  `user_id` varchar(64) NOT NULL,
  `user_name` varchar(50) DEFAULT NULL,
  `join_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `leave_time` datetime DEFAULT NULL,
  `is_admin` tinyint NOT NULL DEFAULT '0' COMMENT '1-是管理员',
  PRIMARY KEY (`id`),
  KEY `idx_room_user` (`room_id`,`user_id`) -- 联合索引,加速按房间查用户
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- meeting_message 表:信令操作审计日志
CREATE TABLE `meeting_message` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `room_id` varchar(64) NOT NULL,
  `sender_id` varchar(64) NOT NULL,
  `receiver_id` varchar(64) DEFAULT NULL COMMENT '为空表示广播',
  `message_type` varchar(20) NOT NULL COMMENT 'JOIN, LEAVE, OFFER, ANSWER...',
  `content` text COMMENT 'JSON字符串,如 SDP 或 Candidate',
  `created_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_room_time` (`room_id`,`created_time`) -- 按房间和时间排序,方便查历史
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

实操要点与避坑指南:

  1. 字符集必须是utf8mb4DEFAULT CHARSET=utf8mb4是硬性要求。utf8在MySQL中实际是utf8mb3,不支持emoji和部分四字节Unicode字符(如某些中文生僻字)。WebRTC的SDP描述中可能包含任意字符串,用utf8会导致插入失败或乱码。执行前务必确认你的MySQL实例默认字符集是utf8mb4,可通过SHOW VARIABLES LIKE 'character_set%';查看。

  2. 唯一索引uk_room_id是防重关键room_id是业务主键,id是数据库自增主键。UNIQUE KEY uk_room_id (room_id)确保不会因并发创建导致同名房间。但要注意:如果WebrtcRoomServiceImpl.createRoom()方法里先生成roomId再存库,而生成逻辑(如UUID.randomUUID().toString())在应用层,那么高并发下仍可能有极小概率重复(UUID理论碰撞概率极低,但非零)。更稳妥的做法是在INSERT INTO meeting_room (...) VALUES (...)时捕获DuplicateKeyException,然后重试生成新ID。

  3. meeting_user表的leave_time字段意义重大:它不是用来“统计在线时长”的,而是为了支持“断线重连”。当用户网络中断,WebSocket连接关闭,WebrtcWS.afterConnectionClosed()会触发,此时WebrtcRoomServiceImpl应更新该用户的leave_time = NOW()。当下次用户带着相同userId重新JOIN时,服务端可查meeting_user表,若存在leave_time IS NULL的记录,说明用户已在房间内,直接复用;若存在leave_time IS NOT NULL的记录,则视为新加入,清除旧记录并插入新行。这避免了同一用户因网络抖动反复创建多个session。

  4. meeting_message表的content字段必须是TEXT:SDP Offer/Answer的JSON字符串动辄上千字符,VARCHAR(255)远远不够。TEXT类型最大可存65535字节,足够容纳绝大多数信令。但要注意:如果未来要支持超大ICE Candidate(如包含大量STUN服务器地址),可能需要MEDIUMTEXT

  5. 初始化数据不是摆设meeting.sql末尾通常有INSERT INTO meeting_room ...语句,插入一条room_id='demo-room'的测试房间。这是为了让你启动后端后,前端无需手动创建房间就能直接JOIN。部署到生产环境前,务必删除或注释掉这些初始化数据,否则所有用户一启动就挤进同一个“demo-room”,体验极差。

提示:执行meeting.sql前,先用mysql -u root -p登录,创建专用数据库:CREATE DATABASE webrtc_meeting DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;。然后USE webrtc_meeting;,再SOURCE /path/to/meeting.sql;。切勿在mysql命令行里直接粘贴长SQL,容易因换行符出错。

3.2 配置文件application.properties:从本地开发到服务器部署的桥梁

application.properties是SpringBoot的“神经系统”,它把硬编码的参数抽离出来,让同一份代码能在不同环境运行。本项目的配置项虽少,但每一处都关乎成败:

# ==================== Spring Boot 核心配置 ====================
spring.application.name=webrtc-video-conference
server.port=8080
# 开发时可开启,生产环境务必关闭
spring.devtools.restart.enabled=true

# ==================== 数据库配置 ====================
# MySQL连接URL,关键!必须替换为你的实际地址
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/webrtc_meeting?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=your_password_here
# HikariCP连接池配置(高性能)
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.max-lifetime=1800000

# ==================== JPA/Hibernate 配置 ====================
# 自动建表(仅开发环境!生产环境必须设为 validate 或 none)
spring.jpa.hibernate.ddl-auto=validate
# SQL日志(调试时打开,生产环境关闭)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# ==================== WebSocket 配置 ====================
# WebSocket端点路径,前端JS里 new WebSocket("ws://ip:port/ws") 的基础
websocket.endpoint=/ws
# 心跳间隔(毫秒),防止NAT超时断连
websocket.heartbeat-interval=30000
# 最大文本消息大小(字节),SDP可能很大
websocket.max-text-message-size=1048576

# ==================== 业务配置 ====================
# 服务器公网IP或域名,前端JS里构建WebSocket URL时必需!
# 部署前必须全局搜索替换!
server.public-ip=127.0.0.1
# 房间最大用户数
meeting.room.max-users=100
# 默认房间名称
meeting.room.default-name=WebRTC Demo Room

实操要点与避坑指南:

  1. spring.datasource.url是生命线jdbc:mysql://127.0.0.1:3306/webrtc_meeting中的127.0.0.1必须改为你的MySQL服务器真实IP。如果是云服务器(如阿里云ECS),不能写localhost127.0.0.1,因为应用和MySQL可能不在同一台机器,需写ECS的内网IP(如172.18.123.45)或外网IP(不推荐,有安全风险)。?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true这些参数是MySQL 8.0+的必需项,缺一不可,否则连接会报错。

  2. spring.jpa.hibernate.ddl-auto的陷阱create会每次启动都删库重建,update会尝试修改表结构(危险!)。validate是最安全的选择——它只校验实体类与数据库表结构是否一致,不执行任何DDL操作。如果发现不一致(如你新增了MeetingRoom.status字段,但数据库表没加status列),启动会直接报错,强迫你手动执行ALTER TABLE生产环境必须设为validatenone

  3. websocket.max-text-message-size必须够大:SDP Offer/Answer的JSON字符串,加上{"type":"OFFER","sdp":"..."}的封装,轻松超过64KB。1048576(1MB)是保守值。如果设得太小(如默认的64KB),前端发Offer时后端会直接关闭连接,日志里显示Max text message size exceeded

  4. server.public-ip是前端通信的命脉:这是本项目部署最关键的一步!前端JavaScript里,创建WebSocket连接的代码类似:
    javascript const wsUrl = `ws://${serverPublicIp}:8080${websocketEndpoint}`; const socket = new WebSocket(wsUrl);
    如果你把后端部署在IP为203.0.113.10的服务器上,server.public-ip就必须是203.0.113.10。否则前端会尝试连接ws://127.0.0.1:8080/ws,这在用户浏览器里永远连不通(127.0.0.1指向用户自己的电脑)。全局搜索替换127.0.0.1为你的服务器IP,是部署前的强制动作。建议用IDE(如IntelliJ IDEA)的Find in Path功能,搜索127.0.0.1,确保application.properties、前端JS、甚至project说明.md里所有地方都改掉。

  5. 连接池配置不是摆设maximum-pool-size=20意味着最多同时20个数据库连接。一个活跃房间平均占用1-2个连接(用户JOIN/LEAVE时查库),100人房间理论上最多需要200连接。所以20是合理的初始值。如果压测时发现数据库连接耗尽(HikariPool-1 - Connection is not available),再逐步调高。

3.3 核心类WebrtcWS与WebrtcRoomServiceImpl深度剖析

现在我们深入到代码的心脏地带,看看这两个类是如何协同工作的。以下代码基于典型实现,细节可能因项目略有差异,但逻辑骨架一致。

WebrtcWS.java 关键片段与原理:
@Component
public class WebrtcWS extends TextWebSocketHandler {

    @Autowired
    private WebrtcRoomServiceImpl roomService;

    // 存储所有活跃的WebSocketSession,key为session.getId()
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 1. 从URL参数或Header中提取用户ID(例如:ws://ip:port/ws?userId=u123)
        String userId = extractUserIdFromSession(session);
        if (userId == null || userId.trim().isEmpty()) {
            session.close(new CloseStatus(CloseStatus.BAD_DATA.getCode(), "Missing userId"));
            return;
        }

        // 2. 将session与userId绑定,存入内存Map(供后续广播用)
        sessions.put(session.getId(), session);

        // 3. 尝试加入一个默认房间(或等待前端发JOIN指令)
        // 这里简化为:直接加入名为"default-room"的房间
        roomService.joinRoom("default-room", session, userId);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        try {
            // 4. 反序列化JSON信令
            SignalingMessage signalingMsg = JSON.parseObject(payload, SignalingMessage.class);

            // 5. 严格的类型校验和必填字段检查
            if (signalingMsg.getType() == null) {
                throw new IllegalArgumentException("Missing 'type' field in signaling message");
            }

            // 6. 根据type分发业务逻辑
            switch (signalingMsg.getType()) {
                case "JOIN":
                    handleJoin(session, signalingMsg);
                    break;
                case "LEAVE":
                    handleLeave(session, signalingMsg);
                    break;
                case "OFFER":
                    handleOffer(session, signalingMsg);
                    break;
                case "ANSWER":
                    handleAnswer(session, signalingMsg);
                    break;
                case "CANDIDATE":
                    handleCandidate(session, signalingMsg);
                    break;
                default:
                    // 未知类型,返回错误
                    sendError(session, "Unknown signaling type: " + signalingMsg.getType());
            }
        } catch (Exception e) {
            // 7. 所有异常必须捕获,避免WebSocket连接意外中断
            log.error("Error handling signaling message from session {}", session.getId(), e);
            sendError(session, "Invalid signaling message: " + e.getMessage());
        }
    }

    private void handleJoin(WebSocketSession session, SignalingMessage msg) {
        String roomId = msg.getRoomId();
        String userId = extractUserIdFromSession(session);
        // 调用服务层加入房间
        roomService.joinRoom(roomId, session, userId);
        // 向该用户发送房间内当前所有用户列表(用于UI渲染)
        List<String> currentUsers = roomService.getCurrentUsersInRoom(roomId);
        sendToSession(session, new SignalingMessage("JOINED", currentUsers));
    }

    // 其他handleXXX方法类似...
}

原理与要点:

  • ConcurrentHashMap存session是广播基础sessions.put(session.getId(), session)这行代码,让后端拥有了“找到任意一个在线用户”的能力。当用户A发Offer,roomService.broadcastToOthers(roomId, userIdA, offerMsg)就会遍历roomService.getRoom(roomId).getUsers(),对每个WebSocketSession调用session.sendMessage()。没有这个Map,广播就是空中楼阁。

  • extractUserIdFromSession()是安全起点:用户ID不能信任前端JS传来的任何东西。最佳实践是从WebSocket握手请求的URL参数(?userId=u123)或Authorization Header(如Bearer Token)中解析。WebrtcWSafterConnectionEstablished时就完成解析,并将userId存入session.getAttributes().put("userId", userId),后续所有handleXXX方法都能通过session.getAttributes().get("userId")安全获取,避免了每次都要解析JSON。

  • 异常捕获是WebSocket的生命线handleTextMessagetry-catch包裹所有逻辑,是强制要求。因为WebSocket连接是长生命周期的,任何未捕获的异常(如JSON解析失败、空指针、数据库超时)都会导致session被Spring框架自动关闭,用户瞬间掉线。捕获后,发送一个友好的{"type":"ERROR","message":"..."}给前端,前端可以优雅提示“信令格式错误,请刷新页面”。

WebrtcRoomServiceImpl.java 关键片段与原理:
@Service
public class WebrtcRoomServiceImpl implements WebrtcRoomService {

    // 内存中管理所有房间
    private final Map<String, WebrtcRoom> rooms = new ConcurrentHashMap<>();

    @Autowired
    private MeetingRoomRepository meetingRoomRepository;

    @Override
    public void joinRoom(String roomId, WebSocketSession session, String userId) {
        // 1. 获取或创建房间
        WebrtcRoom room = getOrCreateRoom(roomId);

        // 2. 检查房间是否满员(线程安全!)
        if (room.getUserCount() >= getMaxUsers()) {
            sendErrorToSession(session, "Room is full");
            return;
        }

        // 3. 将session加入房间(线程安全集合)
        room.addUser(session, userId);

        // 4. 广播"USER_JOINED"事件给房间内其他人
        SignalingMessage joinMsg = new SignalingMessage("USER_JOINED", userId);
        broadcastToOthers(roomId, userId, joinMsg);

        // 5. 持久化:记录用户加入(异步或同步,取决于需求)
        saveUserJoinToDb(roomId, userId);
    }

    @Override
    public void broadcastToOthers(String roomId, String excludeUserId, SignalingMessage message) {
        WebrtcRoom room = rooms.get(roomId);
        if (room == null) return;

        // 6. 遍历房间内所有用户session
        for (Map.Entry<String, WebSocketSession> entry : room.getUserSessions().entrySet()) {
            String targetUserId = entry.getKey();
            WebSocketSession targetSession = entry.getValue();

            // 7. 跳过发送者自己
            if (targetUserId.equals(excludeUserId)) {
                continue;
            }

            try {
                // 8. 发送消息(注意:WebSocketSession不是线程安全的!)
                if (targetSession.isOpen()) {
                    targetSession.sendMessage(new TextMessage(JSON.toJSONString(message)));
                }
            } catch (IOException e) {
                // 9. 目标session可能已关闭,移除它
                log.warn("Failed to send message to user {}, session closed", targetUserId);
                room.removeUser(targetUserId);
            }
        }
    }

    private WebrtcRoom getOrCreateRoom(String roomId) {
        return rooms.computeIfAbsent(roomId, id -> {
            // 10. 初始化新房间,并尝试从数据库加载历史状态(可选)
            WebrtcRoom newRoom = new WebrtcRoom(id);
            // 例如:从DB查出该房间的创建者、名称等,填充newRoom
            return newRoom;
        });
    }
}

原理与要点:

  • computeIfAbsent是线程安全的房间创建rooms.computeIfAbsent(roomId, ...)是ConcurrentHashMap的原子操作。它保证了在高并发下,即使100个用户同时JOIN同一个roomId,也只会创建一个WebrtcRoom实例,避免了竞态条件。

  • room.addUser(session, userId)必须是线程安全的WebrtcRoom内部的用户集合,必须使用ConcurrentHashMap<String, WebSocketSession>CopyOnWriteArrayList。如果用了普通的ArrayList,在joinRoomleaveRoom并发调用时,add()remove()操作可能导致ConcurrentModificationException崩溃。

  • targetSession.sendMessage()前的isOpen()检查是必须的:WebSocket连接可能因网络波动、用户关浏览器而随时断开。targetSession.isOpen()返回false时,强行sendMessage()会抛IllegalStateException。因此,必须先检查,再发送。如果发送失败(IOException),还要及时从roomremoveUser(),否则内存泄漏。

  • broadcastToOthers的性能考量:这是一个O(N)操作(N为房间人数)。当N=100时,要发99次消息。优化方向有两个:一是批量序列化JSON一次(String json = JSON.toJSONString(message)),再循环发送,避免重复序列化;二是引入消息队列(如RabbitMQ),将广播任务异步化,但会增加架构复杂度,对本项目属于过度设计。

4. 实操过程与核心环节实现:从零部署到三人会议

4.1 环境准备与依赖安装:五分钟搞定基础栈

这不是一个需要编译内核的项目,所有依赖都是标准化的。按顺序执行,确保每一步都成功再进行下一步:

  1. Java 11+ JDK:SpringBoot 2.7+要求Java 11或更高版本。检查:
    bash java -version # 输出应类似:openjdk version "11.0.22" 2024-01-16 # 如果没有,去 https://adoptium.net/ 下载Temurin JDK 11

  2. Maven 3.6+:用于构建项目。检查:
    bash mvn -v # 输出应包含 Apache Maven 3.6.x 或更高 # 如果没有,去 https://maven.apache.org/download.cgi 下载并配置PATH

  3. MySQL 5.7+ 或 8.0+:推荐MySQL 8.0,兼容性更好。安装后,启动服务:
    ```bash
    # Ubuntu/Debian
    sudo systemctl start mysql
    sudo systemctl enable mysql

    macOS (Homebrew)

    brew services start mysql

    Windows: 通过服务管理器启动MySQL服务

    ```

  4. Git(可选,用于克隆):如果你是从GitHub下载的zip包,可跳过。否则:
    bash git clone <repository-url> cd nTivOdYlzt3YYsWqOtIm-master-861b815708e9a06d5abc1e2d7ae42bc961de5ecf

注意:所有命令都在终端(Linux/macOS)或命令提示符/PowerShell(Windows)中执行。不要在IDE里点“Run”按钮启动,先确保命令行能成功构建。

4.2 数据库导入与初始化:让数据先活起来

这是最容易出错的一步,务必按顺序、仔细操作:

  1. 创建数据库
    bash mysql -u root -p # 输入密码后进入MySQL命令行 mysql> CREATE DATABASE webrtc_meeting DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql> EXIT;

  2. 导入meeting.sql
    bash # 在项目根目录下执行(确保meeting.sql文件存在) mysql -u root -p webrtc_meeting < meeting.sql # 输入密码,等待执行完成(无输出即成功)

  3. 验证导入结果
    bash mysql -u root -p webrtc_meeting mysql> SHOW TABLES; # 应看到 meeting_room, meeting_user, meeting_message mysql> SELECT * FROM meeting_room; # 应看到至少一条初始化的房间记录(如 demo-room) mysql> EXIT;

常见问题排查:
- ERROR 1064 (42000):SQL语法错误。通常是MySQL版本不匹配(如meeting.sql用MySQL 8.0语法,你用MySQL 5.7执行)。解决方案:用mysqldump --compatible=mysql40重新导出,或手动修改meeting.sqlutf8mb4_0900_as_csutf8mb4_general_ci
- ERROR 1045 (28000):用户名密码错误。确认mysql -u root -p输入的密码正确,或重置root密码。
- Can't connect to local MySQL server:MySQL服务未启动。用sudo systemctl status mysql检查状态。

4.3 配置文件修改:部署前的生死攸关一步

打开src/main/resources/application.properties,逐项修改:

配置项 原始值 修改为 说明
spring.datasource.url jdbc:mysql://127.0.0.1:3306/webrtc_meeting?... jdbc:mysql://YOUR_MYSQL_IP:3306/webrtc_meeting?... YOUR_MYSQL_IP是你的MySQL服务器IP。如果MySQL和Java应用在同一台机器,且用默认端口,可保持127.0.0.1;否则必须改。
spring.datasource.username root your_db_username 创建一个专用数据库用户,权限仅限webrtc_meeting库,比root安全。
spring.datasource.password your_password_here your_actual_password 对应上面的用户名密码。
server.public-ip 127.0.0.1 YOUR_SERVER_PUBLIC_IP 最关键! YOUR_SERVER_PUBLIC_IP是你的Java应用服务器的公网IP或域名(如203.0.113.10meet.example.com)。前端JS会用它构建WebSocket URL。

修改后,保存文件。

提示:用grep -r "127.0.0.1" .命令在项目根目录下全局搜索,确保application.propertiesproject说明.md、甚至前端HTML/JS文件里所有127.0.0.1都被替换成你的服务器IP。漏掉任何一个,前端都无法连接。

4.4 项目构建与启动:见证奇迹的时刻

一切准备就绪,现在构建并启动后端:

  1. 清理并构建(确保使用最新配置):
    bash # 在项目根目录(含pom.xml的目录)执行 mvn clean package -DskipTests # -DskipTests 跳过单元测试,加快构建。首次构建会下载大量依赖,耐心等待。 # 成功后,会在 target/ 目录下生成 webrtc-video-conference-0.0.1-SNAPSHOT.jar

  2. 启动后端服务
    ```bash
    # 方式一:直接运行jar包(推荐,最接近生产环境)
    java -jar target/webrtc-video-conference-0.0.1-SNAPSHOT.jar

    方式二:在IDE中运行(开发调试用)

    在IntelliJ IDEA中,右键 WebrtcVideoConferenceApplication.java -> Run

    ```

  3. 观察启动日志:终端会滚动大量日志,关注以下关键行:
    Tomcat started on port(s): 8080 (http) with context path '' Started WebrtcVideoConferenceApplication in X.XXX seconds (JVM running for Y.YYY)
    出现这两行,说明SpringBoot应用已成功启动,监听在http://localhost:8080(或你的服务器IP:8080)。

  4. 验证WebSocket端点:打开浏览器,访问 http://YOUR_SERVER_IP:8080/actuator/health,应返回{"status":"UP"}。这证明应用健康。WebSocket端点本身无法用浏览器直接访问,但可以用在线工具测试,如:https://www.websocketking.com/ ,填入ws://YOUR_SERVER_IP:8080/ws,点击Connect,如果显示Connected,恭喜,信令通道已打通!

4.5 前端联调与三人会议实战

前端源码是独立的,你需要把它部署到一个HTTP服务器上(不能直接双击HTML打开,会因跨域被浏览器阻止)。最简单的方法是用Python内置服务器:

  1. 启动前端静态服务器(假设前端代码在frontend/目录):
    bash cd frontend # Python 3 python3 -m http.server 8000 # 或 Python 2 python -m SimpleHTTPServer 8000
    此时,前端可通过 http://YOUR_SERVER_IP:8000 访问。

  2. 修改前端配置(关键!):打开前端代码中的config.js或类似文件,找到WebSocket URL配置:
    javascript const WS_URL = `ws://127.0.0.1:8080/ws`; // 错误!这是本地地址 // 必须改为: const WS_URL = `ws://YOUR_SERVER_IP:8080/ws`; // 正确!
    同样,全局搜索替换所有127.0.0.1

  3. 发起三人会议

    • 用户A(主持人):在浏览器打开 http://YOUR_SERVER_IP:8000,点击“创建房间”,输入房间名(如my-meeting),点击确定。页面显示“您是主持人,等待他人加入”。
    • 用户B(参会者):在另一台电脑或手机浏览器打开 http://YOUR_SERVER_IP:8000,点击“加入房间”,输入房间ID my-meeting,点击确定。B的页面应显示A的视频窗口。
    • 用户C(参会者):同上,加入my-meeting。此时,A、B、C三人的浏览器里,应该能看到彼此的视频和听到彼此的声音(前提是麦克风和摄像头权限已授予)。

如果失败,按此顺序排查:
- 检查后端日志:是否有Connection established?是否有Error handling signaling message
- 检查前端浏览器控制台(F12):是否有WebSocket连接失败(net::ERR_CONNECTION_REFUSED)?是否有Failed to execute 'send' on 'WebSocket'?这通常意味着WebSocket URL错了。
- 检查网络:telnet YOUR_SERVER_IP 8080 是否能连通?防火墙是否放行8080端口?
- 检查server.public-ip和前端WS_URL是否完全一致?

5. 常见问题与排查技巧实录:那些年踩过的坑

5.1 WebSocket连接失败:从ERR_CONNECTION_REFUSEDERR_CONNECTION_CLOSED_BEFORE_HEADERS

这是新手遇到的第一个拦路虎,90%的原因都出在配置上。

现象 可能原因 排查与解决
net::ERR_CONNECTION_REFUSED 后端服务根本没起来,或端口不对 1. ps aux | grep java 看Java进程是否存在;2. netstat -tuln | grep :8080 看8080端口是否被监听;3. 检查application.propertiesserver.port是否被改成了其他值(如8081),而前端连的还是8080。
net::ERR_CONNECTION_TIMED_OUT 网络不通,或防火墙拦截 1. 在服务器上执行 curl http://localhost:8080/actuator/health,如果返回{"status":"UP"},说明服务OK;2. 在客户端执行 telnet YOUR_SERVER_IP 8080,如果连接失败,检查云服务器安全组(阿里云/腾讯云)是否开放了8080端口,以及本地防火墙(Windows Defender防火墙)是否允许入站连接。
WebSocket connection to 'ws://...' failed: Error in connection establishment: net::ERR_CONNECTION_CLOSED_BEFORE_HEADERS HTTPS页面尝试连接HTTP WebSocket(混合内容) 这是现代浏览器的安全策略。如果你的前端是通过https://example.com访问的,那么WebSocket URL必须是wss://(WebSocket Secure),而不是ws://。解决方案:为你的域名配置SSL证书(如Let’s Encrypt),并在SpringBoot中启用HTTPS,或干脆让前端也通过HTTP访问(不推荐生产环境)。
WebSocket连接成功,但收不到任何消息 server.public-ip配置错误,或前端JS里URL拼接错误 1. 在后端WebrtcWS.afterConnectionEstablished()方法里加一行log.info("New connection from: {}", session.getRemoteAddress());,看日志里打印的IP是不是你的客户端IP;2. 在前端浏览器控制台,console.log(WS_URL),确认输出的URL和你在application.properties里写的server.public-ip完全一致(包括协议ws://、IP、端口、路径/ws)。

5.2 信令消息丢失或乱序:为什么Offer发出去,Answer收不到?

WebSocket本身是可靠、有序的,消息丢失一定是应用层逻辑出了问题。

现象 可能原因 排查与解决
用户A发了OFFER,用户B的控制台没有任何日志,WebrtcWS.handleTextMessage()没被调用 用户B的WebSocket Session已失效,但内存中未清理 1. 检查WebrtcRoomServiceImpl.broadcastToOthers()方法,在targetSession.sendMessage()IOException后,是否执行了room.removeUser(targetUserId)?如果没有,这个“僵尸session”会一直占着内存,广播时不断尝试向它发消息,最终超时失败。解决方案:确保catch (IOException e)块里有清理逻辑。
用户A发OFFER,用户B收到了,但peerConnection.setRemoteDescription()报错 SDP内容被篡改或格式错误 1. 在WebrtcWS.handleTextMessage()里,log.info("Received OFFER from {}: {}", userId, signalingMsg.getContent());,把收到的原始SDP打出来;2. 在用户B的浏览器控制台,console.log(signalingMsg.content),对比两端是否完全一致。常见错误:后端JSON序列化时,sdp字段里的\r\n被转义成\\r\\n,导致SDP格式损坏。解决方案:确保SignalingMessage类的sdp字段是String类型,不要用JSONObject或其他会自动转义的类型。
房间里只有两人时,一切正常;三人以上时,部分用户收不到CANDIDATE broadcastToOthers性能瓶颈,消息发送太慢导致超时 1. 检查websocket.max-text-message-size是否足够大;2. 在broadcastToOthers循环里,给sendMessage()try-catch,并记录发送耗时:long start = System.currentTimeMillis(); targetSession.sendMessage(...); log.debug("Send to {} took {}ms", targetUserId, System.currentTimeMillis()-start);。如果某次发送耗时超过5秒,说明网络或session有问题,应立即removeUser

5.3 数据库相关问题:从Table not foundDeadlock found

现象 可能原因 排查与解决
启动时报错 Table 'webrtc_meeting.meeting_room' doesn't exist meeting.sql没执行,或执行到了错误的数据库 1. mysql -u root -p -e "USE webrtc_meeting; SHOW TABLES;",确认表是否存在;2. 检查spring.datasource.url里的数据库名webrtc_meeting是否和你创建的数据库名完全一致(区分大小写!)。
Caused by: com.mysql.cj.jdbc.exceptions.MysqlDataTruncation: Data truncation: Data too long for column 'sdp' at row 1 meeting_message.content字段太小,存不下SDP 1. mysql -u root -p webrtc_meeting -e "DESCRIBE meeting_message;",查看content字段类型;2. 如果是VARCHAR(255),执行 ALTER TABLE meeting_message MODIFY content TEXT; 改为TEXT
Deadlock found when trying to get lock 高并发下,多个线程同时操作同一房间的用户列表 1. 这通常发生在joinRoomleaveRoom并发时,都试图修改WebrtcRoom内部的CopyOnWriteArrayList;2. 解决方案:在WebrtcRoom类的关键方法(如addUser, removeUser)上加synchronized关键字,或者使用ReentrantLock。虽然会牺牲一点性能,但保证了数据一致性,对百人规模的会议系统完全可接受。

5.4 实操心得:那些文档里不会写的“潜规则”

  • “全局替换IP”不是一句口号,而是一场战役:我见过最惨的一次,学生在application.properties里改了IP,但在前端index.html里还留着ws://127.0.0.1:8080/ws,又在project说明.md里写着“请修改IP”,他自己照着文档改了一遍,结果改的是文档里的IP……最后花了三小时才定位。我的做法是:在项目根目录执行 grep -r "127.0.0.1" . --include="*.java" --include="*.properties" --include="*.js" --include="*.html",把所有匹配行都列出来,挨个修改。

  • 不要迷信@Async:看到“广播慢”,第一反应是加@Async让广播异步化。但这是个陷阱!@Async方法默认使用SimpleAsyncTaskExecutor,每次调用都新建一个线程,100人房间就会创建100个线程,CPU直接飙到100%。正确的异步化是用ThreadPoolTaskExecutor,并设置合理的线程池大小(如corePoolSize=5, maxPoolSize=10),但这增加了复杂度。对于学习项目,优先保证正确性,用同步广播;等你真要做万人会议时,再研究Kafka或Redis Pub/Sub。

  • 日志是你的第二双眼睛:在WebrtcWS.handleTextMessage()开头加log.debug("Received from {}: {}", session.getId(), payload);,在broadcastToOthers里加log.debug("Broadcasting {} to {} users in room {}", message.getType(), room.getUserCount(), roomId);。当问题发生时,这些日志能让你在10秒内定位到是哪个环节断了,而不是对着空白屏幕发呆。

  • “能跑通”和“能上线”之间,隔着一个Nginx:本地开发用http://localhost:8000ws://localhost:8080/ws没问题,但生产环境必须用域名。这时,你需要Nginx做反向代理,把https://meet.example.com的HTTP请求代理到后端8000端口,把wss://meet.example.com/ws的WebSocket请求代理到后端8080端口。Nginx配置里必须有proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;这两行,否则WebSocket握手会失败。这部分知识,已经超出了本项目的范围,但它是走向生产环境的必经之路。

最后再分享一个小技巧:当你想快速验证信令逻辑是否正确,不必每次都打开三个浏览器。用wscat这个命令行WebSocket客户端,可以模拟任意用户:

# 安装
npm install -g wscat

# 模拟用户A加入房间
wscat -c "ws://YOUR_SERVER_IP:8080/ws?userId=userA"
> {"type":"JOIN","roomId":"test-room"}

# 模拟用户B加入同一房间
wscat -c "ws://YOUR_SERVER_IP:8080/ws?userId=userB"
> {"type":"JOIN","roomId":"test-room"}

这样,你可以在一个终端里,用纯文本的方式,像调试API一样调试信令,效率提升十倍。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:基于SpringBoot搭建的实时音视频会议系统,后端用WebSocket处理房间创建、用户加入、状态同步等信令逻辑,核心类包括WebrtcWS处理器和WebrtcRoomServiceImpl服务;数据库采用MySQL,附带meeting.sql建表脚本与初始数据,支持快速导入;配置文件application.properties中已预留数据库连接参数,部署前需修改为实际地址及服务器IP;前端源码独立提供,可直接联调;项目结构标准,含Maven配置(pom.xml)、资源目录(resources)、主启动类、单元测试支持;配套项目说明.md文档详细列出环境准备、数据库导入、配置替换、服务启动及常见问题排查步骤;适用于毕业设计、课程实践或Java全栈学习,覆盖WebRTC信令流程、SpringBoot集成WebSocket、实时通信系统开发等关键技能点。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐